Mystery Box

First Flight #25
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: medium
Valid

Potential for Insufficient Funds to pay out rewards

Summary

The MysteryBox contract lacks mechanisms to ensure that it maintains sufficient funds to cover all potential reward payouts. This could lead to a situation where users are unable to claim their earned rewards due to insufficient contract balance. The contract does not perform balance checks before distributing rewards, potentially leaving some users unable to receive their winnings if the contract becomes underfunded.

Vulnerability Details

The MysteryBox contract has several functions that can lead to insufficient funds for reward payouts. The key vulnerable areas are:

In the constructor, the initial seed value is insufficient to cover the initial reward pool:

constructor() payable {
owner = msg.sender;
boxPrice = 0.1 ether;
@> require(msg.value >= SEEDVALUE, "Incorrect ETH sent");
// Initialize with some default rewards
@> rewardPool.push(Reward("Gold Coin", 0.5 ether));
@> rewardPool.push(Reward("Silver Coin", 0.25 ether));
@> rewardPool.push(Reward("Bronze Coin", 0.1 ether));
@> rewardPool.push(Reward("Coal", 0 ether));
}

The SEEDVALUE (0.1 ether) is less than the total value of initial rewards (0.85 ether), potentially leading to claim failures from the start.

In the addReward function, new rewards are added without providing corresponding value:

function addReward(string memory _name, uint256 _value) public {
require(msg.sender == owner, "Only owner can add rewards");
@> rewardPool.push(Reward(_name, _value));
}

This can lead to the contract being unable to pay out these new rewards.

The openBox function assigns rewards without checking the contract balance:

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));
} else if (randomValue < 95) {
// 20% chance to get Bronze Coin (75-94)
@> rewardsOwned[msg.sender].push(Reward("Bronze Coin", 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));
} else {
// 1% chance to get Gold Coin (99)
@> rewardsOwned[msg.sender].push(Reward("Gold Coin", 1 ether));
}
boxesOwned[msg.sender] -= 1;
}

The claimAllRewards function doesn't check if the contract has sufficient balance before attempting to transfer:

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];
}

Similarly, claimSingleReward also lacks a balance check:

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];
}

These issues stem from the absence of proper reward record management and balance checks throughout the contract.

Impact

The lack of proper fund management and balance checks in the MysteryBox contract can lead to several severe consequences:

  1. Immediate Insolvency: From the moment of deployment, the contract may be unable to fulfill its reward obligations due to insufficient initial funding. This undermines the core functionality and trustworthiness of the system from the start.

  2. Broken User Expectations: Users who successfully open boxes and receive high-value rewards may find themselves unable to claim these rewards due to insufficient contract balance. This can lead to frustration, loss of trust, and potential reputational damage to the platform.

  3. Unfair Reward Distribution: The "first-come, first-served" nature of the current implementation means that early claimants may receive their rewards while later claimants are left with nothing, even if they have legitimately earned rewards of equal or greater value.

  4. Potential for Exploitation: Malicious actors could exploit this vulnerability by front-running transactions or timing their claims to drain the contract's balance, leaving other legitimate users unable to claim their rewards.

The cumulative effect of these impacts could be catastrophic for the project, potentially leading to its complete failure and loss of all invested funds and user trust.

Proof-of-Concept

To demonstrate the vulnerability of insufficient funds for reward payouts, we've created a test case that simulates a user obtaining a high-value reward and then attempting to claim it when the contract has insufficient balance. This test has been implemented in the MysteryBoxTest contract:

function testClaimRewardsInsufficientFunds() public {
vm.deal(user1, 1 ether);
vm.startPrank(user1);
// User buys a box
mysteryBox.buyBox{value: 0.1 ether}();
// Manipulate reward for testing (simulating a high-value reward)
uint256 desiredRandomValue = 99; // Aiming for Gold Coin
uint256 manipulatedTimestamp = block.timestamp;
while (uint256(keccak256(abi.encodePacked(manipulatedTimestamp, user1))) % 100 != desiredRandomValue) {
manipulatedTimestamp++;
}
// Open the box with the manipulated timestamp
vm.warp(manipulatedTimestamp);
mysteryBox.openBox();
// Check if we got the desired reward
MysteryBox.Reward[] memory rewards = mysteryBox.getRewards();
assertEq(rewards[0].name, "Gold Coin", "Failed to manipulate reward to Gold Coin");
// User tries to claim single reward
vm.expectRevert("Transfer failed");
mysteryBox.claimSingleReward(0);
// User tries to claim all rewards
vm.expectRevert("Transfer failed");
mysteryBox.claimAllRewards();
vm.stopPrank();
}

This test case demonstrates the following:

  1. A user buys a mystery box for 0.1 ether.

  2. We manipulate the timestamp to ensure the user receives a high-value "Gold Coin" reward when opening the box. This simulates a legitimate scenario where a user gets lucky and receives a valuable reward.

  3. The test verifies that the user indeed received the "Gold Coin" reward.

  4. When the user attempts to claim the single reward (claimSingleReward), the transaction reverts with "Transfer failed". This is because the contract doesn't have sufficient funds to pay out the high-value reward.

  5. Similarly, when the user tries to claim all rewards (claimAllRewards), the transaction also reverts due to insufficient funds.

This proof of concept clearly illustrates that the contract can assign rewards that it cannot pay out, leading to a situation where users are unable to claim their legitimately earned rewards due to insufficient contract balance.

To run this test, use the following command:

forge test --mc MysteryBoxTest --mt testClaimRewardsInsufficientFunds -vv

the test output is attached here:

Ran 1 test for test/TestMysteryBox.t.sol:MysteryBoxTest
[PASS] testClaimRewardsInsufficientFunds() (gas: 127943)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 997.54µs (216.29µs CPU time)

The test will pass, confirming that the contract indeed fails to pay out rewards due to insufficient funds, despite users having legitimately earned these rewards through normal contract interactions.

Tools Used

  • Manual review of the smart contract code

  • Foundry for writing and running test cases to validate the vulnerability

Recommendations

To address the issue of insufficient funds for reward payouts, we recommend implementing the following measures:

  1. Implement a Reserve System:
    Create a reserve fund within the contract that sets aside a portion of each box purchase. This reserve should be used exclusively for paying out rewards, ensuring that the contract always has sufficient funds to cover potential winnings.

  2. Implement a Dynamic Pricing System:
    Adjust the box price dynamically based on the current reward pool and contract balance. Ensure that the price of boxes is always sufficient to maintain an adequate reserve for potential payouts.

  3. Implement a Withdrawal Queue:
    In cases where the contract temporarily lacks sufficient funds for immediate payout, implement a queue system for withdrawals. This allows users to claim their rewards in order as funds become available, rather than having their transactions revert.

  4. Emergency Pause Mechanism:
    Implement a mechanism that allows the contract owner to pause reward distributions in emergency situations, such as when the contract balance falls critically low.

By implementing these recommendations, the contract can better manage its funds, ensure it always has sufficient balance to pay out rewards, and provide a more reliable and fair experience for users. It's crucial to thoroughly test these implementations to ensure they work as intended and do not introduce new vulnerabilities.

Updates

Appeal created

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Protocol should have a higher initial balance to prevent prize withdrawing problems

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.