( Original text by raz0r )
This is a walk through for the smart contract hacking challenge organized by PolySwarm for CODE BLUE conference held in Japan on November 01–02. Although the challenge was supposed to be held on-site for whitelisted addresses only, Ben Schmidt of PolySwarm kindly shared a wallet so that I could participate in the challenge.
The target smart contract called
featured a set of honeypot tricks that were described in Ben’s talk “Smart Contract Honeypots”. A naïve attacker would call
function with a number that was compared with a private variable
, which could easily be revealed off-chain. However, the condition would never work as expected because of the following code:
1
2
3
4
|
Guess storage guess; guess.playerNo = players[msg.sender].playerNo; guess.time = now; guesses.push(guess); |
Solidity prior to 0.5.0 allows uninitialized storage pointers. Since the contract was compiled with solc 0.4.25, the code above would silently overwrite the first element in contract’s storage with
value. As a result, variable
which is the first in the storage would be changed right before the comparison. In order to make the condition pass, one had to call
with their player number instead of
value. The contract allowed to update one’s number and name via
, which was quite handy since
had a restriction on the number range (only 0–10).
After the check succeeded it seemed that nothing could stop me to receive the prize. However, it was just the beginning of the long journey into EVM bytecode, as
reverted for unknown reason. After a short debugging session in Remix it was clear that the following line caused it:
1
2
|
// you win! winnerLog.logWinner(msg.sender, players[msg.sender].playerNo, players[msg.sender].name); |
address on EtherScan revealed the source code of another contract called
, however
variable pointed to some other contract which had no verified source code. An attempt to verify the source code was unsuccessful which proved the assumption that
had some different logic inside. A quick recon on
revealed a magic string
in contract’s storage. The first idea was of course to use this string as a username, however
still reverted. Other variations like reversing this string or using capital letters also failed. As quick attempts happened to be ineffective, I moved to reverse engineering the contract’s bytecode. Although decompiled contract was hardly comprehensible, EtherVM provided some hints on the logic inside
function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
} else if (var0 == 0x7fd4b61a) { // logWinner() /**/ if (msg.sender != storage[0x03] & 0x02 ** 0xa0 - 0x01) { revert(memory[0x00:0x00]); } /**/ } else { /**/ label_03F5: var9 = var6; // var6 is magic string var10 = var7 & 0x1f; if (var10 >= 0x20) { assert(); } var9 = byte(var9, var10) * 0x02 ** 0xf8 ~ 0x02 ** 0xf8 * 0x42; var10 = var5; var11 = var7 & 0xffffffff; if (var11 >= memory[var10:var10 + 0x20]) { assert(); } var temp35 = var10 + 0x20 + var11; memory[temp35:temp35 + 0x01] = byte((memory[temp35:temp35 + 0x20] / 0x02 ** 0xf8 * 0x02 ** 0xf8 ~ var9) & ~0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, 0x00); var8 = var8; var7 = var7 + 0x01; if (var7 & 0xffffffff >= memory[var5:var5 + 0x20]) { goto label_0475; } else { goto label_03F5; } } |
It was quite obvious that the function could be called only by
contract, and some operations were done on each byte of the magic string. Therefore I had to find such username that would satisfy some obscure conditions inside a closed-source smart contract code. Sounds like a perfect task for symbolic execution.
Manticore is a symbolic execution tool created by Trail of Bits which supports Ethereum Virtual Machine. It proved to be effective at CTFs, so I decided to give it a try.
Firstly, it was necessary to set up addresses and create the
smart contract:
1
2
3
4
5
6
7
|
import binascii from manticore.ethereum import ManticoreEVM, ABI m = ManticoreEVM() owner_account = m.create_account(balance = 1000 , name = 'owner' , address = 0xbc7ddd20d5bceb395290fd7ce3a9da8d8b485559 ) attacker_account = m.create_account(balance = 1000 , name = 'attacker' , address = 0x762C808237A69d786A85E8784Db8c143EB70B2fB ) cashmoney_contract = m.create_account(balance = 1000 , name = 'CashMoney' , address = 0x64ba926175bc69ba757ef53a6d5ef616889c9999 ) winnerlog_contract = m.create_contract(init = bytecode, owner = owner_account, name = "WinnerLog" , address = 0x2e4d2a597a2fcbdf6cc55eb5c973e76aa19ac410 ) |
After that, the smart contract state had to be recreated. There was just one transaction on the mainnet, supposedly to allow
contract call
:
1
2
|
m.transaction(caller = owner_account, address = winnerlog_contract, data = binascii.unhexlify(b "c3e8512400000000000000000000000064ba926175bc69ba757ef53a6d5ef616889c9999" ), value = 0 ) |
The next step was to create a symbolic buffer and send a transaction to call
with that symbolic buffer:
1
2
3
|
symbolic_data = m.make_symbolic_buffer( 64 ) calldata = ABI.function_call( 'logWinner(address,uint256,bytes)' , attacker_account, 0 , symbolic_data) m.transaction(caller = cashmoney_contract, address = winnerlog_contract, data = calldata, value = 0 , gas = 10000000 ) |
And finally my goal was to find at least a single running state, i.e. the one that finished with a
instead of
or
:
1
2
3
4
5
|
for state in m.running_states: world = state.platform result = state.solve_one(symbolic_data) print ( "[+] FOUND: {}" . format (binascii.hexlify(result))) break |
After several minutes manticore successfully found a username that would not result in a reverted transaction:
After setting this sequence of bytes as my username, I successfully claimed one of the prizes. The complete solution can be found on GitHub.