← Back to EHAX 2026

Ghosty

Reverse Engineering 391 pts

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:

  1. 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's entry() function uses these callbacks to derive cryptographic keys, decrypt an embedded stage2 shared object (R0M1 blob) via XChaCha20-Poly1305, loads it via memfd_create, and calls its catalog then pulse function with the user's input.
  2. Stage 2 (decrypted pulse function): 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() and policy() 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 0
  • POLICY_TABLE: 256 bytes at offset 1024
  • SCRAMBLE_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