leaky-gradient
leaky-gradient
Platform: CUSTOM | Category: ML | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: affine_leak_recovery, basis_vector_probing, column_norm_thresholding, empirical_linearity_test
Summary
Walkthrough of the CUSTOM ML leaky-gradient. Task: remote ML oracle accepts a 64-bit value and returns a class plus a partially masked noisy 64-element leak, initially presented as a gradient. Solution: show the leak is actually affine in the in
Description
Original organizer description was not preserved in the local task notes.
The service accepts 16 hex characters (64 input bits) and returns a class label together with a partially masked, quantized 64-element leak vector. The goal is to recover the hidden secret encoded by the model and turn it into the final tjctf{...} flag.
Analysis
At first, the leak looked like a gradient of the loss with respect to each input bit. That suggested optimization-style attacks, so I briefly tried gradient descent / simulated annealing on the binary input space, tried fitting a linear softmax classifier to the observations, and even checked whether the raw leak values mapped directly to ASCII. None of those approaches produced a stable recovery.
The real breakthrough was testing whether the leak behaved linearly rather than like a true gradient. Empirically,
text read-only • copy allowedleak(e0 + e1) ≈ leak(e0) + leak(e1) - leak(0)
held with RMS error about 0.7, which was consistent with the integer quantization noise already visible in repeated samples. So the exposed signal was effectively an affine map:
text read-only • copy allowedleak(x) = W x + b
where x is the 64-bit input vector, W is an internal matrix, and b = leak(0) is the bias.
Once this was clear, the recovery problem became simple:
- query the all-zero input to get
b = leak(0) - query every basis vector
e_i - recover each column with
W[:, i] = leak(e_i) - leak(0)
Because the leak was randomly masked, each logical query had to be repeated several times and merged to reconstruct the full 64-element vector. After recovering all 64 columns, I computed their L2 norms. Those norms separated cleanly into two clusters:
- low cluster around
45 - high cluster around
81
Thresholding the norms yielded the bit pattern:
text read-only • copy allowed1011101011011100000011111111111011100000110111011111000000001101
Grouping the bits into nibbles gave:
text read-only • copy allowedREDACTED
which directly produced the flag body.
Solution
- Repeated the same query enough times to average out masking and quantization noise.
- Queried the zero vector and reconstructed
b = leak(0). - Queried all 64 basis vectors
e_iand reconstructed the corresponding full leak vectors. - Computed each recovered column as
W[:,i] = leak(e_i) - leak(0). - Measured the L2 norm of every column.
- Split the norms with a threshold between the two visible clusters.
- Read the resulting 64 bits as hexadecimal:
REDACTED. - Wrapped it in the required format.
python read-only • copy allowed#!/usr/bin/env python3 import json import socket import numpy as np HOST = "tjc.tf" PORT = 31002 SAMPLES = 40 def bits_to_hex(bits): out = [] for i in range(0, 64, 4): nib = (bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3] out.append(format(nib, "x")) return "".join(out) def recvline(sock): data = b"" while b"\n" not in data: chunk = sock.recv(4096) if not chunk: raise EOFError("connection closed") data += chunk return data.split(b"\n", 1)[0] def query_many(hex_input, samples=SAMPLES): acc = [[] for _ in range(64)] for _ in range(samples): sock = socket.create_connection((HOST, PORT), timeout=15) sock.settimeout(15) recvline(sock) recvline(sock) sock.sendall((hex_input + "\n").encode()) row = json.loads(recvline(sock).decode()) sock.close() for i, v in enumerate(row["leak"]): if v is not None: acc[i].append(v) return np.array([np.median(v) if v else np.nan for v in acc], dtype=float) def fill_missing(vec): if np.isnan(vec).any(): raise ValueError("increase SAMPLES until every coordinate is observed") return vec def basis_vector(i): bits = [0] * 64 bits[i] = 1 return bits_to_hex(bits) def main(): b = fill_missing(query_many("0" * 16)) cols = [] for i in range(64): ei = fill_missing(query_many(basis_vector(i))) cols.append(ei - b) W = np.column_stack(cols) norms = np.linalg.norm(W, axis=0) threshold = (norms.min() + norms.max()) / 2.0 bits = (norms > threshold).astype(int) bit_string = "".join(map(str, bits.tolist())) hex_secret = bits_to_hex(bits.tolist()) flag = f"tjctf{{{hex_secret}}}" print("bitstring:", bit_string) print("hex:", hex_secret) print("flag:", flag) if __name__ == "__main__": main()
Key Takeaways / Lessons
- Mastering affine_leak_recovery, basis_vector_probing, column_norm_thresholding, empirical_linearity_test was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-15 • CUSTOM • leaky-gradient • Difficulty: Medium