Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Players can join games for free

Summary

When a game is created using RockPaperScissors::createGameWithToken(), players can call RockPaperScissors::joinGameWithEth() with no value to join the game for free.

Vulnerability Details

When a user calls RockPaperScissors::createGameWithToken(), it sets game.bet = 0.

function createGameWithToken(uint256 _totalTurns, uint256 _timeoutInterval) external returns (uint256) {
require(winningToken.balanceOf(msg.sender) >= 1, "Must have winning token");
require(_totalTurns > 0, "Must have at least one turn");
require(_totalTurns % 2 == 1, "Total turns must be odd");
require(_timeoutInterval >= 5 minutes, "Timeout must be at least 5 minutes");
// Transfer token to contract
winningToken.transferFrom(msg.sender, address(this), 1);
uint256 gameId = gameCounter++;
Game storage game = games[gameId];
game.playerA = msg.sender;
@> game.bet = 0; // Zero ether bet because using token
game.timeoutInterval = _timeoutInterval;
game.creationTime = block.timestamp;
game.joinDeadline = block.timestamp + joinTimeout;
game.totalTurns = _totalTurns;
game.currentTurn = 1;
game.state = GameState.Created;
emit GameCreated(gameId, msg.sender, 0, _totalTurns);
return gameId;
}

This means that another user can call RockPaperScissors::joinGameWithEth() with {value: 0} and join the game for free.

Add the following code to the test suite:

function testPlayerBCanJoinTokenGameForFree() public {
// playerA creates a game with token.
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
console2.log("playerB token balance before joining: ", token.balanceOf(playerB));
console2.log("playerB ETH balance before joining: ", playerB.balance);
// This line confirms playerB joined the game.
vm.expectEmit();
emit PlayerJoined(gameId, address(playerB));
// playerB joins game by calling joinGameWithEth with no value.
game.joinGameWithEth{value: 0}(gameId);
// playerB's balance has not changed.
console2.log("playerB token balance after joining: ", token.balanceOf(playerB));
console2.log("playerB ETH balance before joining: ", playerB.balance);
}

The output is:

Ran 1 test for test/RockPaperScissorsTest.t.sol:RockPaperScissorsTest
[PASS] testPlayerBCanJoinTokenGameForFree() (gas: 244672)
Logs:
playerB token balance before joining: 10
playerB ETH balance before joining: 10000000000000000000
playerB token balance after joining: 10
playerB ETH balance before joining: 10000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.40ms (1.86ms CPU time)

Impact

Anyone can join games created with RockPaperScissors::createGameWithToken for free and play with zero risk, effectively gaming the protocol.

Tools Used

Manual review

Recommendations

Require game.bet > 0 when calling RockPaperScissors::joinGameWithEth.

function joinGameWithEth(uint256 _gameId) external payable {
Game storage game = games[_gameId];
require(game.state == GameState.Created, "Game not open to join");
require(game.playerA != msg.sender, "Cannot join your own game");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
+ require(game.bet > 0, "Bet amount cannot be zero");
require(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
Updates

Appeal created

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Game Staking Inconsistency

joinGameWithEth function lacks a check to verify the game was created with ETH

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Game Staking Inconsistency

joinGameWithEth function lacks a check to verify the game was created with ETH

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.