PolySwarm Smart Contract Hacking Challenge Writeup

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 CashMoney featured a set of honeypot tricks that were described in Ben’s talk “Smart Contract Honeypots”. A naïve attacker would call do_guess() function with a number that was compared with a private variable current, which could easily be revealed off-chain. However, the condition would never work as expected because of the following code:

Guess storage guess;
guess.playerNo = players[msg.sender].playerNo;
guess.time = now;

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 players[msg.sender].playerNo value. As a result, variable current 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 do_guess() with their player number instead of current value. The contract allowed to update one’s number and name via updateSelf(), which was quite handy since do_guess() 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 do_guess() reverted for unknown reason. After a short debugging session in Remix it was clear that the following line caused it:

// you win!
winnerLog.logWinner(msg.sender, players[msg.sender].playerNo, players[msg.sender].name);

CashMoney address on EtherScan revealed the source code of another contract called WinnerLog, however winnerLog 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 WinnerLog had some different logic inside. A quick recon on WinnerLog revealed a magic string dogecointothemoonlambosoondudes! in contract’s storage. The first idea was of course to use this string as a username, however do_guess() 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 logWinner() function.

} else if (var0 == 0x7fd4b61a) { // logWinner()
if (msg.sender != storage[0x03] & 0x02 ** 0xa0 - 0x01) { revert(memory[0x00:0x00]); }
} else {
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 CashMoney 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 WinnerLog smart contract:

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 CashMoney contract call WinnerLog:

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 logWinner() with that symbolic buffer:

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 STOP instead of REVERT or THROW:

for state in m.running_states:
  world = state.platform
  result = state.solve_one(symbolic_data)
  print("[+] FOUND: {}".format(binascii.hexlify(result)))

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.

Join the Conversation


Leave a comment

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.