🌳 Advanced ⏱️ 35 min

Randomness Vulnerabilities

Randomness is fundamentally incompatible with blockchain's deterministic, public execution model. Every node running the EVM must reach the same result for the same transaction. This means any "random" value derived from block state — timestamp, difficulty, hash — is either predictable by miners/validators, or knowable by other nodes before your transaction is included. Games, lotteries, NFT trait assignment, and any protocol using on-chain randomness are vulnerable.

🚨 Critical

There is no true randomness on a public blockchain. Every value derived from block state (block.timestamp, block.prevrandao, blockhash) can be influenced by validators or predicted by sophisticated attackers. The only secure source of on-chain randomness is Chainlink VRF — all other approaches are exploitable.

Why Blockchain Randomness Is Broken

SourceWho Can Predict/Manipulate?Manipulation WindowSecurity
block.timestampValidators (within ~12 sec)±12 seconds per blockNone
block.prevrandaoValidators (can choose to skip)One block aheadLow
blockhash(n)Miners (pre-Merge), predictable after 256 blocksUp to 256 blocksNone
keccak256(block vars)Same as the variables usedSame as inputsNone
Commit-revealParticipants can grief (not reveal)Between commit and revealMedium
Chainlink VRFWould require corrupting Chainlink oracle networkNone practicalHigh

Vulnerable Pattern 1: block.timestamp as Randomness

⚠️ block.timestamp Manipulation
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract TimestampLottery { uint256 public ticketPrice = 0.1 ether; // ❌ block.timestamp is set by the validator who proposes the block // Validators can choose timestamps within ~12 seconds of the parent block // A validator can keep trying timestamps until block.timestamp % 2 == 0 function buyTicket() external payable { require(msg.value == ticketPrice, "Wrong price"); // ❌ "Random" outcome based on manipulable block.timestamp if (block.timestamp % 2 == 0) { // Winner! Gets 2x back (bool ok,) = msg.sender.call{value: 0.2 ether}(""); require(ok); } // Validator who wins the lottery: just propose a block with winning timestamp } // ❌ Also wrong: keccak256 of timestamp + sender — all inputs are known function badRandom() external view returns (uint256) { return uint256(keccak256(abi.encodePacked( block.timestamp, // ← Manipulable msg.sender, // ← Known block.number // ← Known ))); // An attacker can compute this before submitting their transaction // and only transact when the result is favorable } }

Vulnerable Pattern 2: blockhash

⚠️ blockhash Manipulation and Staleness
contract BlockhashLottery { mapping(address => uint256) public commitBlock; function commit() external payable { require(msg.value == 0.1 ether); commitBlock[msg.sender] = block.number; } function reveal() external { uint256 committed = commitBlock[msg.sender]; require(committed > 0, "Must commit first"); require(block.number > committed, "Wait one block"); // ❌ Bug 1: blockhash returns 0 for blocks older than 256 // If user waits >256 blocks to reveal, blockhash = 0 // keccak256(0) is predictable — always produces the same result bytes32 randomHash = blockhash(committed); // ❌ Bug 2: Even within 256 blocks, miners (pre-Merge) could // choose to withhold a winning block and try again // (Less practical post-Merge but still a design flaw) if(uint256(randomHash) % 2 == 0) { (bool ok,) = msg.sender.call{value: 0.2 ether}(""); require(ok); } commitBlock[msg.sender] = 0; } }

Commit-Reveal Scheme: How It Works and Its Limits

🔒 Commit-Reveal — Two-Phase Randomness
contract CommitRevealRandom { mapping(address => bytes32) public commits; uint256 public combinedEntropy; bool public revealed; // PHASE 1: Each participant commits to a secret number // They submit: keccak256(secret) — the number is hidden function commit(bytes32 commitment) external { require(commits[msg.sender] == bytes32(0), "Already committed"); commits[msg.sender] = commitment; } // PHASE 2: Each participant reveals their secret // Contract verifies it matches the commitment, XORs into combined entropy function reveal(uint256 secret) external { bytes32 commitment = keccak256(abi.encodePacked(secret)); require(commits[msg.sender] == commitment, "Wrong secret"); combinedEntropy ^= secret; // XOR all secrets together commits[msg.sender] = bytes32(0); } // ⚠️ LIMITATION 1: Last revealer controls the outcome // They can see everyone else's reveals and choose whether to reveal // Only reveal if combinedEntropy XOR theirSecret gives a winning number // If not favorable, don't reveal (forfeit deposit, grief the system) // ⚠️ LIMITATION 2: Participants can grief by never revealing // Forces a timeout and fallback mechanism // ⚠️ LIMITATION 3: Two-transaction UX friction — bad for games }

Chainlink VRF: The Right Way

✅ Chainlink VRF v2 Implementation
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol"; import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol"; contract SecureLottery is VRFConsumerBaseV2 { VRFCoordinatorV2Interface immutable coordinator; uint64 immutable subscriptionId; bytes32 immutable keyHash; uint32 constant CALLBACK_GAS_LIMIT = 100000; uint16 constant REQUEST_CONFIRMATIONS = 3; uint32 constant NUM_WORDS = 1; mapping(uint256 => address) public requestToPlayer; mapping(uint256 => uint256) public requestToResult; constructor(address _coordinator, uint64 _subscriptionId, bytes32 _keyHash) VRFConsumerBaseV2(_coordinator) { coordinator = VRFCoordinatorV2Interface(_coordinator); subscriptionId = _subscriptionId; keyHash = _keyHash; } // Step 1: Player enters, VRF request is sent to Chainlink function enter() external payable returns (uint256 requestId) { require(msg.value == 0.1 ether); // Request random words from Chainlink VRF requestId = coordinator.requestRandomWords( keyHash, subscriptionId, REQUEST_CONFIRMATIONS, CALLBACK_GAS_LIMIT, NUM_WORDS ); requestToPlayer[requestId] = msg.sender; } // Step 2: Chainlink calls this with verified random number (1-3 blocks later) // This is called by the VRF Coordinator — cannot be front-run or manipulated function fulfillRandomWords( uint256 requestId, uint256[] memory randomWords ) internal override { address player = requestToPlayer[requestId]; uint256 randomResult = randomWords[0] % 100; // 0-99 requestToResult[requestId] = randomResult; // ✅ Player wins if result < 50 (50% chance) // randomWords[0] is generated off-chain with verifiable proof // The Chainlink oracle network must be compromised to manipulate this if (randomResult < 50) { (bool ok,) = player.call{value: 0.2 ether}(""); require(ok); } } }
⚠️ Common VRF Mistakes

Using VRF doesn't automatically fix all randomness issues. Common mistakes: (1) Requesting VRF randomness and making a decision in the same transaction — the request and fulfillment are separate. (2) Not validating that requestId in fulfillRandomWords came from your contract. (3) Re-using the same randomness word for multiple independent decisions — derive separate values from the single random word by hashing it with different indices.

Foundry Test: Randomness Vulnerability Detection

🧪 Foundry Test — Predict and Exploit blockhash Randomness
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; contract RandomnessExploitTest is Test { BlockhashLottery public lottery; function setUp() public { lottery = new BlockhashLottery(); vm.deal(address(lottery), 10 ether); } /// @notice Demonstrates that blockhash can be computed by an attacker contract function test_blockhashExploit() public { address attacker = makeAddr("attacker"); vm.deal(attacker, 1 ether); vm.prank(attacker); lottery.commit{value: 0.1 ether}(); uint256 committedBlock = block.number; // Roll forward one block so the block hash is available vm.roll(block.number + 1); // In same tx, the attacker contract can read blockhash(committedBlock) // and only call reveal() if the result is favorable bytes32 knownHash = blockhash(committedBlock); bool wouldWin = uint256(knownHash) % 2 == 0; if (wouldWin) { uint256 balanceBefore = attacker.balance; vm.prank(attacker); lottery.reveal(); assertGt(attacker.balance, balanceBefore, "Attacker should win"); } else { // Don't reveal — forfeit 0.1 ETH to avoid losing // In practice, only reveal when you know you'll win } } }

How Auditors Identify Randomness Bugs

💡 Tip

When auditing a contract, search for: any use of block.timestamp, block.prevrandao, blockhash, or block.number in a keccak256, a modulo operation, or a conditional. If the output affects which address wins money, which NFT gets a rare trait, or any other valuable outcome — it's a vulnerability. The fix is always Chainlink VRF for genuine randomness needs.

🔍 Detection Patterns to Grep For
// Slither command to find timestamp dependence: slither . --detect timestamp // Manual grep for suspicious randomness patterns: grep -rn "block.timestamp %" src/ grep -rn "blockhash" src/ grep -rn "block.prevrandao" src/ grep -rn "keccak256.*block\." src/ // Patterns that are ALWAYS wrong for security-critical randomness: // uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) ← predictable // block.timestamp % n ← manipulable by validator // blockhash(block.number - 1) ← miner withholding attack // block.prevrandao % n ← validator grinding attack

Real-World Exploits

ProtocolYearBugImpact
Meebits NFT2021blockhash for trait assignmentAttacker minted rare traits by predicting/controlling blockhash before reveal
Fomo3D2018block.timestamp lotteryKey round outcomes could be influenced by timestamp manipulation
SmartBillions2017blockhash predictabilityContract offered jackpot; attacker predicted blockhash to always win
Roast Football2018block.number randomnessOutcome predictable from block data; lottery repeatedly exploited

Key Takeaways

  • There is no secure on-chain randomness without an external oracle. block.timestamp, blockhash, block.prevrandao — all are manipulable or predictable by validators.
  • Chainlink VRF is the correct solution. It generates randomness off-chain with a cryptographic proof that can be verified on-chain. Validators cannot manipulate it because the random number is committed before the transaction.
  • Commit-reveal provides partial protection but has griefing vectors. The last revealer can withhold their reveal to change the outcome. Use it only for non-high-stakes randomness where griefing is tolerable.
  • keccak256 of block variables is not randomness — it is obscured predictability. The inputs are all known to sophisticated attackers. Hashing known values does not create unknown values.
  • When auditing, treat all block-state modulo arithmetic as critical vulnerabilities. Any code pattern of the form (block.timestamp % n), (blockhash(x) % n), or keccak256(block.*) % n controlling financial outcomes is exploitable.

Commit-Reveal Improvements and Best Practices

✅ Improved Commit-Reveal with Griefing Penalty
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract ImprovedCommitReveal { struct Commitment { bytes32 hash; uint256 blockNumber; bool revealed; } mapping(address => Commitment) public commitments; uint256 public constant REVEAL_WINDOW = 100; // blocks to reveal uint256 public constant DEPOSIT = 0.1 ether; // griefing penalty function commit(bytes32 secretHash) external payable { require(msg.value == DEPOSIT, "Wrong deposit"); require(commitments[msg.sender].hash == bytes32(0), "Already committed"); commitments[msg.sender] = Commitment({ hash: secretHash, blockNumber: block.number, revealed: false }); } function reveal(uint256 secret) external { Commitment storage c = commitments[msg.sender]; require(c.hash != bytes32(0), "No commitment"); require(!c.revealed, "Already revealed"); require(block.number > c.blockNumber, "Too early"); require(block.number <= c.blockNumber + REVEAL_WINDOW, "Window expired"); require( c.hash == keccak256(abi.encodePacked(secret, msg.sender)), "Wrong secret" ); c.revealed = true; // Return deposit to honest revealer (bool ok,) = msg.sender.call{value: DEPOSIT}(""); require(ok); // Use secret as entropy source _processOutcome(secret); } // If participant doesn't reveal in REVEAL_WINDOW blocks: // Anyone can slash them — they lose their deposit // This penalizes griefing (selective reveal) function slashExpired(address participant) external { Commitment storage c = commitments[participant]; require(c.hash != bytes32(0)); require(!c.revealed); require(block.number > c.blockNumber + REVEAL_WINDOW, "Window not expired"); delete commitments[participant]; // Slash deposit — goes to contract treasury or slasher (bool ok,) = msg.sender.call{value: DEPOSIT / 2}(""); require(ok); } function _processOutcome(uint256 secret) internal { /* use secret as entropy */ } }

block.prevrandao: Post-Merge Randomness Source

⚠️ block.prevrandao — Better But Still Manipulable
// After Ethereum's Merge (Sept 2022), block.difficulty was renamed block.prevrandao // It now contains the RANDAO reveal value from the beacon chain // RANDAO: validators XOR their secrets together to produce randomness // Is block.prevrandao secure? // BETTER than block.timestamp: not directly manipulable by timing // WORSE than Chainlink VRF: validators can still manipulate it // Validator manipulation of RANDAO: // In the last slot of each epoch, the proposing validator knows the RANDAO value // They can choose to reveal or not reveal their contribution // "Last revealer" has ~1 bit of influence over the RANDAO value // For a 50/50 lottery: this 1 bit is sufficient to guarantee winning // Cost: validator loses one epoch's rewards (~2 ETH) to guarantee winning // If jackpot > 2 ETH: attack is profitable // Use for: non-critical randomness, tie-breaking, UI shuffling // Avoid for: lotteries, NFT trait rarity, any high-value random selection uint256 rand = block.prevrandao; // ← Fine for low stakes, bad for high stakes

Chainlink VRF Common Implementation Mistakes

MistakeWhat HappensCorrect Approach
Making game decision in requestRandomWords()Random words not available yet — callback hasn't firedMake decisions only in fulfillRandomWords()
Not validating requestId in callbackMalicious contract can call fulfillRandomWords() directlyCheck msg.sender == VRF coordinator
Using one word for multiple independent decisionsCorrelated randomness — not truly independentDerive separate values: keccak(word, index)
No fallback if VRF request failsGame or lottery is permanently stuckAdd timeout + manual resolution mechanism
Insufficient LINK balanceVRF request silently fails or revertsMonitor LINK balance and top up automatically

Key Takeaways

  • Block variables (block.timestamp, block.prevrandao, blockhash) are not random — miners/validators have varying degrees of influence over them and all values are known or computable before transaction inclusion.
  • Any on-chain computation of "randomness" is deterministic and can be predicted or manipulated by a contract executing in the same block.
  • Chainlink VRF v2+ is the industry standard for on-chain randomness — use it for any application where unpredictability matters for fairness or security.
  • Commit-reveal schemes are a viable VRF alternative for applications that can tolerate two-transaction flows and can handle griefing (the revealer refusing to reveal).
  • The blockhash function only returns non-zero values for the last 256 blocks — using it for older blocks silently returns zero, which can be predicted and exploited.
  • Post-Merge block.prevrandao is generated from the RANDAO mix, which has 1-bit manipulation potential per validator — not suitable for high-value applications.

Entropy Accumulation Anti-Patterns

Some developers attempt to increase apparent randomness by combining multiple block variables or hashing user-provided data together. These approaches still fail because the entire set of inputs is available to a contract in the same block, allowing on-chain exploitation.

Why "Combined" On-Chain Entropy Still Fails
// ❌ STILL VULNERABLE: Combining block variables doesn't add entropy function badRandom() public view returns (uint256) { return uint256(keccak256(abi.encodePacked( block.timestamp, block.prevrandao, // Validator can manipulate (1 bit) block.coinbase, // Known — the block proposer block.gaslimit, // Predictable msg.sender, // Attacker controls this tx.gasprice // Attacker controls this ))); } // An attacker contract can compute this EXACT value before calling // and simulate different inputs (try different gasprice values) until // the result gives a winning lottery ticket // ❌ Also broken: keccak256 of user-supplied "salt" function userSaltRandom(bytes32 salt) public view returns (uint256) { // Attacker can brute-force salt values until they get desired output return uint256(keccak256(abi.encodePacked(block.timestamp, salt))); }

Foundry Test for VRF Dependency Verification

Verifying VRF Integration Cannot Be Bypassed
contract VRFSecurityTest is Test { VRFConsumer consumer; VRFCoordinatorV2_5Mock coordinator; function setUp() public { coordinator = new VRFCoordinatorV2_5Mock(0.1 ether, 1e9); uint256 subId = coordinator.createSubscription(); coordinator.fundSubscription(subId, 10 ether); consumer = new VRFConsumer(subId, address(coordinator)); coordinator.addConsumer(subId, address(consumer)); } // ✅ Verify: result not set until VRF callback fires function test_ResultPendingUntilFulfillment() public { consumer.requestRandom(); // Before fulfillment, result should be 0 or "pending" assertEq(consumer.lastResult(), 0); // Simulate VRF fulfillment from coordinator coordinator.fulfillRandomWords(1, address(consumer)); // After fulfillment, result is non-zero assertGt(consumer.lastResult(), 0); } // ✅ Verify: only coordinator can call rawFulfillRandomWords function test_OnlyCoordinatorCanFulfill() public { uint256[] memory words = new uint256[](1); words[0] = 12345; vm.expectRevert(); // Attacker tries to inject their own "random" value consumer.rawFulfillRandomWords(1, words); } }