← Back to Blog

On-Chain Randomness Manipulation: block.timestamp, PREVRANDAO, and VRF Front-Running Vulnerabilities

2026-04-18 randomness block-timestamp prevrandao vrf commit-reveal miner-manipulation solidity-security

Randomness is deceptively hard to generate on a deterministic blockchain. Every node must arrive at the same state, which means anything a contract can read — block metadata, sender addresses, chain IDs — is either known in advance or manipulable by the parties with the most to gain. This post walks through six concrete vulnerabilities, from the classic timestamp miner trick to post-merge PREVRANDAO bias and VRF front-running. Each section includes a vulnerable code sample, the attack vector, a hardened replacement, and tips for spotting the pattern during audits.


1. block.timestamp Manipulation

Miners and validators have a narrow but real window to shift the timestamp of a block they produce. The Ethereum protocol allows a block timestamp to drift roughly 15 seconds in either direction from the true wall-clock time without peers rejecting it. For any contract that branches on block.timestamp % N, that window is enough.

// VULNERABLE: lottery winner chosen by timestamp
contract TimestampLottery {
    address[] public players;

    function pickWinner() external {
        require(players.length > 0);
        uint256 index = uint256(block.timestamp) % players.length;
        address winner = players[index];
        payable(winner).transfer(address(this).balance);
        delete players;
    }
}

A validator who is also a player watches for the moment they are scheduled to propose a block. They try two or three candidate timestamps; if one produces index pointing to their own address, they include the block with that timestamp and collect the prize. The attack requires no special tooling — just a small script at the validator client level.

// FIXED: off-chain VRF via Chainlink
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";

contract VRFLottery is VRFConsumerBaseV2 {
    VRFCoordinatorV2Interface private immutable coordinator;
    uint64 private immutable subscriptionId;
    bytes32 private immutable keyHash;
    address[] public players;
    uint256 public pendingRequestId;

    constructor(address _coordinator, uint64 _subId, bytes32 _keyHash)
        VRFConsumerBaseV2(_coordinator)
    {
        coordinator = VRFCoordinatorV2Interface(_coordinator);
        subscriptionId = _subId;
        keyHash = _keyHash;
    }

    function requestWinner() external returns (uint256 requestId) {
        requestId = coordinator.requestRandomWords(keyHash, subscriptionId, 3, 200_000, 1);
        pendingRequestId = requestId;
    }

    function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
        uint256 index = randomWords[0] % players.length;
        payable(players[index]).transfer(address(this).balance);
        delete players;
    }
}

Detection tips: Grep for block.timestamp %, block.timestamp &, or any conditional that compares block.timestamp to a modulus used as an index. Flag every lottery, raffle, or NFT mint that resolves a winner inside the same transaction where the block is produced.


2. blockhash Limited History

blockhash(n) returns the hash of block n only when n is within the last 256 blocks. For any older block it returns bytes32(0). Contracts that store a block number at request time and resolve later — or that reference the current block directly — can be manipulated in two different ways.

// VULNERABLE: two separate bugs in one contract
contract BlockhashRoulette {
    mapping(address => uint256) public commitBlock;

    function commit() external {
        commitBlock[msg.sender] = block.number;
    }

    function reveal() external {
        uint256 bn = commitBlock[msg.sender];
        // Bug 1: same-block use — attacker can simulate before sending
        // Bug 2: if called 257+ blocks later, hash is 0 → deterministic result
        uint256 rand = uint256(blockhash(bn)) % 6;
        if (rand == 0) {
            payable(msg.sender).transfer(1 ether);
        }
        delete commitBlock[msg.sender];
    }
}

An attacker who calls commit and reveal in the same block can simulate blockhash(block.number) off-chain before submitting — it equals zero because the current block is not yet finalized. A different attacker simply waits 257 blocks, guaranteeing the hash returns zero and the payout condition becomes constant.

// FIXED: bind to a future block and enforce window
contract SafeBlockhashRoulette {
    mapping(address => uint256) public revealBlock;

    function commit() external {
        // reveal window: blocks N+1 through N+10
        revealBlock[msg.sender] = block.number + 1;
    }

    function reveal() external {
        uint256 target = revealBlock[msg.sender];
        require(block.number > target, "too early");
        require(block.number <= target + 10, "window expired");
        bytes32 hash = blockhash(target);
        require(hash != bytes32(0), "hash unavailable");
        uint256 rand = uint256(hash) % 6;
        if (rand == 0) {
            payable(msg.sender).transfer(1 ether);
        }
        delete revealBlock[msg.sender];
    }
}

Detection tips: Search for blockhash(block.number) (same-block), blockhash(block.number - 1), or any blockhash call where the argument is stored and later retrieved without enforcing a tight block-window. Missing expiry checks are equally dangerous.


3. PREVRANDAO (block.difficulty) Validator Bias

After the Ethereum merge, block.difficulty was repurposed as block.prevrandao, seeded from the beacon chain RANDAO. It looks random, but validators can bias it: before proposing, they peek at the RANDAO output and choose whether to reveal their validator key or skip their slot. Skipping costs one slot's worth of rewards but can shift the RANDAO mix in their favour when a high-value outcome depends on it.

// VULNERABLE: post-merge prevrandao treated as secure randomness
contract PrevrandaoMint {
    uint256 public constant RARE_CHANCE = 100; // 1-in-100 chance of "legendary"

    function mint() external {
        uint256 roll = uint256(block.prevrandao) % RARE_CHANCE;
        string memory rarity = (roll == 0) ? "legendary" : "common";
        _mint(msg.sender, rarity);
    }

    function _mint(address to, string memory rarity) internal {
        // mint logic
    }
}

A validator running a bot can estimate whether the current PREVRANDAO will produce roll == 0, then either propose the block (if it profits them or a colluding minter) or skip it to alter future RANDAO state. Over many epochs a well-resourced validator can meaningfully increase their probability of hitting the rare outcome.

// FIXED: combine VRF with commit-reveal; never use prevrandao alone
// Use Chainlink VRF subscription model (see section 1 for full boilerplate)
// Key principle: randomness must be unobservable before the random event is committed.
contract VRFMint is VRFConsumerBaseV2 {
    mapping(uint256 => address) public requestToMinter;

    function requestMint() external {
        uint256 reqId = _requestVRF();
        requestToMinter[reqId] = msg.sender;
    }

    function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
        address minter = requestToMinter[requestId];
        uint256 roll = randomWords[0] % 100;
        string memory rarity = (roll == 0) ? "legendary" : "common";
        _mint(minter, rarity);
        delete requestToMinter[requestId];
    }

    function _requestVRF() internal returns (uint256) { /* Chainlink VRF call */ }
    function _mint(address to, string memory rarity) internal { /* mint logic */ }
}

Detection tips: After the merge, every occurrence of block.difficulty is functionally block.prevrandao. Flag both. Look for NFT rarity, loot drops, or prize allocations that resolve in the same transaction as a block.prevrandao read.


4. Commit-Reveal Scheme Flaws

Commit-reveal is often proposed as a fix for on-chain randomness, but naive implementations have a critical flaw: the last participant to reveal can see all previously revealed values, compute the combined result, and simply withhold their reveal if the outcome is unfavourable. The scheme needs to bind each commit to an external source of unpredictability so withholding is no longer consequence-free.

// VULNERABLE: last revealer can withhold to grief or gain
contract NaiveCommitReveal {
    mapping(address => bytes32) public commits;
    uint256 public combinedSecret;
    uint256 public revealCount;

    function commit(bytes32 hash) external {
        commits[msg.sender] = hash;
    }

    function reveal(uint256 secret) external {
        require(commits[msg.sender] == keccak256(abi.encodePacked(secret)));
        combinedSecret ^= secret;
        revealCount++;
        delete commits[msg.sender];
    }
}

After all but one participant have revealed, the last revealer can compute combinedSecret ^ theirSecret and see the final result before deciding to reveal.

// FIXED: bind reveals to a specific block hash; slash non-revealers
contract BoundCommitReveal {
    struct Commit { bytes32 hash; uint256 revealDeadlineBlock; }
    mapping(address => Commit) public commits;
    uint256 public combinedSecret;

    uint256 public constant REVEAL_WINDOW = 20; // blocks
    uint256 public commitDeadline;

    function commit(bytes32 hash) external {
        require(block.number <= commitDeadline, "commit phase over");
        commits[msg.sender] = Commit(hash, commitDeadline + REVEAL_WINDOW);
    }

    function reveal(uint256 secret, uint256 blindingFactor) external {
        Commit storage c = commits[msg.sender];
        require(block.number <= c.revealDeadlineBlock, "reveal window closed");
        // bind to a future block hash captured at commit time
        bytes32 expected = keccak256(abi.encodePacked(secret, blindingFactor, blockhash(commitDeadline)));
        require(c.hash == expected, "bad reveal");
        combinedSecret ^= secret;
        delete commits[msg.sender];
    }

    // Non-revealers: slash their deposit; use a fallback VRF seed.
}

Detection tips: Look for XOR-based combinedSecret patterns with no slashing mechanism and no economic penalty for withholding. Schemes with a single coordinator who reveals last are especially dangerous.


5. Weak Seed Combination

Developers sometimes try to build randomness by hashing together several on-chain values — block.timestamp, block.chainid, msg.sender, tx.origin, and a nonce. Even combining many predictable values does not produce unpredictability. Every input is either known before the transaction or manipulable by the caller.

// VULNERABLE: XOR/keccak of public on-chain values
contract WeakSeed {
    uint256 public nonce;

    function random() internal returns (uint256) {
        nonce++;
        return uint256(keccak256(abi.encodePacked(
            block.timestamp,
            block.chainid,
            msg.sender,
            tx.origin,
            nonce,
            address(this).balance
        )));
    }

    function play() external payable {
        uint256 r = random() % 2;
        if (r == 0) {
            payable(msg.sender).transfer(address(this).balance);
        }
    }
}

An attacker deploys a proxy contract, simulates random() in a view call with the same block.timestamp, msg.sender (proxy address), and current nonce, then only sends the real transaction when the simulated result is 0. The balance check makes it marginally harder but the attacker can fund their proxy to produce the exact balance needed.

// FIXED: no on-chain seed is sufficient alone; always use VRF
// If VRF latency is unacceptable, use a trusted off-chain signer pattern:
contract OffChainSignedRandom {
    address public immutable signer; // controlled by a trusted off-chain service

    constructor(address _signer) { signer = _signer; }

    function play(uint256 randomValue, bytes calldata sig) external payable {
        bytes32 msgHash = keccak256(abi.encodePacked(msg.sender, randomValue, block.number));
        bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash));
        address recovered = _recover(ethHash, sig);
        require(recovered == signer, "invalid randomness");
        if (randomValue % 2 == 0) {
            payable(msg.sender).transfer(address(this).balance);
        }
    }

    function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) {
        (bytes32 r, bytes32 s, uint8 v) = abi.decode(sig, (bytes32, bytes32, uint8));
        return ecrecover(hash, v, r, s);
    }
}

Detection tips: Any keccak256 that hashes exclusively from block.* globals, msg.sender, tx.origin, address(this).balance, or gasleft() without an external entropy source is weak. Count the inputs — more inputs does not mean more security if all are observable.


6. VRF Response Front-Running

Chainlink VRF is a robust source of randomness, but the integration pattern matters. If a contract reveals its intended action before the VRF response lands on-chain, a searcher can front-run the fulfillRandomWords callback to exploit the now-known random outcome.

// VULNERABLE: action intent readable from pending state before VRF fulfills
contract VRFFrontRunnable {
    mapping(address => uint256) public pendingBet; // amount staked, visible on-chain
    mapping(uint256 => address) public requestOwner;

    function placeBet() external payable {
        pendingBet[msg.sender] = msg.value; // intent is public
        uint256 reqId = _requestVRF();
        requestOwner[reqId] = msg.sender;
    }

    function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
        address player = requestOwner[requestId];
        if (randomWords[0] % 2 == 0) {
            payable(player).transfer(pendingBet[player] * 2); // payout visible before it happens
        }
        delete pendingBet[player];
    }

    function _requestVRF() internal returns (uint256) { /* ... */ }
}

A MEV bot watches the mempool for fulfillRandomWords calls from the VRF coordinator. When it sees a pending fulfillment that will pay out, it inserts its own placeBet in the same block at a higher gas price — effectively sniping guaranteed wins.

// FIXED: seal bets before request; disallow new bets once a request is in-flight
contract VRFSealedBet {
    enum State { Idle, Pending }
    State public state;
    address public currentPlayer;
    uint256 public currentBet;
    uint256 public currentRequestId;

    modifier onlyIdle() { require(state == State.Idle, "round in progress"); _; }

    function placeBet() external payable onlyIdle {
        currentPlayer = msg.sender;
        currentBet = msg.value;
        state = State.Pending; // lock new bets
        currentRequestId = _requestVRF();
    }

    function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
        require(requestId == currentRequestId, "stale request");
        if (randomWords[0] % 2 == 0) {
            payable(currentPlayer).transfer(currentBet * 2);
        }
        state = State.Idle;
    }

    function _requestVRF() internal returns (uint256) { /* ... */ }
}

Detection tips: In any VRF integration, check whether new participants can enter after a request is in-flight. Also verify that the payout logic is not predictable from the request ID or the requester's address alone. Look for missing access controls on fulfillRandomWords — it should only be callable by the VRF coordinator.


Protect Your Protocol

On-chain randomness attacks have drained millions of dollars from lotteries, NFT mints, and gaming protocols. The common thread is treating deterministic or manipulable values as if they were secret. Whether you are reviewing a new protocol or hardening an existing one, the six patterns above are the first things an attacker will probe.

ContractScan automatically detects weak randomness sources, missing VRF access controls, and commit-reveal scheme flaws across your entire codebase. Run a free scan at contractscan.io before your next deployment and get a prioritised report of randomness vulnerabilities — before someone else finds them on mainnet.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →