mlfreemedium

leaky-gradient

TJCTF 2026

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.

$ ls tags/ techniques/
affine_leak_recoverybasis_vector_probingcolumn_norm_thresholdingempirical_linearity_test

$ cat /etc/rate-limit

Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.

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,

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:

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

...

$ grep --similar

Similar writeups