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 — 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_B … L2_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
CMOVSis the difference between a closed-form inversion and a search problem. Readingdensecarefully 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.