Summary
The MysteryBox::claimAllRewards function iterates through every reward owned by a player, which may consume an excessive amount of gas, should this reward list grow too large. This can result in a denial or service, due to out-of-gas errors.
Vulnerability Details
As can be seen below, MysteryBox::claimAllRewards iterates through every Reward owned by the player.
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];
}
This includes the Coal reward type, which has a value of 0 ether and therefore bloats the for loop with unnecessary iterations. This is highlighted in the code excerpt below:
function openBox() public {
require(boxesOwned[msg.sender] > 0, "No boxes to open");
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));
}
boxesOwned[msg.sender] -= 1;
}
Therefore, should a player allow the rewardsOwned list to grow to large, by buying and opening a large number of mystery boxes without claiming the underlying rewards, they may encounter out-of-gas errors, resulting in a denial of service.
Impact
Players with a large amount of rewardsOwned may not be able to claim all their rewards in one transaction using the MysteryBox::claimAllRewards function as intended.
Tools used
Manual review
Recommended Mitigation
An additional mapping should be added to the MysteryBox contract that maps the address of the player to the value accrued by all their rewards owned. The MysteryBox::openBox function should update this mapping, which can be used rather than looping through an array of the rewards owned by the player.
These changes can be observed below:
mapping(address => uint256) totalValueEarned;
...
function openBox() public {
require(boxesOwned[msg.sender] > 0, "No boxes to open");
// Generate a random number between 0 and 99
uint256 randomValue = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % 100;
// Determine the reward based on probability
if (randomValue < 75) {
// 75% chance to get Coal (0-74)
rewardsOwned[msg.sender].push(Reward("Coal", 0 ether));
// no need to update as reward is worth 0 eth
} else if (randomValue < 95) {
// 20% chance to get Bronze Coin (75-94)
rewardsOwned[msg.sender].push(Reward("Bronze Coin", 0.1 ether));
+ totalValueEarned[msg.sender] += 0.1 ether;
} else if (randomValue < 99) {
// 4% chance to get Silver Coin (95-98)
rewardsOwned[msg.sender].push(Reward("Silver Coin", 0.5 ether));
+ totalValueEarned[msg.sender] += 0.5 ether;
} else {
// 1% chance to get Gold Coin (99)
rewardsOwned[msg.sender].push(Reward("Gold Coin", 1 ether));
+ totalValueEarned[msg.sender] += 1 ether;
}
boxesOwned[msg.sender] -= 1;
}
...
function claimAllRewards() public {
uint256 totalValue = totalValueEarned[msg.sender];
require(totalValue > 0, "No rewards to claim");
(bool success,) = payable(msg.sender).call{value: totalValue}("");
require(success, "Transfer failed");
delete rewardsOwned[msg.sender];
+ totalValueEarned[msg.sender] = 0;
}