Coin Flip asks you to guess a coin toss ten times in a row. Guessing randomly gives you a 1 in 1024 chance. The toss is not random, and once you see why, winning ten times is no harder than winning once.
Try it in the browser: solve Coin Flip in the SCH lab. The original is Ethernaut level 3.
The contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
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;
}
}
}
What that huge number is
FACTOR looks arbitrary. It is exactly 2^255.
A uint256 holds 256 bits, so dividing by 2^255 gives 1 if the highest bit is set and 0 if it is not. Integer division discards the rest. That means this line reads the single most significant bit of the previous block hash:
uint256 coinFlip = blockValue / FACTOR;
The bit itself is fine. A block hash is unpredictable before its block is mined. The problem is when it becomes readable.
The flaw
blockhash(block.number - 1) returns the hash of the block before the current one. That block already exists, its hash is public, and every contract executing in the current block can read it.
So you are not predicting anything. You read the number the contract treats as secret, run the identical division, and submit the answer you already have.
{
"title": "🎬 The coin was already flipped before you guessed",
"stage": { "width": 920, "height": 440 },
"nodes": [
{ "id": "block", "label": "Block N-1", "role": "hash is public", "emoji": "🧱", "x": 430, "y": 60, "color": "gold" },
{ "id": "exploit", "label": "Exploit contract", "role": "mirrors the math", "emoji": "📜", "x": 60, "y": 300, "color": "red" },
{ "id": "target", "label": "CoinFlip", "role": "consecutiveWins", "emoji": "🎲", "x": 760, "y": 300, "color": "cyan" }
],
"links": [
{ "from": "block", "to": "exploit" },
{ "from": "block", "to": "target" },
{ "from": "exploit", "to": "target" }
],
"nets": [
{ "id": "wins", "label": "Consecutive wins" },
{ "id": "odds", "label": "Your odds" }
],
"legend": [
{ "cls": "call", "label": "contract call" },
{ "cls": "sig", "label": "reads state" },
{ "cls": "token", "label": "outcome" },
{ "cls": "fail", "label": "blocked" }
],
"scenarios": {
"Vulnerable: the answer is readable": [
{ "note": "Block N-1 is already mined. Its hash is fixed, public, and readable by every contract in block N.", "hi": ["block"], "net": { "wins": "0", "odds": "50% per guess" } },
{ "note": "<code>CoinFlip.flip()</code> derives the side from <code>blockhash(block.number - 1) / 2^255</code>, which is just the top bit of that hash.", "hi": ["block","target"], "chip": { "from": "block", "to": "target", "label": "🔍 reads blockhash", "cls": "sig" } },
{ "note": "Your exploit contract reads the <b>same hash</b> in the <b>same block</b> and runs the <b>same division</b>.", "tone": "bad", "hi": ["block","exploit"], "chip": { "from": "block", "to": "exploit", "label": "🔍 reads blockhash", "cls": "sig" }, "net": { "odds": "100%" } },
{ "note": "It calls <code>flip()</code> with the answer it already computed. The guess cannot be wrong.", "tone": "bad", "hi": ["exploit","target"], "chip": { "from": "exploit", "to": "target", "label": "📞 flip(side)", "cls": "call" }, "bal": { "target": "consecutiveWins = 1" }, "net": { "wins": "1" } },
{ "note": "<code>lastHash</code> rejects a second flip in the same block, so you wait one block and repeat.", "hi": ["target"], "chip": { "from": "exploit", "to": "target", "label": "⛔ same block rejected", "cls": "fail" } },
{ "note": "Ten blocks, ten guaranteed wins. The level is solved.", "tone": "bad", "hi": ["target","exploit"], "chip": { "from": "exploit", "to": "target", "label": "📞 flip(side) x10", "cls": "call" }, "bal": { "target": "consecutiveWins = 10" }, "net": { "wins": "10" } }
],
"Fixed: the answer does not exist yet": [
{ "note": "The contract asks an oracle for randomness instead of deriving it from chain data.", "hi": ["target"], "net": { "wins": "0", "odds": "50% per guess" } },
{ "note": "Your guess is committed now, but the random word will not be produced until a later transaction.", "tone": "ok", "hi": ["exploit","target"], "chip": { "from": "exploit", "to": "target", "label": "📞 commit guess", "cls": "call" } },
{ "note": "The attacker reads every value on chain. None of it determines the outcome.", "tone": "ok", "hi": ["block","exploit"], "chip": { "from": "block", "to": "exploit", "label": "🔍 tells you nothing", "cls": "fail" }, "net": { "odds": "50%" } },
{ "note": "The oracle delivers a verifiable random value in a later block and the guess is settled against it.", "tone": "ok", "hi": ["target"], "chip": { "from": "target", "to": "target", "label": "🎲 VRF settles", "cls": "token" }, "net": { "wins": "0 or 1, honestly" } }
]
}
}
Why this has to be a contract
You cannot solve Coin Flip from a wallet. Read the block hash off-chain, compute the side, send the guess, and your transaction lands in a later block. By then blockhash(block.number - 1) points somewhere else and your answer is stale.
The exploit works because reading the hash and calling flip() happen in one transaction, so both see the same value. Atomicity is what turns public data into a guaranteed win, and that generalizes: an attacker's contract can compute an outcome first and revert if it does not like the result.
The exploit
The lab wires the target up as coinflipInstance and calls your run() once per block, so you only need to write a single flip:
function run() external {
vm.startBroadcast(PLAYER_PRIVATE_KEY);
uint256 factor = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
uint256 blockValue = uint256(blockhash(block.number - 1));
bool side = blockValue / factor == 1;
coinflipInstance.flip(side);
vm.stopBroadcast();
}
It is the target's own logic copied out, with the result passed in as the guess instead of hoping. Ten calls across ten blocks push consecutiveWins to 10. The lastHash check is the only reason you need separate blocks: it rejects a second flip while the block hash is unchanged.
Watch the walkthrough
Nothing on chain is a secret
Every value a contract can read, an attacker can read: block hashes, timestamps, block.prevrandao, balances, and the contents of private storage variables. The private keyword controls which contracts can reference a variable at the Solidity level. It does not encrypt anything, and reading a private slot takes one call to an archive node. Later Ethernaut levels make you do exactly that.
This class has cost real money. NFT mints have been drained by attackers who calculated rarity in the same transaction as the mint and reverted whenever the result was not worth keeping, with the Meebits mint in 2021 the most cited example.
The fixes are boring, which is the right state for a solved problem. Use a verifiable randomness oracle such as Chainlink VRF, or commit-reveal. Both work because at the moment the attacker must act, the answer does not exist anywhere they can reach.
When you see a contract deriving anything valuable from blockhash, block.timestamp, or block.prevrandao, write the finding. No arrangement of on-chain data produces randomness an attacker in the same block cannot reproduce.
Keep going
Start at Fallback for level 1, or Fallout for level 2. Every level runs in the SCH CTF lab, and the Web3 CTF challenge list has the other wargames. Weak randomness pays well in contests because it hides inside game and mint logic that looks harmless. The Smart Contract Hacking course covers it, including the oracle designs that fix it and how those get misconfigured.