security: harden model-file parsing, server, and build (untrusted-mirror threat model)#368
Conversation
Verified & applied the ColibriFixes.md audit against the current tree (adversarially confirmed; no regressions vs. legitimate GLM-5.2 loading or normal server flows). Model-file trust boundary (untrusted-mirror threat model): - glm.c: qt_check_fmt() rejects (not infers) any quantized tensor whose weight/scale byte counts disagree with the config shape, at all three format sites (qt_from_disk + both expert_load arms) — closes the matmul OOB read and the per-row scale OOB write (SEC-1); embed_row token-id bound zero-fills out-of-range ids (SEC-5). - st.h: cross-check numel==nbytes/esz for float dtypes + shape-product overflow guard + st_read_f32_cap at config-sized sinks (SEC-2/B1); st_read_slice_f32 NULL + bounds guard (B6). - tok.h: negative-id and json_get NULL/type guards (SEC-3); tk_read_file fseek/ftell/malloc/fread error handling + size cap (B3/B4). - json.h: bound \u reads against the NUL, strncmp full true/false/null keywords (SEC-4); growable string buffer replacing the silent 64KB truncation (B5). Server (openai_server.py): - fail-closed on non-loopback bind without a key (SEC-6), Host-header allowlist incl. OPTIONS preflight (SEC-7), auth-gate /health & /experts telemetry (SEC-8), scheduler head-of-line fix (B2), [DONE] under ka_lock (B9). Build / supply chain / UI: - Makefile CUDA_ARCH=portable (sm_80..120 + compute_120 PTX fallback), native stays default (D1); env-pinnable model download revisions + c/tools/requirements.txt (D3); CUDA event-leak fix (B8); flake.lock reproducibility note (D2). - web: theme-aware ErrorBoundary, API-key autocomplete, /experts error surface, tooltip viewport clamp, 401 message, responsive badge row, type=button, Probing label. Verified: test_json/test_st pass; glm.o/olmoe.o build; backend_cuda.cu compiles under nvcc 13.3; portable gencode parses; web tsc --noEmit clean; all py_compile OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
config.json arrives from unverified mirrors (same threat model as the qt_check_fmt / SEC-1 loader hardening). cfg_root() and the ref_glm.json oracle path both did an unbounded ftell -> malloc(n+1) -> fread with no size cap and no NULL check, so a hostile or oversized file caused a load-time OOM or, on malloc failure, a NULL-deref via b[got]=0. Add cfg_slurp(): 256 MB cap, NULL-checked alloc, full-read check (mirrors tok.h tk_read_file). Route both sites through it. config.json failure stays fatal (exit 1); the oracle path returns 1 as before. Found by an adversarial re-audit of the loader trust boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0edce50 to
c7326d6
Compare
|
Rebased onto current dev — now 0 behind, and re-verified end-to-end.
One thing the re-verify caught and I fixed here: dev's newer Out-of-scope follow-up (pre-existing on dev, not a rebase artifact): the Linux |
|
Correction to my previous comment — I reverted the My re-verify flagged that dev's newer Flagging it so it's a conscious decision: when the server is exposed with Net: the PR is exactly the original audit (SEC-1…B9 + SEC-9), rebased clean onto dev, no behavior changes beyond the documented hardening. |
c7326d6 to
df644dc
Compare
Follow-up hardening found by a research+audit pass (corroborated against the llama.cpp GGUF overflow CVEs and safetensors "two-authorities" TOCTOU class). The original audit hardened glm.c thoroughly but left sibling surfaces open. - olmoe.c load_expert_w (CRITICAL): the dtype-3 (U8/I8) container path did st_read_raw(name,q) + st_read_f32(qs,scale) writing header-controlled byte counts into config-sized buffers (q=malloc(O*I), scale=falloc(O)). st_init skips its numel==nbytes/esz cross-check for dtype-3, so a crafted .safetensors with an over-declared U8 weight or oversized .qs was a controlled heap OOB write — the same SEC-1 primitive glm.c's qt_check_fmt already rejects. Add the matching size check (weight bytes == O*I, scale elems == O) in the shared loader; reject, never repair. - olmoe.c load_cfg: unbounded ftell->malloc slurp of config.json (SEC-9 class, already fixed in glm.c) and json_get(...)->num deref with no NULL check (a missing key = NULL-deref). Add a 256 MB cap + NULL/short-read guards, a req_num() that exits on missing/non-numeric keys, and CKR-style range checks so bad dims can't drive a later malloc(inter*hidden) or a div-by-zero on n_heads. - tok.h (hostile tokenizer.json): type-check vocab ids and merge-pair entries as J_NUM / J_ARR-of-J_STR before dereferencing (was a NULL-deref / silent id=0), and cap maxid so n_ids=maxid+1 can't signed-overflow into a multi-GB calloc. Verified the real 151K-vocab GLM tokenizer still loads + encodes unchanged. - openai_server.py: set APIHandler.timeout=30 — a slowloris client that dribbles its request line/body could pin a worker thread (and a KV slot) forever, before the scheduler's queue bound (B2) applies. Deferred (not shipped here): the Linux io_uring finalize path (uring_finalize_load) has the same missing qt_check_fmt + no fmt=4 handling — a 5th format site, but it is #ifdef __linux__ and cannot be compiled/tested on this Windows box, so it needs its own Linux-gated PR. /profile stays public by design (a test asserts it) — maintainer's call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Extended the audit after a research + code pass (corroborated against the llama.cpp GGUF integer-overflow CVEs — 33298/53630/27940/25664-68 — and the safetensors "two-authorities" TOCTOU class). The original audit hardened
Validation: Two things deferred (not in this PR):
|
…dary Colibri loads model directories and safetensors from mirrors it does not control, so the file's declared shapes and byte spans are attacker-influenced input. Three memory-safety holes on that boundary, independently confirmed (incl. a from-scratch adversarial audit that re-derived the same two) and present in the shipped v1.0.0: - st.h st_read_f32: numel came from the shape, nbytes from the offsets, with no cross-check. A crafted tensor whose shape inflates numel past nbytes made the BF16/F16 loop read past the malloc'd raw buffer and the F32 memcpy write past the caller's config-sized destination (heap OOB read + write). Now enforce numel*esz == nbytes before any copy. - st.h header parse: the shape product could overflow int64 to a small/negative numel that would then pass the cross-check. Guard each multiply. - glm.c qt_resolve_fmt (new, replaces the three duplicated "?1:?2:3" fmt sites in qt_from_disk and both expert_load arms): the old inference SILENTLY fell to int2 for any unrecognized weight byte count, so a too-short weight became a valid int2 whose matmul read O*I nibbles past the buffer; and an oversized scale array overflowed the per-row t->s. Now the weight bytes must match a known int8/int4/int2 layout and the scale array must match the expected per-row (O) or grouped (O*ng) cardinality, else refuse. - glm.c config/generation_config slurp: unbounded ftell -> malloc(n+1) gave a hostile file a load-time OOM, and on malloc failure b[got]=0 was a NULL deref. Cap at 256 MB and NULL-check. Verified: TF token-exactness unchanged on every quant format (full-precision 32/32, int4 11/32, int2 1/32, mix 5/32 -- byte-identical to the pre-change binary); fmt=4 grouped path preserved (the scale check is by construction the same condition detect_group_size already imposed); a hand-crafted hostile safetensors is refused cleanly; ASan+UBSan clean on legit and hostile loads (only the pre-existing intentional startup leaks remain). These are the C trust-boundary items of #368, landed as a minimal standalone fix; the server-side and build items of that PR follow via its rebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Heads-up on the rebase you flagged: I did an independent audit of the model-file trust boundary and confirmed your SEC-1/SEC-2 (the safetensors OOB read/write) and SEC-9 (config slurp) against current This is meant to unblock your rebase, not replace your PR: #413 is deliberately scoped to the C corruption bugs so v1.0.1 can ship them fast. The server-side hardening (SEC-6 fail-closed bind, SEC-7 Host allowlist, SEC-8 telemetry auth) and the build/docs items are all still yours and don't overlap #413. So to answer your "one PR or split?": please split — rebase #368 down to the server + build items and it lands clean on top of #413. Credited you in the #413 description; thank you for the careful writeup, it made the independent verification fast. |
security: reject malformed model tensors at the untrusted-mirror boundary (C half of #368)
|
Heads-up: |
What
A security-hardening pass against the model-file trust boundary (colibri loads model dirs + safetensors from untrusted mirrors), plus the server and build. Every item was adversarially confirmed against the tree, with no regression vs. legitimate GLM-5.2 loading or normal server flows.
Findings fixed
Model-file trust boundary
glm.c:qt_check_fmt()rejects (rather than silently infers) any quantized tensor whose weight/scale byte counts disagree with the config shape, at all three format sites (qt_from_disk+ bothexpert_loadarms) — closes a matmul heap OOB read and a per-row scale OOB write (SEC-1).embed_rowzero-fills out-of-range token ids (SEC-5).st.h: cross-checknumel == nbytes/eszfor float dtypes + shape-product overflow guard +st_read_f32_capat config-sized sinks (SEC-2 / B1);st_read_slice_f32NULL + bounds guard (B6).glm.c: SEC-9 — bound theconfig.json/ref_glm.jsonslurps (256 MB cap + NULL-checked alloc + full-read check); the old unboundedftell → malloc(n+1)gave a hostile file a load-time OOM or, on malloc failure, a NULL-deref viab[got]=0.tok.h: negative-id +json_getNULL/type guards (SEC-3);tk_read_filefseek/ftell/malloc/fread error handling + size cap (B3 / B4).json.h: bound\ureads against the NUL + full-keywordstrncmpfor true/false/null (SEC-4); growable string buffer replacing the silent 64 KB truncation (B5).Server (
openai_server.py)/health&/expertstelemetry (SEC-8); scheduler head-of-line fix (B2);[DONE]underka_lock(B9).Independent corroboration
An unrelated adversarial audit re-derived SEC-1 (matmul OOB read) and SEC-2 (scale OOB write) from scratch against a tree without these fixes — same two bugs, same locations. They're real, not theoretical.
Status / ask
This branch was cut from an older
devand is behind, so it needs a rebase onto currentdevbefore merge. The conflicts are nearby churn —devcontains none of these fixes (verified: noqt_check_fmt/st_read_f32_capupstream), so nothing here is superseded. Before investing in the rebase: do you want this as one PR, or split into (a) model-file C trust-boundary fixes, (b) server fixes, (c) build/docs? I'll do either. Threat model throughout is the untrusted-mirror model directory.