Ein milljón bjóra
`Exif.ReadLocation()` returns a raw `JsonElement`, giving a zero-byte write; steer the classifier to inflate the approved count
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:
- Render
GUINNESSover a beer photo, save as JPEG — this is the image the model was verified against. piexif.insert()an EXIFUserCommentof{"location":{"x":10.75,"y":59.91}}onto those exact JPEG bytes (rewrites only the EXIF segment, pixels untouched).POST /api/beerswith it — theJsonElementpath fires, the row desyncs, and the server storesamount = 1,701,344,288withapproved = true.GET /api/stats→counted >= 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 echoesclassification) vs. UserComment variant (exploit), plus the constraint checkersearch.py— offline sweep over image variants against the local classifierocrtest.py— the text-overlay probe that found the Guinness phrasingexploit/guinness_exp.jpg— the final payload