Elric's work always goes through Vaultrune before it goes anywhere else. That's not a courtesy, it's the law Cassian built his house on: nothing comes back from a survey and gets used until a Vaultrune warden has taken it apart, checked every joint against the tolerances on file, and stamped it clean. [...] This one skipped that step entirely. It turned up in the satchel from the Stonepass survey like it belonged there, no requisition number, no warden's mark, nothing to say where it came from or who cleared it. Run it and it holds together perfectly, every joint clean, every tolerance dead on, which is exactly the problem. [...] Something got built into this one that was never meant to survive an inspection. Find what they buried before a Vaultrune warden does.
A 14 KB ELF that accepts exactly 8 bytes of input. Those bytes are hashed by self-decrypting shellcode, compared against a constant hidden inside an instruction, and - on a match - used as the seed that decrypts the flag. Every step of the hash is bijective (reversible), so nothing has to be brute forced.
$ unzip -l a2524fda-2958-49a9-9bd8-184ddbeb69ff-1784744107.zip
Length Date Time Name
--------- ---------- ----- ----
0 07-19-2026 22:21 reversing_secondhand/
14496 07-19-2026 22:21 reversing_secondhand/secondhand
--------- -------
14496 2 files
$ file secondhand
secondhand: ELF 64-bit LSB 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, not stripped. No server, no source.
Neither. It is a local binary. Run it with anything and it prints two paragraphs of flavour text and exits 0 - no prompt, no error, no hint that the input was wrong:
$ echo AAAAAAAA | ./secondhand
This is a rough cut. What is playing is the version we agreed to air, not the version that
happened. The honest take got cut. It is still in here, it is just not the one running.
Facilities, second floor printer. It jams on one page every run, the page in the middle that
everyone flips straight past. [...]
$ echo short | ./secondhand; echo "exit: $?"
(same output)
exit: 0
Conclusion: Wrong length and wrong content are indistinguishable from the outside, and the exit code never changes. There is nothing to probe against - this has to be solved statically.
Ghidra shows a function named threat_level_midnight() whose body is a wall of
bit operations. That looks like an obfuscator - possibly movfuscator - and would set the
tone for the whole binary.
It is not. The pattern is ordinary GCC SSE2 auto-vectorization; the function does what its C source presumably said, just widened to 128-bit registers.
Conclusion: A red herring, and the first of several. Ignore it and follow the actual
data flow from main instead.
main is short. Read what it does with the input before looking at anything
else.
The whole program is five steps:
1. read one line from stdin, insist on exactly 8 characters
2. x = uint64(those 8 bytes, little-endian)
3. h = FUN_00401c50(x) <- the self-decrypting shellcode
4. if h != 0xb8491337c0debabe: give up <- the only check in the program
5. flag = ciphertext[0..20] XOR splitmix64_keystream(seed = x)
=======
the same x
The ciphertext is 21 bytes sitting at 0x402520, and step 5 only runs once
step 4 has passed. Note where x appears: twice. It is what gets checked
in steps 3-4, and it is the decryption key in step 5.
Conclusion: One equation, one unknown - so the input can be computed instead of
guessed. And because x is the key rather than just a password, patching out
the comparison in step 4 leads nowhere: the program would proceed to step 5 and XOR the
ciphertext against a keystream built from the wrong seed.
FUN_00401c50 does not contain the hash. It decrypts a 38-byte blob from
.rodata (read backwards, rol 3, XOR against a 7-byte key at
0x402535), mmaps it RW, mprotects it RX and calls it.
The decrypted shellcode is eight instructions:
mov rax, rdi
mov r11, 0xa5a5a5a5deadbeef
xor rax, r11
rol rax, 0x11
mov r11, 0x100000001b3 ; FNV-1a prime
imul rax, r11
not rax
ret
Conclusion: h = ~(rotl64(x ^ 0xa5a5a5a5deadbeef, 17) * 0x100000001b3).
XOR, rotate, multiply by an odd constant and NOT - every one of them bijective, so the
whole thing inverts.
FUN_00401d90 is where the result should be checked, but Ghidra renders it as a
large switch-like construct that does not match the short function visible in the
disassembly.
The function computes its own entry point:
0x401dd0 + ((len & 0x1f) ^ 0xe). For len = 8 that lands
inside an instruction, and the bytes from that offset decode to a hidden
cmp h, 0xb8491337c0debabe. Ghidra disassembles from the nominal entry and
therefore shows a different function than the one that runs.
Conclusion: The clean FNV-style hash loop that is at 0x401dd0
is unreachable at length 8 - it would need len = 14 (mod 32). Another decoy.
The real target constant is 0xb8491337c0debabe.
| Decoy | Why it leads nowhere |
|---|---|
threat_level_midnight |
GCC SSE2 auto-vectorization, not an obfuscator |
ptrace(PTRACE_TRACEME, 0, 0, 0) |
textbook anti-debug, but it sits on the failure branch and never runs on the winning path |
FNV hash loop at 0x401dd0 |
unreachable at length 8; needs len = 14 (mod 32) |
H4D5_UP_Y0U_M4D3_IT |
a consolation prize - patching out the comparison prints it, but the line below
stays garbage, because the flag is keyed on x |
Conclusion: Nothing here needs defeating. Invert the hash and the binary hands over the flag by itself.
Undo the four operations in reverse order. The only one needing care is the multiply -
0x100000001b3 is odd, hence invertible modulo 2^64.
x = rotr64(~target * modinv(0x100000001b3, 2^64), 17) ^ 0xa5a5a5a5deadbeef
Conclusion: x = 0x28382f8d1780680f, little-endian
0f 68 80 17 8d 2f 38 28. Not printable, so it cannot be typed - it has to be
piped in.
Three short steps: invert the hash, verify by running it forward, and - for completeness - decrypt the flag offline without the binary at all.
#!/usr/bin/env python3
# Secondhand - recover the accepted 8-byte input and the flag.
M = (1 << 64) - 1
XOR_CONST = 0xa5a5a5a5deadbeef
MUL = 0x100000001b3 # FNV-1a prime
TARGET = 0xb8491337c0debabe
CT = bytes.fromhex("55dd4eb0a2b16c88e3a2d4224a1b250eb720db0bfa")
# --- 1. invert the shellcode hash --------------------------------------
h = TARGET
h = (~h) & M # undo NOT
h = (h * pow(MUL, -1, 1 << 64)) & M # undo multiply (MUL is odd)
h = ((h >> 17) | (h << 47)) & M # undo rotate-left-17
x = h ^ XOR_CONST # undo xor
print("x =", hex(x))
print("bytes =", x.to_bytes(8, "little").hex())
# --- 2. verify by running the hash forward -----------------------------
z = (x ^ XOR_CONST) & M
z = ((z << 17) | (z >> 47)) & M
z = (z * MUL) & M
print("check =", hex((~z) & M), "->",
"OK" if (~z) & M == TARGET else "MISMATCH")
# --- 3. the flag, without running the binary ---------------------------
def splitmix64(state):
state = (state + 0x9E3779B97F4A7C15) & M
z = ((state ^ (state >> 30)) * 0xBF58476D1CE4E5B9) & M
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & M
return state, (z ^ (z >> 31)) & M
state, keystream = x, b""
while len(keystream) < len(CT):
state, out = splitmix64(state)
keystream += out.to_bytes(8, "little")
print("flag =", bytes(a ^ b for a, b in zip(CT, keystream)).decode())
$ python3 solve.py
x = 0x28382f8d1780680f
bytes = 0f6880178d2f3828
check = 0xb8491337c0debabe -> OK
flag = HTB{WHY_S00_345Y_H0N}
And against the binary itself. The host is arm64 macOS, so it runs in an amd64 container; the input is not printable and has to be piped:
$ docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w ubuntu:24.04 \
sh -c "printf '\017\150\200\027\215\057\070\050\n' | ./secondhand"
H4D5_UP_Y0U_M4D3_IT
HTB{WHY_S00_345Y_H0N}
Both lines appear: the consolation message and the real flag. That is the
confirmation that x is right rather than merely accepted - a patched branch
would print the first line and garbage for the second.
HTB{WHY_S00_345Y_H0N}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Input | main reads one line through a hardened fgets wrapper and
requires exactly 8 characters, packed little-endian into a
uint64_t -> x. |
| 2 | Self-decrypting hash | FUN_00401c50 decrypts a 38-byte shellcode blob from
.rodata (backwards, rol 3, XOR with a 7-byte key), maps it
executable and calls it:
h = ~(rotl64(x ^ 0xa5a5a5a5deadbeef, 17) * 0x100000001b3). |
| 3 | Hidden comparison | FUN_00401d90 jumps to 0x401dd0 + ((len & 0x1f) ^ 0xe),
landing inside an instruction. Those bytes decode to
cmp h, 0xb8491337c0debabe, which is why the constant is invisible to a
linear disassembler. |
| 4 | The flag is keyed, not gated | On a match, x seeds splitmix64 and the keystream XORs the 21 ciphertext
bytes at 0x402520. Patching the branch is useless - without the correct
x the plaintext is garbage. |
| 5 | Inversion | XOR, ROL, multiply-by-odd and NOT are all bijective:
x = rotr64(~target * modinv(0x100000001b3, 2^64), 17) ^ 0xa5a5a5a5deadbeef.
No search, no brute force. |
What makes this one "insane" is not the maths - the hash inverts in four lines. It is the amount of work that looks mandatory and is not: a function that mimics an obfuscator but is compiler output, an anti-debug call that never executes on the winning path, and a clean hash loop sitting at the address the code jumps past. The real check is eight instructions long and never appears as such in the file. Fitting for the title: the honest take got cut, but it is still in there, running.