← 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: 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
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()

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