Ein milljón bjóra

`Exif.ReadLocation()` returns a raw `JsonElement`, giving a zero-byte write; steer the classifier to inflate the approved count

2026.09.30 NNS CTF 2026 Web
FLAG NNS{5K4l_e1n_m1llj0N_bjor4_o6_ei7t_t0M7_7up13}

Goal: GET /api/stats reveals the flag once counted = sumIf(amount, approved) >= 1_000_000. Every upload inserts exactly one row with amount = 1u hardcoded, and Approve() is a token bucket capped at 100, so the legitimate route can never get there. The way in is a RowBinary row desync driven by a VLM prompt injection.

Bug 1 — location writes zero bytes

Exif.ReadLocation() returns the raw System.Text.Json.JsonElement for the EXIF UserComment path, while the GPS fallback correctly wraps in Tuple.Create(...):

return (tagged, x.GetDouble(), y.GetDouble());              // JsonElement  <- buggy path
...
return (Tuple.Create(gps.Longitude, gps.Latitude), ...);    // proper tuple <- good path

ClickHouse.Driver’s TupleType.Write (which PointType extends) handles ITuple and IList and otherwise returns silently, writing nothing. A JsonElement is neither, so the Point column contributes 0 bytes instead of 16.

The exact desync arithmetic

Client writes, for the buggy path:

[amount UInt32 = 01 00 00 00][varint n][classification n bytes][approved 1 byte]   = n+6 bytes

Server re-reads that same buffer against the declared schema:

[location 16B][amount 4B][varint len][string][approved 1B]

Mapping the offsets (C = classification bytes, n = len(C)):

The row parses to completion (no EOF, no leftovers) exactly when

C[15] == n - 16

and then amount is the little-endian value of C[11:15] — any four printable ASCII bytes give well over a billion, so the 1,000,000 goal is met by a single row.

Bug 2 — steering the classification through the classifier

classification is the beer-classifier’s output, capped to 100 bytes by Fit(). The classifier is lusxvr/nanoVLM-230M-8k decoding greedily, so it is deterministic per image — and it reads text rendered into the photo. Painting GUINNESS across a beer photo turns the stock answer

'The beer in the glass appears to be a type of beer, possibly a stout or porter, given ...'   (n=100, C[15]=' ')

into

'The beer in the image is Guinness, a stout beer.'

which is exactly 48 bytes with a space at index 15 — i.e. C[15] == 32 == 48 - 16. ✅

Simulated server re-parse of that row:

client wrote   : 54 bytes
  location(16B): 0100000030546865206265657220696e
  amount       : 1,701,344,288        <- b' the' little-endian
  str len k    : 32
  string       : 'image is Guinness, a stout beer.'
  approved     : 1
  consumed     : 54 / 54  -> EXACT

Building the payload

The classifier only ever sees pixels (Image.open(...).convert("RGB").resize((512,512)), no EXIF transpose), so the probe and the exploit can share byte-identical pixel data:

  1. Render GUINNESS over a beer photo, save as JPEG — this is the image the model was verified against.
  2. piexif.insert() an EXIF UserComment of {"location":{"x":10.75,"y":59.91}} onto those exact JPEG bytes (rewrites only the EXIF segment, pixels untouched).
  3. POST /api/beers with it — the JsonElement path fires, the row desyncs, and the server stores amount = 1,701,344,288 with approved = true.
  4. GET /api/statscounted >= 1_000_000 → flag.

A local rebuild of the classifier container reproduced the live output byte-for-byte, so the whole search was done offline with no rate-limit cost.

Files

  • craft.py — GPS variant (probe, insert succeeds and echoes classification) vs. UserComment variant (exploit), plus the constraint checker
  • search.py — offline sweep over image variants against the local classifier
  • ocrtest.py — the text-overlay probe that found the Guinness phrasing
  • exploit/guinness_exp.jpg — the final payload