Description
The joinGameWithToken
function allows any user to join an existing game by supplying the correct gameId
and bet amount(1 token). However, there is no check to ensure that the playerB
slot has not already been filled. This allows any user to repeatedly overwrite the playerB
address for a given gameId, leading to corrupted or inconsistent game state.
Since playerB
is set unconditionally, this behavior breaks the expected one-to-one mapping between players and games. It also introduces the risk of race conditions where multiple users can attempt to join the same game at the same time — the last transaction mined wins, overwriting the others silently.
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");
winningToken.transferFrom(msg.sender, address(this), 1);
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
There is no check like require(game.playerB == address(0))
, so this function allows playerB
to be overwritten any number of times before playerA
reveals their hand.
Impact
State Corruption and Game Hijacking: A malicious user can repeatedly overwrite the playerB field, breaking the integrity of the game logic.
Loss of Funds: Original playerB
will loose their staked ETH as there is no method to get the funds back after being overwritten.
Proof of Concepts
function testPlayerBOverwriteInJoinGameWithToken() public {
address playerC = makeAddr("playerC");
vm.prank(address(game));
token.mint(playerC, 10);
vm.startPrank(playerA);
token.approve(address(game), 1);
uint256 gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
(address storedPlayerA, address storedPlayerB,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerB, playerB);
assertEq(token.balanceOf(playerB), 9);
assertEq(token.balanceOf(address(game)), 2);
assertEq(token.balanceOf(playerA), 9);
vm.startPrank(playerC);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
(address storedPlayerA2, address storedPlayerB2,,,,,,,,,,,,,, RockPaperScissors.GameState state2) =
game.games(gameId);
assertEq(storedPlayerA2, playerA);
assertEq(storedPlayerB2, playerC);
assertEq(token.balanceOf(playerC), 9);
assertEq(token.balanceOf(address(game)), 3);
}
1. Player A creates a game with token(gameId = 0xabc...)
2. Player B joins with 1 token
3. Player C calls joinGameWithToken with same gameId, also sending 1 token
Result: game.playerB now equals Player C, not B
Recommended mitigation
function joinGameWithToken(uint256 _gameId) external {
Game storage game = games[_gameId];
+ require(game.playerB == address(0), "Game already joined");
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);
}