[THUGS(red)]

Ringtrue

Echo of Astrael - An 8-input MLP with a bijective (non-ReLU) activation function was inverted analytically layer by layer to recover the unique valid input.

ringtrue-writeup.md 5.2 KiB

ringtrue — Echo of Astrael

| | |

|---|---|

| Platform | HackTheBox |

| Category | Reversing |

| Target | ringtrue — x86-64 ELF, PIE, stripped of nothing (symbols intact), glibc 2.34 |

| Techniques | Static analysis (Ghidra), neural-net weight extraction, integer linear algebra, SHA-256 keystream recovery |

| Author | b3sk4r |

TL;DR

The binary asks for eight integers, pushes them through a hand-rolled 3-layer int8 MLP, and compares the output to a hardcoded target vector. On an exact match it SHA-256s the low byte of each input and XORs the digest against a 30-byte ciphertext to print the flag. Because the activation function is bijective over the integers and all three weight matrices are invertible, the network can be inverted analytically — no brute force, no solver, one unique answer.

Recon

strings and the boot animation set a heavy "relic / resonance" theme, but two log lines are load-bearing rather than decorative:


[ 0.061200] npu: resonance_core.tflm - MLP 8-8-8-8
[ 0.077553] crypto: vault-seal = xor-stream, [...]

That is the whole challenge described up front: an 8→8→8→8 network, then an XOR stream cipher. The symbol table confirms it — dense, L0_W/L0_BL2_W/L2_B, ECHO_S, VOW_CIPHER, VOW_LEN.

Input handling is unremarkable: fgets into a 256-byte buffer, then


sscanf(buf, "%d %d %d %d %d %d %d %d", ...)   // must return 8

The eight int32 values are sign-extended to int64 and become the input vector.

The network

dense is a textbook quantized matmul:


MOVSXD  R9, dword ptr [RSI + R11*4]    ; bias[j]  (int32)
MOVSX   R8, byte ptr [R10 + RAX]       ; W[j][i]  (int8)
IMUL    R8, qword ptr [RDX + RAX*8]    ; * x[i]
ADD     R9, R8                         ; accumulate in int64

So out[j] = B[j] + Σ W[j][i] · x[i], no scaling, no saturation — the W_SCALE floats in .data are never referenced.

The activation is the interesting part, and it is not ReLU:


LEA    RCX,[RAX+RAX]     ; 2x
TEST   RAX,RAX
CMOVS  RAX,RCX           ; x < 0  ->  2x

f(x) = x for x ≥ 0, f(x) = 2x otherwise. Misreading this as ReLU is the obvious trap: ReLU destroys information on the negative branch, which would make the network non-invertible and push you toward brute force. This function is a bijection on ℤ, and its inverse is trivial — v if v ≥ 0, else v/2 (which must come out even, a free consistency check at every layer).

Success is an exact 64-bit comparison of all eight outputs against ECHO_S. The RESONANCE [####] 85% bar and the per-neuron h0..h7 meters are a hot/cold oracle (thresholds 20k / 100k / 600k, and 100 − Σ|Δ| / 80000 for the percentage). Tempting to hill-climb on — unnecessary once the algebra is clear.

Solve

Extract the constants straight out of .data:


$ objdump -s -j .data ./ringtrue

That yields ECHO_S (8 × int64), three 8×8 int8 weight matrices, three int32 bias vectors, and the 30-byte VOW_CIPHER. Then invert layer by layer — solve W·p = target − B over ℚ, assert the result is integral, invert the activation, repeat:

| layer | input vector recovered |

|---|---|

| L2 | 25473, 162408, 49600, -226556, 33555, 20062, 68975, 9391 |

| L1 | 4523, -586, 4655, 2996, 3385, -128, -4138, 2290 |

| L0 | 83, 97, 108, 116, 67, 114, 119, 110 |

Every solve landed on exact integers and every negative pre-activation was even, so the solution is unique. Forward-running the network reproduces ECHO_S on all eight outputs.

Those eight numbers are ASCII: SaltCrwn — the challenge was telling you the answer in its own theme the whole time.

Keystream

On a match the binary builds a 12-byte message — the low byte of each input plus a 4-byte little-endian block counter — hashes it with SHA-256 (standard IV, K table at .rodata+0xa80), and XORs the digest against VOW_CIPHER. With VOW_LEN = 30 a single block covers the whole plaintext:


key = sha256(bytes([v & 0xff for v in x]) + struct.pack('<I', 0)).digest()
flag = bytes(c ^ k for c, k in zip(cipher, key))
# HTB{INSERT_YOUR_CAPTURED_FLAG}

Lessons

  • Check the activation before assuming the architecture. One CMOVS is the difference between a closed-form inversion and a search problem. Reading dense carefully was worth more than any tooling.
  • A "hot/cold" progress meter is a leak. Even without the algebra, the per-neuron proximity bars reduce the keyspace enormously — real products should never expose graded feedback on a secret comparison.
  • Comparing against a hardcoded target is not a security control. The target, the weights and the ciphertext all shipped in .data. An MLP is obfuscation, not a KDF; anything derived from user input alone is recoverable by the user.
  • Theming is signal. salt-mask boot, brine-crystal, Salt Crown — the flavour text encoded the answer, which is a useful reminder to read strings for content and not just for format specifiers.

Tools

| Tool | Purpose |

|---|---|

| Ghidra | Static analysis, function and data-structure recovery |

| objdump -s -j .data | Verbatim extraction of weights, biases, target vector, ciphertext |

| Python (fractions, hashlib) | Exact rational linear solve, activation inversion, keystream reconstruction |

Something wrong with this page?

Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.