Co-written with
In a few hours, an attacker will claim the prize for the first Consensys Diligence Ethereum hacking challenge. Here’s how they’ll do it, why nobody else can perform the same attack (any longer), and why the attacker has to wait…
The challenge consisted of a smart contract submitted to the mainnet, without sources. The contract is meant to be decoded, attacked, and drained of its minimal funds. The draining account will then get an off-contract bounty.
At this point in time, an attacker has not just entered the house but also locked the door behind them, so nobody else can enter. (Which is also why we stopped looking into the challenge and are are instead writing this text.) But, interestingly, the attacker has to wait until the Constantinople rollout enables the CREATE2 opcode, for the second step of the attack to take place!
To understand the challenge, let’s look at a decompiled version of the contract. We are using our favorite decompiler — our own service, contract-library.com, applied on the challenge contract.
As it turns out, the challenge requires solving two sub-problems: first, gaining ownership of the contract, in order to enable a delegatecall
to a contract that the attacker controls, and, second, circumventing checks over the bytecode of the contract getting delegatecall
-ed: the contract cannot contain instructions create
, call
, callcode
, delegatecall
, staticcall
, selfdestruct
. Let’s look at both sub-problems in detail, and see how they are solved.
Challenge Problem 1
In the decompiled code, one can notice that there are two arrays, with guessed names array_0
and owners
. The latter is used to check whether the caller has the required privileges to perform the final part of the attack. Although there are no setters for owners, one can still pollute the data stored in it, as all arrays are stored in the same address space. The length of the first array in the deployed contract was set to maxint: a size that allows overflow, so that an attacker can write anywhere in storage.
Per standard convention for (dynamic) storage arrays, their lengths are stored in storage locations 0 and 1, while their contents are stored at storage locations keccak256(0)
and keccak256(1)
, respectively. One can therefore compute the offset of the contents of owners
and of the length of owners
(as well as that of array_0
) relative to the start of the contents of array_0
, as can be seen in the following “attacker’s” code:
function offsets() private returns (uint, uint, uint) {
uint array0start = uint(keccak256(abi.encodePacked(uint(0))));
uint array1start = uint(keccak256(abi.encodePacked(uint(1))));
uint contentOffset = array1start - array0start;
uint lengthOffset = uint(-array0start);
return (contentOffset, lengthOffset, lengthOffset + 1);
}
Since the challenge contract allows overflow of the array_0
contents area, these offsets let us write into owners
, and also change the length of owners
. In fact, the attacker did not stop there! They also set the length of array_0
to 0, so that no future attacker can employ the same overflow.
function attack() public {
address attackerAddress = address(...);
address victim = address(0x68Cb...);
(uint contentOffset, uint lengthOffset0, uint lengthOffset1)
= offsets();
bool success;
bytes memory result;
// set address I control as one of the owners
victim.call(abi.encodeWithSelector(
bytes4(0x4214352d), uint(attackerAddress), contentOffset)
);
// set length of array 0 to 0 (no more out of bounds)
victim.call(abi.encodeWithSelector(
bytes4(0x4214352d), uint(0), lengthOffset0)
);
// set length of array 1 to 1 (make attacker the only owner)
victim.call(abi.encodeWithSelector(
bytes4(0x4214352d), uint(1), lengthOffset1)
);
}
The contract registered as owner (attackerAddress
) can be any that the attacker controls. Now the attacker has both entered and secured the door! But the more serious challenge is still up ahead.
Challenge Problem 2
The second part of the challenge is the actual draining of the contract’s funds. This involves creating yet another attacker contract that will simply drain the contract’s balance. If one checks function 0x2918435f
of the challenge contract, the code calls delegatecall
on an attacker-supplied address parameter, effectively handing it full control of the account. There is a small twist to this plot however. The delegatecall
is preceded by checks of all the bytecodes of the called contract, to ensure that they never match values 0xf0, 0xf1, 0xf2, 0xf4, 0xfa, or 0xff. This precludes use of instructions create
, call
, callcode
, delegatecall
, staticcall
, and selfdestruct
.
Currently (Feb. 27), these are the only instructions that can be used to drain a contract from its funds. In a few hours, however, a new bytecode instruction (create2
) will be available and it can also move funds! Hence the attacker now only needs to pass the address to a smart contract implementing something similar to this:
contract BountyClaimer {
function() external {
uint res;
assembly {
let res := create2(balance(address), 0, 1, 0)
}
}
}
A minor challenge is that byte value 0xff arises commonly in Solidity compilation, so the attacker has to use roundabout ways to compute some values, but this is little more than a nuisance.
We would like to salute the clever attacker that will be executing this attack within the next few hours.
Happy hunting!