Back

SC09: Insecure Randomness - Code Examples

These paired examples show vulnerable on-chain randomness next to a secure alternative. The pattern to internalise is always the same: randomness that any actor can compute, observe, or bias within the transaction that consumes it is not randomness at all. Each secure version imports unpredictability from outside that transaction—a verifiable oracle or a prior commitment.

Every Solidity snippet below is illustrative and trimmed for clarity (imports, access control, and error handling are abbreviated). Do not copy the vulnerable versions into production; they exist to be understood and rejected.

Example 1: Lottery Winner Selection

A lottery pays the whole pot to one player. If the winning index is derived from block variables, the value is either public before the transaction lands or set by the block proposer—so it can be predicted or biased.

Vulnerable: block variables pick the winner

// VULNERABLE -- winner is computable from public block data
contract LotteryVulnerable {
    address[] public players;

    function enter() external payable {
        require(msg.value == 0.1 ether, "fixed entry");
        players.push(msg.sender);
    }

    function drawWinner() external {
        // Every input here is public or proposer-controlled in this same block.
        uint256 rand = uint256(keccak256(abi.encodePacked(
            block.timestamp,
            blockhash(block.number - 1),
            block.prevrandao
        )));
        uint256 winnerIndex = rand % players.length;   // predictable
        payable(players[winnerIndex]).transfer(address(this).balance);
        delete players;
    }
}

Why it breaks: a caller can evaluate the exact same expression in their own contract in the same transaction, learn winnerIndex before entering or drawing, and act only when it points at them. The block proposer can additionally nudge block.timestamp or block.prevrandao to steer the result.

Secure: Chainlink VRF (request/fulfil with proof)

// SECURE -- randomness arrives in a later tx, carrying an on-chain proof
import {VRFConsumerBaseV2} from "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import {VRFCoordinatorV2Interface} from "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";

contract LotterySecure is VRFConsumerBaseV2 {
    VRFCoordinatorV2Interface immutable COORDINATOR;
    uint64  immutable subId;
    bytes32 immutable keyHash;
    uint16  constant CONFIRMATIONS = 3;
    uint32  constant CALLBACK_GAS  = 200000;
    uint32  constant NUM_WORDS     = 1;

    address[] public players;
    mapping(uint256 => bool) public pending;   // requestId -> awaiting fulfil

    // 1) REQUEST -- no usable randomness exists in this transaction
    function drawWinner() external returns (uint256 requestId) {
        requestId = COORDINATOR.requestRandomWords(
            keyHash, subId, CONFIRMATIONS, CALLBACK_GAS, NUM_WORDS);
        pending[requestId] = true;
    }

    // 2) FULFIL -- coordinator calls back ONLY after verifying the VRF proof
    function fulfillRandomWords(uint256 requestId, uint256[] memory words)
        internal override
    {
        require(pending[requestId], "unknown request");
        pending[requestId] = false;
        uint256 winnerIndex = words[0] % players.length;   // proven random
        payable(players[winnerIndex]).transfer(address(this).balance);
        delete players;
    }
}

The winning value does not exist when drawWinner() runs, so no one can compute-then-revert; the coordinator accepts only a word whose proof verifies against the registered key, so it cannot be cherry-picked.

Example 2: NFT Rarity / Mint

Rarity assignment is high value: a rare trait can be worth many times the mint price, so any predictability is quickly and profitably exploited by mint bots.

Vulnerable: rarity from block data at mint time

// VULNERABLE -- rarity is derivable in the same tx as the mint
contract RarityVulnerable {
    uint256 public nextId;

    function mint() external payable returns (uint256 id) {
        require(msg.value == 0.05 ether, "mint price");
        id = nextId++;
        uint256 roll = uint256(keccak256(abi.encodePacked(
            block.timestamp, block.prevrandao, msg.sender, id
        ))) % 1000;
        // roll < 10 == legendary. An attacker computes roll BEFORE minting
        // and only submits the tx when it lands on a rare tier.
        _assignRarity(id, roll);
    }
}

Why it breaks: a bot contract reproduces the same roll expression, mints only when the result is rare, and reverts otherwise—draining every valuable trait for the cost of gas on winning attempts.

Secure: reveal rarity from VRF after mint

// SECURE -- mint first, assign rarity later from proven randomness
contract RaritySecure is VRFConsumerBaseV2 {
    // ... COORDINATOR / keyHash / subId as in Example 1 ...
    uint256 public nextId;
    mapping(uint256 => uint256) public requestToTokenId;
    mapping(uint256 => uint256) public rarityOf;   // 0 == not yet revealed

    // 1) Mint issues the token with NO rarity known yet
    function mint() external payable returns (uint256 id) {
        require(msg.value == 0.05 ether, "mint price");
        id = nextId++;
        uint256 requestId = COORDINATOR.requestRandomWords(
            keyHash, subId, 3, 200000, 1);
        requestToTokenId[requestId] = id;
    }

    // 2) Rarity is set in a later tx from a proof-verified word
    function fulfillRandomWords(uint256 requestId, uint256[] memory words)
        internal override
    {
        uint256 id = requestToTokenId[requestId];
        rarityOf[id] = (words[0] % 1000) + 1;   // proven; not gameable at mint
    }
}

Because rarity is unknown at mint time and only fixed later from a verified word, there is no value to predict and nothing to selectively revert against.

Example 3: Naive keccak256 vs. Commit-Reveal

When an external oracle is genuinely unavailable, a hardened commit-reveal scheme can import unpredictability from a commitment made before the outcome data existed. Hashing public block variables cannot.

Vulnerable: "random" from keccak256(block.*)

// VULNERABLE -- a deterministic hash of public inputs is still public
contract CoinFlipVulnerable {
    function flip(bool guess) external returns (bool won) {
        bool outcome = uint256(keccak256(abi.encodePacked(
            block.timestamp, blockhash(block.number - 1)
        ))) % 2 == 0;
        won = (guess == outcome);   // attacker precomputes outcome, always "wins"
        if (won) payable(msg.sender).transfer(1 ether);
    }
}

Secure: commit-reveal with deposit, slashing, and timeout

// SECURE -- entropy is committed before the outcome-determining data exists
contract CommitRevealFlip {
    struct Entry { bytes32 commitment; uint256 deposit; bool revealed; }
    mapping(address => Entry) public entries;
    uint256 public commitDeadline;
    uint256 public revealDeadline;
    bytes32 public seedAccumulator;
    uint256 constant DEPOSIT = 0.1 ether;

    // COMMIT: lock hash of (secret, salt) plus a slashable deposit
    function commit(bytes32 commitment) external payable {
        require(block.timestamp < commitDeadline, "commit closed");
        require(msg.value == DEPOSIT, "bad deposit");
        entries[msg.sender] = Entry(commitment, msg.value, false);
    }

    // REVEAL: prove preimage; mix EVERY secret into the shared seed
    function reveal(uint256 secret, bytes32 salt) external {
        require(block.timestamp >= commitDeadline
             && block.timestamp < revealDeadline, "not reveal phase");
        Entry storage e = entries[msg.sender];
        require(!e.revealed, "already revealed");
        require(keccak256(abi.encodePacked(secret, salt)) == e.commitment, "bad reveal");
        e.revealed = true;
        seedAccumulator = keccak256(abi.encodePacked(seedAccumulator, secret));
        payable(msg.sender).transfer(e.deposit);   // returned on honest reveal
    }

    // Unrevealed deposits are FORFEIT after revealDeadline, removing the
    // incentive to withhold an unfavourable secret.
}

Two failure modes a naive commit-reveal must close:

Commit-reveal without deposits, slashing, and a timeout fallback is gameable. The scheme only holds when withholding or reordering a reveal is provably more expensive than any advantage it buys.

Example 4: The Compute-Then-Revert Attacker

This is the exploit that defeats every "hash the block variables" design. Because the RNG expression is deterministic and all its inputs are visible in the current transaction, an attacker contract simply reproduces the same expression, checks whether it would win, and reverts otherwise—so the only transactions that ever land are winning ones.

Vulnerable target

// VULNERABLE target -- deterministic RNG from same-tx public inputs
contract GuessGameVulnerable {
    function play() external payable returns (bool won) {
        require(msg.value == 1 ether, "stake");
        uint256 rand = uint256(keccak256(abi.encodePacked(
            block.timestamp, blockhash(block.number - 1), address(this)
        ))) % 10;
        won = (rand == 7);
        if (won) payable(msg.sender).transfer(2 ether);
    }
}

Attacker: only enters in the same tx when it already knows it wins

// ATTACKER -- recomputes the target's RNG and reverts on a loss
interface IGuessGame { function play() external payable returns (bool); }

contract Attacker {
    IGuessGame immutable game;
    constructor(address _game) { game = IGuessGame(_game); }

    function attack() external payable {
        // Reproduce the EXACT expression the target will evaluate this block.
        uint256 rand = uint256(keccak256(abi.encodePacked(
            block.timestamp, blockhash(block.number - 1), address(game)
        ))) % 10;

        // Abort before spending the stake unless it is a guaranteed win.
        require(rand == 7, "not a winning block -- revert, pay only gas");

        game.play{value: 1 ether}();   // executes only when the win is certain
    }
    // With unlimited, free retries the attacker eventually hits a winning
    // block and drains the prize pool. The secure fix is Example 1/2: make the
    // randomness unavailable in the transaction that consumes it (VRF split),
    // so there is no value to recompute and nothing to revert against.
}

The structural lesson: defences like “reject contract callers” (require(msg.sender == tx.origin)) are trivially bypassed and break composability. The only reliable fix is to split request from consumption so the outcome does not exist when the caller acts—exactly what the VRF request/fulfil pattern provides.

What Changed / Why Secure

ExampleVulnerable source of randomnessSecure fixWhy the fix holds
1. Lottery keccak256(block.timestamp, blockhash, prevrandao) Chainlink VRF request/fulfil Value does not exist at request time; proof verified on-chain
2. NFT rarity Block data hashed at mint Mint now, assign rarity later from VRF Nothing to predict at mint; rarity fixed from a proven word
3. Coin flip keccak256(block.*) deterministic hash Commit-reveal + deposit, slashing, timeout Entropy committed before outcome data existed; withholding is slashed
4. Guess game Same-tx deterministic RNG Split request from consumption (VRF) No same-tx value to recompute; compute-then-revert is defeated

Real Incident Classes

The failures below are recurring classes of real-world exploits, not specific named events. Each maps directly to one of the examples above.

Incident classRoot causeMaps to
Predictable-RNG lotteries drained by a precomputing callerWinner index from block variables, computable in the same txExamples 1, 4
On-chain casino / dice games farmed for guaranteed winsDeterministic hash of public inputs; free retries via revertExamples 3, 4
NFT mints where bots harvested every rare traitRarity derived from block data known before mintingExample 2
Proposer-biased drawsReliance on timestamp / prevrandao a block proposer can nudgeExamples 1, 2
Commit-reveal games gamed by reveal withholdingNo deposit / slashing / timeout on non-revealExample 3

Next Steps