Security audit, 2026-08-16 — the whole threat model
Status: executed 2026-08-16 — two vulnerabilities found and fixed (httpd response splitting CWE-113, regex repetition bomb); the rest clean per package below. Writers, concurrency, and 32-bit lanes uncovered.
Maintainer material. Everything below was executed, not read — where a claim rests on running something, the command and the answer are named.
Scope: every entry point in audit-brief.md's threat model. Two vulnerabilities were found and fixed; the rest came back clean, and the per-package reasoning is below so a reviewer can disagree with it.
| entry point | verdict |
|---|---|
a PNG — core:image | ceiling correct, untested; make image-ceiling added |
an HTTP request — core:httpd | response splitting (CWE-113), fixed |
an archive — core:zip | no memory-safety defect; API conflates four outcomes |
a socket — core:net | no memory-safety defect; one comment corrected |
markdown — core:markdown | clean; evasion vectors added to the test |
an HTTP response body — core:http | clean, and already ILP32-aware |
a TLS stream — core:tls | clean |
a decompressed stream — core:compress | ceiling correct and already gated |
a regex pattern — core:regex | memory bomb, fixed; one corruption left open |
The language property both packages rest on
Established by running it, because it decides whether every unchecked read below is a bug:
| construct | out of range | result |
|---|---|---|
b[i] | index | aborts — tycho: string index 99 out of bounds (len 4) |
b[a:c] | slice | clamps — b[2:60000] on 4 bytes gives length 2 |
Both are memory-safe. An over-long slice is a short answer; an over-long index is a trap that kills the process. Neither reads out of bounds. A parser here therefore cannot leak adjacent memory, but it *can* be made to abort, which is a denial of service for anything parsing an upload.
core:zip
No memory-safety defect found. The parse path is guarded where it needs to be, and the language covers the rest.
le16/le32do no bounds checking of their own. Every call is reached through a guard:listandentry_bodyboth testpos + 46 > len(b)beforele32(b, pos), andorshort-circuits — confirmed by running a case whose right arm would abort if evaluated.find_eocdscans backward fromlen(b) - 22, so its reads stay in range for any input, including one shorter than 22 bytes (the loop does not run).- The EOCD scan is unbounded — real readers stop after 64 KB, since the comment length is 16-bit. Measured: 1 MB → 0.01 s, 4 MB → 0.02 s, 16 MB → 0.07 s, 64 MB → 0.26 s. Linear, ~4 ns/byte, and the archive is already in memory by then. Not a practical denial of service; not worth a ceiling.
entry_bodyomits the full-record bound thatlistapplies (pos + 46 + nlen + elen + clen > len(b)). Because slices clamp, the effect is a truncated name that fails to match — fail-closed, not a memory error. Reachable only by callingentry_bodydirectly;extractrunslistfirst, which validates every record it returns.
The finding is the API, not the parser. extract returns bare bytes, and an empty result is four different answers: the name is absent, the archive is malformed, the CRC did not match, or the member is genuinely zero bytes. A caller writing that to disk turns a CRC failure into an empty file silently. core:compress named this Truncated and core:image named it Corrupt, both for the same reason. core:zip has no error channel at all. Marked gap: in the package header.
No shipped consumer is exposed: tools/tycho-snap only calls zip.create, zip.list and zip.crc32, never extract.
Entry names are returned verbatim, including ../../etc/passwd — correct for a reader, and already stated in the header and pinned by the package test.
core:net
No memory-safety defect found. The two receive paths are the whole of the untrusted surface and both are careful:
netx_readandnetx_udp_readrefusemax <= 0*before* the cast tosize_t, so a negative length cannot become an enormous allocation.- Both check
mallocfor NULL and fail closed with an empty result rather than a partial read. netx_readdistinguishes EOF, timeout and error through a status out-parameter, whichnet.readmaps toErr(Eof)/Err(Timeout)/Err(Failed).has_nulguardslisten,connect,udp_bindandudp_send, so a NUL-bearing host is refused rather than silently truncated at the FFI boundary.
One defect found and fixed: the comment on netx_udp_read's error arm said a zero-length datagram would "keep buf, outlen 0", and the line below it freed and returned. Both arms produce the identical observable result, so a legal zero-length UDP datagram cannot be told apart from a receive error. The behaviour is a real limitation of a sentinel-returning API; the comment described an intent the code did not implement. Corrected, and marked gap: with the shape a fix would take.
core:image
The ceiling is correct and nothing was watching it. A PNG header declares width and height; the RGBA buffer is width * height * 4, so a 69-byte file declaring 30000x30000 asks for 3.6 GB. IMG_MAX_OUT (512 MiB) refuses it — measured, the bomb comes back TooBig.
make corelib decodes one valid image and says nothing about the limit, so the ceiling could have been raised to SIZE_MAX with every lane green. The shim's own comment says the compile-time override exists "so a gate can prove the ceiling fires"; that gate did not exist. make image-ceiling is it.
core:httpd
Response splitting, CWE-113. with_header pushed key and value verbatim and render_head concatenated key + ": " + val + CRLF unvalidated, so an attacker-supplied value ended the header block and delivered its own body. Demonstrated on the wire before fixing.
The shipped server was never vulnerable, and not by accident: server/main.ty runs an is_ctl loop over the percent-decoded path and answers 400. Confirmed against a live tycho-httpd — GET /dir redirects, GET /dir%0d%0aX-Injected:%20yes gets 400. The exposure was to any other consumer without that check, so render_head strips CR and LF now.
core:markdown
Clean. Raw HTML is escaped, and a link URL goes through safe_url, which is an allow-list: the scheme before the first : must be http, https or mailto. Everything else renders as text.
That design is the reason seven evasion vectors — mixed case, a leading space, tab, newline and NUL inside the keyword, vbscript:, data:text/html — are all refused. Replacing it with the naive deny-list (sch != "javascript" and ...) lets five of the seven through, which is now a control in the package test.
A protocol-relative //host/path does link, and that is right: it is an ordinary external link, the same as the http:// beside it, not script execution.
core:regex
POSIX ERE is a DFA and does not backtrack, so the classic catastrophic shapes are harmless — (a+)+$, (a|a)*$ and (a*)*b against a non-matching subject all return instantly, and a 100000-character subject is 0.18 s.
The bomb is bounded repetition multiplying out. (a{1000}){1000} is fifteen bytes and a million NFA states — ~207 MB of RSS. (a{20000}){20000} OOM-killed the machine this audit was running on. regcomp offers no ceiling and neither did the shim, so any program compiling a user-supplied pattern — a search box — could be stopped by fifteen bytes.
rx_compile now refuses on the product of the {n} maxima, capped at 100000. (a{100}){100} is 10000 and still compiles; a pattern with no bounded repetition is untouched. Pinned in the package test with two controls, because a guard that refused every bounded repetition would score the bombs perfectly.
Open: heap corruption under allocation failure
Not root-caused, and recorded rather than guessed at. Under ulimit -v, a Tycho binary calling regex.compile on a large pattern aborts with corrupted size vs. prev_size while consolidating or double free or corruption (!prev) — deterministic, 6 runs of 6.
What was ruled out, each by running it:
- Not the shim's failure path. It frees only the
regex_tit malloc'd and never callsregfreeafter a failedregcomp, which is what POSIX requires. - Not the Tycho runtime. All six allocation sites in
runtime/tycho_rt.ccheck for NULL, and a Tycho program with no regex at the same cap fails cleanly withtycho: out of memory. - Not glibc's
regcompalone. Pure C calling it at the same cap returnsREG_ESPACEcleanly — including a version that mirrors the shim exactly (heapregex_t,freeon failure) and one that first fills the heap with large live blocks to match the arena's shape.
So it needs the real Tycho binary and it is reproducible, but the faulting site is unidentified. ASan cannot see it because ASan replaces glibc's allocator outright; valgrind cannot run inside the ulimit -v that creates the condition. The repetition bound above makes the known trigger unreachable, which is mitigation and not a fix. gap: needs a debug-glibc build or a smaller reproducer to locate.
core:http and core:tls
Both clean.
http_shim.c's body-accumulation callback is the only place that sees bytes arrive — curl's own CURLOPT_MAXFILESIZE acts on a Content-Length it was given, so a chunked or lying response walks past it. The cap is checked overflow-free (add > MAX || r->len > MAX - add, after which MAX - add cannot underflow) and a short return aborts the transfer. It is the one place in corelib that already reasons about ILP32 explicitly.
tls_shim.c refuses max <= 0 and clamps max > INT_MAX before handing an int to SSL_read, with the reasoning written at the site; the port is validated 0..65535 before a bounded snprintf into a 16-byte buffer.
ILP32
Raised as an open question by the zip/net pass and closed here. make ilp32 runs tests/run.sh under -m32, and that runner globs examples/*.ty tests/*.ty without descending, so the corelib shims are never built 32-bit by any lane. Checked by hand instead: net, compress, strings and datetime all compile under gcc -m32, and each flagged size expression was re-read under 32-bit semantics —
compress'scap * 2uis capped at 1 GiB before it can wrap;net's(size_t)maxtruncates identically for themallocand therecv, so the two stay consistent;tlsclamps toINT_MAXexplicitly;strings'n * dplenandcrypto's2 * n + 1are bounded by a real string or key length, and a 2 GB one does not fit a 32-bit address space anyway.
No ILP32 defect found. The gap is that nothing *watches* this — a future shim can introduce one and no lane will notice.
What this audit did not cover
- Writers.
zip.createand the encode side ofimagewere not audited. The threat model is a hostile artifact being READ, not one being produced. - Concurrency. No package was examined for behaviour under simultaneous use from several tasks.
make concand the TSan leg offlow-checkcover the runtime, not these packages specifically. core:tomlandcore:csvas parsers.format-diffcompares csv and json against Python, which is a correctness oracle rather than a hostile-input one. toml has no differential at all.- The compiler on hostile
.tysource. The fuzz lanes cover it; this audit did not add to them. - No lane builds the corelib shims 32-bit. See ILP32 above — the reading was done by hand and is not repeatable by a gate.
What now runs that did not before
corelib/test/zip/main.ty gained a structural-malformation leg: every truncation of a valid archive (442 of them), an EOCD whose central-directory offset points past the end, and a record claiming a 60000-byte name in a 68-byte archive. Each must list nothing and extract nothing, and reaching the final line is the proof that none of them aborted.
The fixture asserts its own shape first, because the first cut of that leg was four bytes short and put the 60000 in usize instead of nlen — it passed while testing nothing. Control observed: deleting list's record-length guard turns the 0-entry answer into 1.