HTB Cyber Apocalypse 2026 - The Salt Crown: Reversing / Secondhand (Insane)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

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.

1. Download

$ 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.

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 Chase the obfuscated-looking function (failed)

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.

3.2 Map the input path (success)

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.

3.3 Decrypt the shellcode (success)

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.

3.4 Find where the comparison actually happens (failed, then fixed)

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.

3.5 Note the remaining decoys (failed / dead ends)

DecoyWhy 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.

3.6 Invert the hash (success)

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.

4. Solution

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())

5. Run it

$ 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.

Flag

HTB{WHY_S00_345Y_H0N}

6. Summary of how the exploit works

#StageMechanism
1Input main reads one line through a hardened fgets wrapper and requires exactly 8 characters, packed little-endian into a uint64_t -> x.
2Self-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).
3Hidden 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.
4The 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.
5Inversion 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.