← Back to Writeups
CUSTOMMediumML

leaky-gradient

XESXOR9/10/20264 min read
#ml#custom#medium#affine_leak_recovery#basis_vector_probing#column_norm_thresholding#empirical_linearity_test

leaky-gradient

Platform: TJCTF 2026 | Category: ML | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-15 | Status: Solved Techniques: affine_leak_recovery, basis_vector_probing, column_norm_thresholding, empirical_linearity_test

Summary

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 input, recover the bias and all basis columns, then threshold column norms to reconstruct the hidden 64-bit secret and hex-decode the flag body.

leaky-gradient — TJCTF 2026

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
leak(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
leak(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
1011101011011100000011111111111011100000110111011111000000001101

Grouping the bits into nibbles gave:

text
REDACTED

which directly produced the flag body.

Solution

  1. Repeated the same query enough times to average out masking and quantization noise.
  2. Queried the zero vector and reconstructed b = leak(0).
  3. Queried all 64 basis vectors e_i and reconstructed the corresponding full leak vectors.
  4. Computed each recovered column as W[:,i] = leak(e_i) - leak(0).
  5. Measured the L2 norm of every column.
  6. Split the norms with a threshold between the two visible clusters.
  7. Read the resulting 64 bits as hexadecimal: REDACTED.
  8. Wrapped it in the required format.
python
#!/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()