Security audit (2026-09)

Scope: the runtime (memory safety, reference counting, allocator, fixed pools), every parser of untrusted input, the network services and their authentication, filesystem and process access, deployment (export, deploy.sh, systemd), the compiler’s generated C++, and the supply chain. Method: code review, then one fuzz harness per parser (tests/fuzz/, libFuzzer + ASan + UBSan) run until clean, then a conformance regression per runtime fix (tests/conformance/hardening.ts). The resulting guarantees and limits are in guide/08-security.md; third-party code in security/third-party.md.

Severity: critical remote memory corruption or a crash from one packet on a default listener; high memory corruption from program or file input, remote code or file access, privilege escalation; medium denial of service, missing authentication or hardening, injection needing unusual input; low defence in depth.

Findings

#SeverityComponentIssueFixStatus
1criticalzinc:net serve (runtime/mod/net.cpp)Content-Length parsed with atol: a negative or overflowing value passed the length check and copied ~4 GB out of bounds (one request crashes any serve() program); atol also read past the bufferdigits only, within the headers, at most ZRT_HTTP_MAX_BODY (1 MiB); 400 / 413 / 431 replies; corpus http/content-length-*fixed
2criticalzinc:mqtt (runtime/mod/mqtt.cpp)PUBLISH topic length not checked against the packet: out-of-bounds read then a ~4 GB copy; remaining-length varint unbounded (wraps); CONNACK read p[1] of a 0-byte packet4-byte varint, fields checked against the packet, 1 MiB packet cap (ZRT_MQTT_MAX_PACKET); corpus mqtt/*fixed
3criticalremote display RLE (plugins/display-remote/remote_proto.h)rle_decode with a rectangle width of 0 never wrapped to the next row: heap overflow in any zinc:remote viewer (ZincStudio included) fed by a malicious server or a spoofed beaconempty rectangles take no data; rle_check.cpp asserts it; corpus remote/rect-zero-widthfixed
4highgfx.path / polygon (runtime/gfx.cpp)contour counts are program data: a negative count was UB (double → unsigned), a huge one wrapped the index and overflowed the point pool, and the stored count claimed points that were never written (rasterizer read past them); an open polygon wrote one float past the poolcounts clamped to the points present, written count exact, pool check +1; gfx::emit validates plugin contours toofixed (hardening.ts, fuzz/gfx.cpp)
5highrasterizer (runtime/raster.cpp)a NaN y coordinate became a garbage row index: out-of-bounds write into the edge table; out-of-range float → int conversions (UB) everywhere a command coordinate is converted; image scaling with a tiny width gave a negative sample index (read before the image)NaN edges skipped; saturating conversions for command coordinates; per-pixel paths clamp or validate the scale first (1e6 texels/pixel)fixed
6highpools + weak references (runtime/zrt.cpp, zrt_ext.h)a @pooled object that was @weak-referenced was freed with the system allocator (static pool storage): crash / heap corruptionpools register their slab; mfree returns such a slot to its poolfixed (hardening.ts, ASan)
7higharenas (runtime/zrt.cpp)an Arena disposed while a newer one was open stayed linked from it: use-after-free on every later allocation checkunlinked wherever it is in the chainfixed (hardening.ts, ASan)
8highzinc:remote viewer (plugins/remote-view)a HELLO size the runtime refused left the session’s size set: the next RECT wrote past the imagethe size is checked against the image actually allocated; protocol error otherwisefixed
9highglTF / GLB (plugins/three)GLB chunk length off + 8 + clen wrapped in 32 bits: infinite loop or a JSON chunk of ~4 GB (out-of-bounds read); out-of-range JSON number casts (UB)length checked by subtraction; saturating casts of every JSON numberfixed (fuzz/gltf.cpp)
10highvector tiles (plugins/map)a LineTo before any MoveTo incremented a coordinate as if it were the contour count (out-of-bounds read in tile rendering); protobuf sub-messages read uninitialised when the wire type differed; varint shift ≥ 64 (UB)points before a MoveTo dropped; cursors reset per field; shift boundedfixed (fuzz/map.cpp)
11highTrueType (runtime/ttf.cpp)ttfs[8] overflowed with more than 5 custom fonts in assets/ (a normal project); no read was bounds-checked; glyph ids ≥ 2^31 bypassed the glyph check; numH = 0 / upem = 032-entry table, guarded; every read through a bounds-checked reader; composite fan-out budgetfixed (fuzz/ttf.cpp)
12highdeploy.sh (compiler/src/tools.ts)staged in a predictable /tmp/<name> and copied with sudo rsync -a: the binary and unit stayed owned by the ssh user while the service ran as root (local root escalation, or a pre-planted /tmp/<name>)mktemp -d staging, installed root:root, fixed modesfixed
13highsystemd unit (zinc export --target linux/rpi1)ran as root with no sandboxing; network listeners bind all interfaces by defaultDynamicUser, no capabilities, NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, kernel/clock/cgroup protections, StateDirectory for storage, device groups filtered by deploy.shfixed
14highresource baking (compiler/src/resources.ts)asset file names (fonts, images) pasted into C++ string literals unescaped: a file name with " and a newline injects code into the programoctal-escaped literalsfixed
15highzinc:mapping over OSC/save and /load took any path from the network (arbitrary file write, e.g. a shell profile); /sync replied with up to 17 datagrams to any address (amplification)plain file names only over OSC, /sync at most 10/s; ZINC_BIND to restrict the listenerfixed
16highmapping web companion (examples/video/mapper/companion)listened on all interfaces with no authentication; any web page in the user’s browser could POST OSC to it (CSRF), reaching #15127.0.0.1 by default, per-run token in a custom header (also blocks CSRF)fixed
17mediumzinc:net serveunbounded request buffer, no connection or idle limits, a busy loop on EAGAIN while sending (a client that does not read froze the event loop)16 KiB headers + body cap, 32 connections, 10 s idle, blocking send with a 2 s timeoutfixed
18mediumDevTools (plugins/devtools)no Host or Origin check: any web page could open ws://127.0.0.1:9229 (screenshot, DOM edits), and DNS rebinding worked403 unless Host is loopback and Origin (when sent) is the DevTools frontendfixed (corpus cdp/*)
19mediumdisplay-remoteno authentication when bound to the LAN; the newest connection replaced the viewer and injected inputoptional token (ZINC_REMOTE_TOKEN / token), AUTH before HELLO, unauthenticated connections never replace the viewer, warning when exposed without onefixed
20mediumdisplay-remote / remote-viewPING replies queued without limit for a client that does not read; realloc results unchecked; the viewer buffered any message length the server announcedPONG only while the queue is small, checked reallocs, message length bounded by the screen sizefixed
21mediumSVG (plugins/svg)nested <g> overflowed the stack; stroke-dasharray with infinite or tiny values looped forever while allocating; <use> fan-out was exponential; the float contour count saturated at 2^24 (then read wrong)nesting 256, element budget 200000, point cap 2^22, numbers clamped to ±1e30, absurd dash arrays draw solidfixed (fuzz/svg.cpp, corpus svg/*)
22mediumLottie (plugins/lottie)precomps referencing themselves and nested repeaters expanded exponentially; out-of-range float → int casts (UB)100000 layers/copies per frame; saturating castsfixed (fuzz/lottie.cpp)
23mediummap styles (plugins/map)JSON recursion unbounded (parse, free, filter evaluation); strlen(NULL) when source-layer or a get key is not a stringdepth 64, type checksfixed
24mediumstrings (runtime/zrt.cpp)s.lastIndexOf('') never returned (found by fuzzing); lastIndexOf rescanned from each match (quadratic)one backward scanfixed (hardening.ts, fuzz/strings.cpp)
25mediumruntime sizes (zrt.h, zrt.cpp)on 32-bit targets sizeof(T) * n could wrap (small block, then overflow); TLSF rounded a huge request to a tiny block; StrBuilder looped forever past 2^31 bytes; runtime images w * h * 4 wrappedchecked element sizes, n > budget in the allocator, strings capped at 2^30, images at 16384 per sidefixed
26mediumgenerated C++ (compiler/src/emit-cpp.ts)#line directives pasted file names unescaped: a file name with a quote or newline injected code into debug buildsJSON-escaped file namesfixed
27mediumzinc exportthe project name flowed unchecked into rm -rf of dist/<name>-<target>, shell commands run with sudo on the device, unit and .desktop linesseparators and control characters refused; a plain [A-Za-z0-9._-] word for linux/rpi1/rmppfixed
28mediumzinc:storage (runtime/mod/storage.cpp)file created world-readable (the guide recommends it for tokens), truncated before rewriting (a crash lost everything), values over 8 KiB split into bogus entrieswritten to a 0600 temp file, fsync, rename; lines of any lengthfixed
29mediumzinc:processarguments travel joined by U+001F: an argument containing it became several (argument injection), a NUL cut it; the program’s sockets and files leaked into childrensuch arguments throw; children get only stdin/out/err (POSIX_SPAWN_CLOEXEC_DEFAULT / closefrom)fixed (examples/process/cli)
30mediumwasm dev server (compiler/bin/serve.mjs)listened on all interfaces; GET /%E0 crashed it; the root prefix check let <dir>-x/ siblings throughloopback (HOST to change), 400 on bad escapes, separator-terminated rootfixed
31mediumsupply chain (docker/)SDK base images and ESP-IDF referenced by tag (latest for ps2)pinned by digestfixed
32mediumbuild flagsrelease builds had no stack protector, FORTIFY or RELRO (depended on each toolchain’s defaults)-fstack-protector-strong -D_FORTIFY_SOURCE=2, -z relro -z now on Linux; measured belowfixed
33mediumstb_image configuration (plugins/three)internal asserts compiled out; no dimension limit: a few hundred bytes of JPEG declaring 16384 x 16384 pixels ran the decoder for minutes and asked for 1 GiB (found by fuzzing)asserts abort, STBI_MAX_DIMENSIONS 16384, at most 2^24 pixels decoded (checked with stbi_info first); upstream advisories in third-party.mdmitigated
34mediumJSON.parse on small targets512 nesting levels of recursion on a 16 KiB ESP32 stackZRT_JSON_DEPTH 32 on esp32 and ps1mitigated
35lowDyn / array indices(int32_t) of an out-of-range or NaN double index before the range check (UB, UBSan)range checked firstfixed
36lowevent loop(uint64_t)(wait * 1000) for a huge timer delay (UB)at most one second per sleepfixed
37lowzinc monitorbound all interfaces, printed datagram strings raw (terminal escape injection), crashed on {"type":"hello"}loopback (ZINC_MONITOR_HOST), control characters escaped, handler guardedfixed
38lowzinc:webview (macOS)the IPC bridge trusted any port on localhostonly the port of the page the app openedfixed
39lowzinc:fsa path containing NUL was cut by libc ("x\0.txt" passes an endsWith('.txt') check)such paths failfixed
40lowasset embeddinghidden directories (assets/.git, .secrets) were embeddedhidden files and directories skippedfixed
41lowESP32 WiFi credentialsssid and password pasted into CMake unescaped (${…} expanded, ; split); password only in zinc.jsonquoted CMake arguments; ZINC_WIFI_PASSWORD from the environmentmitigated
42lowzinc tsconfig / editor pluginrm -rf node_modules/zinc-ts-plugin even if it was a real packageonly our own symlink is replacedfixed
43lowzinc:videoplayer size from the file’s aspect ratio unbounded (UB cast, huge mallocs unchecked)at most 8192 per side, allocations checkedfixed
44lowsim OSC (sim/osc.mjs)a truncated datagram threw in the simulatordecoded like the native onefixed
45lowinput tapes (ZINC_REPLAY)a replayed tape’s touch, key and text counts indexed fixed arrays uncheckedclamped when readfixed
46lowdeferred destruction (runtime/zrt.cpp)when the 4096-slot deferred queue is full, a release cascade recurses again (stack)bounded by the object graph’s depth; documentedaccepted
47lowobfuscation docs“every string literal” overclaimed; the keystream seed is publicguide correctedfixed
48lowremote-display beacons, OSCUDP senders are unauthenticated and spoofabledocumented (a listed app is not an authenticated one)accepted
49lowasset embeddingsymlinks under assets/ are followed (an asset can come from outside the project)documented: the build machine is trustedaccepted
50lowZincStudio projectsa .zproj contains code that runs when the project runsdocumented: open projects you trustaccepted
51lowzinc:processa single empty argument (['']) is dropped by the joindocumentedaccepted
52lowESP32 fetch (runtime/mod/net_esp32.cpp)malloc results unchecked; two literal lengths include the NULreported to the web-APIs work (owns fetch)open
53lowhost fetch (runtime/mod/net.cpp)no CURLOPT_TIMEOUT, no response size capreported to the web-APIs work (owns fetch)open
54infocompileridentifiers such as INT32_MAX, __LINE__, NAN fail to compile (macro collisions); not injection–open
55inforuntime-fno-threadsafe-statics while some plugins run worker threads (gphoto2, video): a function-local static first used on two threads would racenone observed; review when a plugin adds statics in thread codeopen

Fuzzing

scripts/fuzz.sh builds each harness with libFuzzer + ASan + UBSan (UB is fatal) against the runtime (ZRT_DEBUG: every allocation through malloc) and runs it with its seed corpus; zinc test --fuzz replays the corpora.

Machine under heavy load from other builds (load average 60 to 110), so executions per second are low; each harness ran in several campaigns (10 to 60 s while fixing, then 180 s on the final code).

HarnessParserExecutions (final 180 s run)Executions (all runs)FoundStatus
jsonJSON.parse + stringify round trip8 092 07913 402 921–clean
stringsUTF-16 indexing, slicing, search, case, padding on arbitrary bytes1 548 5222 984 006lastIndexOf('') hang (#24)fixed
httpnet.serve request parser, split across two reads1 110 4265 591 476–clean
mqttbroker packets (CONNACK, PUBLISH, topic matching)8 312 83612 129 988–clean
oscOSC datagrams18 147 51033 429 686–clean
cdpDevTools HTTP (Host / Origin) and WebSocket frames1 993 0503 404 805–clean
remoteremote display stream (HELLO, RLE RECT, FRAME, PONG)1 387 2361 874 346–clean
gfxevery zinc:gfx draw call with hostile numbers, then rasterized49 95671 639–clean
svgSVG parse, draw, rasterize2 2924 265dash loop, <use> explosion, 2 GB allocation (#21)fixed
lottieLottie parse, three frames drawn and rasterized100 875242 346UB conversions (#22)fixed
gltfglTF / GLB and every loader query413 126827 157UB conversions (#9)fixed
imagePNG / JPEG (stb_image, as configured)452 466476 018JPEG decompression bomb (#33)fixed
ttfTrueType parse and rasterization630 391771 986–clean
mapmap styles and vector tiles, decoded, filled, stroked25 32156 299–clean

About 75 million executions in all, no open crash. svg and map are slow per input (each one rasterizes a frame or tile under ASan); longer campaigns would help them most. The HTTP client is libcurl’s (fuzzed upstream) and WebSocket exists only in the DevTools endpoint (cdp).

Found by fuzzing and fixed: #24 (lastIndexOf('') hang), #33 (JPEG decompression bomb), #21 (SVG dash loop and <use> explosion reproduced, the point budget), #22 (Lottie UB casts), #9 (glTF UB casts). The seeds include the reproducers of the review findings (http/content-length-*, mqtt/*, remote/rect-zero-width, svg/nest-deep, svg/dash-inf, svg/use-fanout, json/deep, image/huge-jpeg-timeout).

Hardening flags overhead

CPU time (user + sys), median, macOS arm64, this machine under load (other builds running), baseline commit vs this branch without (ZINC_HARDEN=0) and with the flags:

Benchmarkbaselinebranch, no flagsbranch, flags
raster micro-benchmark (400 frames of rrects, polygons, strokes, text, shadows, full render), 15 runs554 ms557 ms (+0.4 %)548 ms (within noise)
fannkuchredux (tight array permutations), 9 × 20 runs7.81 s7.83 s (+0.3 %)7.97 s (+1.8 % vs no flags)
binarytrees, fib, jsonout, mandelbrot, mapset, nbody, sort, spectralnorm, strings–±0 %0 to +1.4 % (noise level)

The runtime fixes themselves cost nothing measurable once the per-pixel paths clamp before converting (a first version with saturating conversions everywhere cost 4 % on the raster benchmark). The flags are on for release builds of hosted targets; ZINC_HARDEN=0 turns them off.

Tests

node compiler/src/cli.ts test --target macos for the profiles macos, ps1, esp32, rpi1 and rmpp, with --debug (ASan + UBSan) and --pixels: see the results below; tests/conformance/hardening.ts is the runtime regression program (each block crashed, hung or corrupted memory under ASan before its fix).

Command (--target macos)Result
test (profile macos)34 programs, 0 failures
test --profile ps134 programs, 0 failures
test --profile esp3234 programs, 0 failures
test --profile rpi134 programs, 0 failures
test --profile rmpp34 programs, 0 failures
test --debug (ASan + UBSan)34 programs, 0 failures
test --pixels6 frames, 0 failures
test --fuzz (corpora replay)14 harnesses, 0 failures

Also checked: a Linux build in zinc/sdk-linux (PIE, BIND_NOW, GNU_RELRO), examples/process/cli (all checks, including the new separator check), the mapping companion (401 without the token, 204 with it), the webview, video and mapper examples (build), plugins/display-remote/rle_check.cpp, tsc -p compiler.

Edit this page on GitHub ↗