TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    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 pointverdict
    a PNG — core:imageceiling correct, untested; make image-ceiling added
    an HTTP request — core:httpdresponse splitting (CWE-113), fixed
    an archive — core:zipno memory-safety defect; API conflates four outcomes
    a socket — core:netno memory-safety defect; one comment corrected
    markdown — core:markdownclean; evasion vectors added to the test
    an HTTP response body — core:httpclean, and already ILP32-aware
    a TLS stream — core:tlsclean
    a decompressed stream — core:compressceiling correct and already gated
    a regex pattern — core:regexmemory 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:

    constructout of rangeresult
    b[i]indexaborts — tycho: string index 99 out of bounds (len 4)
    b[a:c]sliceclamps — 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.

    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:

    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:

    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 —

    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

    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.