← Back to EHAX 2026
Ghosty
Challenge Description
"How freaky can you get with the ghost?"
Connection: nc chall.ehax.in 22222
Handout: Three files - libruntime.so (Rust shared library), main.lua (LuaJIT driver), tables_blob.bin (encrypted lookup tables).
Overview
The challenge implements a two-stage verification system:
- Stage 1 (
main.lua+libruntime.so): A LuaJIT script provides callback functions (mix32, scramble, get_salt, policy, log) via an API struct. The Rust library'sentry()function uses these callbacks to derive cryptographic keys, decrypt an embedded stage2 shared object (R0M1 blob) via XChaCha20-Poly1305, loads it viamemfd_create, and calls itscatalogthenpulsefunction with the user's input. - Stage 2 (decrypted
pulsefunction): Performs a complex series of transformations using the same API callbacks to derive a 32-byte target value, then compares the user's input against it. If they match, returns 0 (success); the Rust code then decrypts and prints the flag from the R0M2 blob.
Key Insight
The stage2 pulse function's comparison target is entirely deterministic and independent of user input. The target is derived from:
- The
get_salt()callback (fixed:0x13371337) - The
mix32()andpolicy()callbacks (deterministic based on the lookup tables) - The
scramble()callback (deterministic based on the scramble key) - Fixed constants embedded in the stage2 binary
Therefore, we can compute the expected input by reimplementing the derivation.
Solution Steps
1. Extract the Lookup Tables
The tables_blob.bin file (1312 bytes) is XOR-decoded with a 32-byte nonce to produce:
MIX_TABLE: 256 uint32_t values (1024 bytes) at offset 0POLICY_TABLE: 256 bytes at offset 1024SCRAMBLE_KEY: 32 bytes at offset 1280
2. Dump Stage2
Using LD_PRELOAD to intercept dlopen, the decrypted stage2 .so was dumped when the Rust library loaded it from a memfd.
3. Reverse the Target Derivation in pulse
The derivation in the pulse function:
salt = get_salt() # 0x13371337
initial_key = salt ^ 0xa5c31d2e
# 16 rounds of key mixing
state = initial_key
accumulator = 0
for i in range(16):
state = mix32(state, i ^ 0x9e3779b9)
state ^= accumulator
accumulator += 0x045d9f3b
state = policy(state)
# Generate 16-byte keystream
keystream = [state ^ (i * 0x7f4a7c15) for i in range(4)] # as uint32
# Scramble the keystream
scramble(keystream, 16, salt ^ state ^ 0x3c6ef372)
# Fold keystream back into state
for i in range(16):
state ^= keystream[i] << ((i & 3) * 8)
# Generate 32-byte target using xorshift PRNG
prng_state = state
target = bytearray(32)
for i in range(32):
prng_state = xorshift(prng_state) # shift 13, 17, 5
target[i] = data_at_0x21a5[i] ^ (prng_state & 0xFF)
4. Compute the Answer
Running the derivation produces:
target = ghost_8d3f4a91c2e7b6d0
5. Submit
$ echo "ghost_8d3f4a91c2e7b6d0" | nc chall.ehax.in 22222
Tools Used
- radare2 (r2) for disassembly
- GCC for C driver programs
- Python 3 for the solver script
- LD_PRELOAD interception for stage2 extraction