July 29, 2026
Reverse Engineering: RINgtrue - HTB Cyber Apocalypse CTF 2026: The Salt Crown (WriteUp)
Challenge Description: We are given a 64-bit ELF executable named “ringtrue”. The program prompts us for eight tone samples (integers) and…

By EVEX Security
2 min read
Challenge Description: We are given a 64-bit ELF executable named "ringtrue". The program prompts us for eight tone samples (integers) and acts as a Resonance Console to unlock a vault.
1. Initial Reconnaissance
Running the binary presents a boot sequence log that hints at an embedded neural network: "MLP 8–8–8–8", "int8", "leaky".
A quick pass with "strings" and basic static analysis confirms that instead of using a heavy framework like TensorFlow Lite, the matrix operations are hardcoded in C. We can spot several key symbols in the binary:
- Weights (L0_W, L1_W, L2_W): 64-byte arrays (int8).
- Biases (L0_B, L1_B, L2_B): 32-byte arrays (8x int32).
- Target Signature (ECHO_S): The expected output of the network.
- Ciphertext (VOW_CIPHER): The encrypted flag.
The binary takes 8 integers via "scanf("%d %d…", …)" and passes them through three dense layers. If the output matches ECHO_S, a custom hash of our input is used as a XOR key to decrypt VOW_CIPHER.
2. Dissecting the "Neural Network"
To build a solver, we need to precisely reverse-engineer the math behind the layers. Diving into the disassembly of the "dense" function and "main" reveals the actual implementation:
- Row-Major Matrix Multiplication: Inside "dense" , the pointer arithmetic (movsbq (%r10,%rax,1),%r8) indicates that the matrix multiplication reads weights in a row-major order. The correct index for the dot product is W[j * 8 + i].
- Custom LeakyReLU: The activation function isn't implemented inside "dense". Instead, in "main", right after the first two layer calls, there is a conditional move:
mov 0x70(%rsp,%rdx,1),%rax
lea (%rax,%rax,1),%rcx ; rcx = rax * 2
test %rax,%rax
cmovs %rcx,%rax ; if rax < 0, rax = rcxmov 0x70(%rsp,%rdx,1),%rax
lea (%rax,%rax,1),%rcx ; rcx = rax * 2
test %rax,%rax
cmovs %rcx,%rax ; if rax < 0, rax = rcxThis translates to a simple LeakyReLU variant: if (val < 0) val = val * 2. The third layer has no activation function.
Also a crucial detail is the size of ECHO_S. While the weights are 8-bit and biases are 32-bit, the accumulation and final comparison happen in 64-bit space. Extracting ECHO_S via GDB (x/8gx &ECHO_S) gives us 8 exact int64 target values.
3. Smashing the MLP with Z3
Since this neural network relies entirely on deterministic integer arithmetic without any precision loss (no floating-point math), we don't need gradient descent. We can model the exact algebraic constraints using the Z3 Theorem Prover.
Here we get to the complete extraction and solver script using "pwntools" and "z3-solver":
from pwn import ELF
from z3 import *
import struct
elf = ELF('./ringtrue')
# extract weights (convert to signed int8)
L0_W = [b if b < 128 else b - 256 for b in elf.read(elf.symbols['L0_W'], 64)]
L1_W = [b if b < 128 else b - 256 for b in elf.read(elf.symbols['L1_W'], 64)]
L2_W = [b if b < 128 else b - 256 for b in elf.read(elf.symbols['L2_W'], 64)]
# extract biases (int32)
L0_B = list(struct.unpack('<8i', elf.read(elf.symbols['L0_B'], 32)))
L1_B = list(struct.unpack('<8i', elf.read(elf.symbols['L1_B'], 32)))
L2_B = list(struct.unpack('<8i', elf.read(elf.symbols['L2_B'], 32)))
# hardcoded ECHO_S target values (int64)
ECHO = [
1542223, 574187, -2694563, -3518303,
383776, 576877, 2637871, -2518822
]
s = Solver()
X = [BitVec(f'x_{i}', 64) for i in range(8)]
# limit inputs to standard 32-bit signed integers (scanf constraints)
for x in X:
s.add(x >= -2147483648, x <= 2147483647)
def run_layer(inputs, W, B, use_act=True):
outputs = []
for j in range(8):
acc = B[j]
for i in range(8):
# row-major indexing
acc = acc + inputs[i] * W[j * 8 + i]
if use_act:
# custom LeakyReLU derived from cmovs instruction
acc = If(acc < 0, acc * 2, acc)
outputs.append(acc)
return outputs
# 3-layer network
h0 = run_layer(X, L0_W, L0_B, use_act=True)
h1 = run_layer(h0, L1_W, L1_B, use_act=True)
out = run_layer(h1, L2_W, L2_B, use_act=False)
# constraints for the target signature
for i in range(8):
s.add(out[i] == ECHO[i])
if s.check() == sat:
m = s.model()
ans = []
for i in range(8):
val = m[X[i]].as_long()
# handle 64-bit two's complement
if val >= (1 << 63):
val -= (1 << 64)
ans.append(val)
print(" ".join(map(str, ans)))
else:
print("Unsat")from pwn import ELF
from z3 import *
import struct
elf = ELF('./ringtrue')
# extract weights (convert to signed int8)
L0_W = [b if b < 128 else b - 256 for b in elf.read(elf.symbols['L0_W'], 64)]
L1_W = [b if b < 128 else b - 256 for b in elf.read(elf.symbols['L1_W'], 64)]
L2_W = [b if b < 128 else b - 256 for b in elf.read(elf.symbols['L2_W'], 64)]
# extract biases (int32)
L0_B = list(struct.unpack('<8i', elf.read(elf.symbols['L0_B'], 32)))
L1_B = list(struct.unpack('<8i', elf.read(elf.symbols['L1_B'], 32)))
L2_B = list(struct.unpack('<8i', elf.read(elf.symbols['L2_B'], 32)))
# hardcoded ECHO_S target values (int64)
ECHO = [
1542223, 574187, -2694563, -3518303,
383776, 576877, 2637871, -2518822
]
s = Solver()
X = [BitVec(f'x_{i}', 64) for i in range(8)]
# limit inputs to standard 32-bit signed integers (scanf constraints)
for x in X:
s.add(x >= -2147483648, x <= 2147483647)
def run_layer(inputs, W, B, use_act=True):
outputs = []
for j in range(8):
acc = B[j]
for i in range(8):
# row-major indexing
acc = acc + inputs[i] * W[j * 8 + i]
if use_act:
# custom LeakyReLU derived from cmovs instruction
acc = If(acc < 0, acc * 2, acc)
outputs.append(acc)
return outputs
# 3-layer network
h0 = run_layer(X, L0_W, L0_B, use_act=True)
h1 = run_layer(h0, L1_W, L1_B, use_act=True)
out = run_layer(h1, L2_W, L2_B, use_act=False)
# constraints for the target signature
for i in range(8):
s.add(out[i] == ECHO[i])
if s.check() == sat:
m = s.model()
ans = []
for i in range(8):
val = m[X[i]].as_long()
# handle 64-bit two's complement
if val >= (1 << 63):
val -= (1 << 64)
ans.append(val)
print(" ".join(map(str, ans)))
else:
print("Unsat")4. Retrieving the Flag
Running the script yields the required array of 8 integers instantly: 83 97 108 116 67 114 119 110.
Feeding these tone samples into the binary satisfies the neural network, triggering the internal decryption routine.
Flag: HTB{h3y_s1gn3t_1_4m_y0ur_k1ng}
Authored by EVEX Security.