# 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 ```c 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: ```asm 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: ```asm 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: ```python key = sha256(bytes([v & 0xff for v in x]) + struct.pack('