$ cat writeup.md…
$ cat writeup.md…
umdctf
Task: a remote checker loads a predictor and a RealNVP purification flow, then validates an adversarial candidate under L-inf and likelihood constraints before printing the flag. Solution: submit a float32 array filled with NaN so every comparison in the checker evaluates false and the success path reveals the flag.
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
We are given a remote service plus the local files challenge.py, predictor.py, flow.py, model weights, and a reference input window. The service asks for a base64-encoded NumPy float32 array of shape (5, 64) and checks whether it is a valid purified adversarial example.
Remote target:
nc challs.umdctf.io 30302
The important logic is in challenge.py:
linf = float(np.max(np.abs(sub - x_ref))) if linf > EPS + 1e-6: return False lp = float(flow.log_prob(t).item()) if lp < lp_thresh: return False probs = F.softmax(predictor(purify(t, flow)), dim=1).numpy()[0] if probs[1] < CONF_FLIP: return False
All three decisions depend on floating-point comparisons performed on attacker-controlled data, but the checker never rejects NaN values.
That is fatal because IEEE-754 NaN is unordered:
nan > x is Falsenan < x is Falsenan <= x is Falsenan >= x is FalseSo if we can make every checked value become NaN, the code fails open.
Submitting an array filled with NaN does exactly that:
sub - x_ref contains NaN, so np.max(np.abs(...)) becomes NaN and linf > EPS is false.flow.log_prob(t) becomes NaN, so lp < lp_thresh is false.NaN, so probs[1] is NaN and probs[1] < CONF_FLIP is false.Since none of the guards trigger, check() reaches the success branch and returns the flag.
...
$ grep --similar