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: |
|
Rebase needed: #298 (CUDA per-group scale fix) and #192 (per-request grammars, touches |
|
This is a v1.0.1 priority and I want it in. Two blockers: 1. It conflicts now ( 2. Tests per hardened path. For each check, a one-line note on the threat it closes + ideally a test that fails-before / passes-after (a malformed-header fixture, an oversized data_offset, a path-traversal attempt in a tensor name). With the rebase green and those tests, this merges — the threat model is real and I want it landed correctly, not fast. Thanks @KingIcyCreamProjects. |
st_init skips its numel*esz==nbytes cross-check for dtype-3 (U8/I8) tensors, so a crafted header from an untrusted mirror could declare nbytes far larger than the config-sized destination: st_read_raw would then write past w_block (heap overflow), and an oversized .qs would overrun s->gs. slot_ensure_allocated sizes those buffers exactly ng+ng+nd bytes and inter+inter+hidden floats, so require an exact match and refuse otherwise — same reject-never-repair contract as qt_resolve_fmt in the GLM engine. Found while reviewing #368: that PR hardened the equivalent path in the old load_expert_w, which the olmoe refactor replaced — so the hole was still open on dev in the new code. Weight/scale validation only; no behaviour change on a well-formed container. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I went through the conflicts hunk by hunk to see exactly what still needs your hands. Good news: your rebase just got much smaller, and one of your findings is already fixed on dev with credit to you. Already covered on
Still unique to this PR, still wanted:
So the ask is now: rebase on current |
… conflicts + fix fmt=5 scale cap Conflict resolution (13 hunks, 6 files): - colibri.c / st.h: dev already carries an equal-or-stronger guard for the same threats (qt_resolve_fmt resolves AND validates the quant layout, refusing unknown byte counts instead of falling through to int2; the shape-product overflow guard is present). Took dev's side — no check is lost. - olmoe.c: the load_expert_w hardening targeted a function the olmoe refactor replaced; dev now validates the equivalent path in load_expert_merged. - web/*: i18n churn only, took dev's strings. This PR's unique value is preserved and is the point of the merge: the server hardening in openai_server.py (+121), plus json.h, tok.h, Makefile and the downloader/requirements pinning. Bug found and fixed while validating: st_read_f32_cap's call site bounded the scale read with the PER-ROW cardinality (O), which is correct for int8/int4/int2 but rejects grouped formats — int3-g64 (fmt=5) keeps O*i3_groups(I) scales, so a legitimate container was refused (tests/test_int3_load failed). The cap now matches the cardinality qt_resolve_fmt already validates and falloc reserved. Verified: clean build, token-exact unchanged (fp 32/32, int4 21/32), tests/test_int3_load ok, full make check OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Merged. I did the rebase for you rather than making you carry it — the conflicts were a mix of "dev already has an equal-or-stronger guard" and i18n churn, so no security check was lost:
What landed is your unique work: One bug I found while validating, worth knowing about: Verified before merge: clean build, token-exact unchanged (fp 32/32, int4 21/32), |
…ution make saw them as up-to-date targets in a fresh checkout (mode 100644, no exec bit) and skipped the build, so every CI run against dev now dies at test_e8_kernel with EACCES. The .gitignore listed test binaries one by one and had fallen behind; replaced with a pattern that ignores the build outputs and keeps the sources.
…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 JustVugg#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>
security: reject malformed model tensors at the untrusted-mirror boundary (C half of JustVugg#368)
st_init skips its numel*esz==nbytes cross-check for dtype-3 (U8/I8) tensors, so a crafted header from an untrusted mirror could declare nbytes far larger than the config-sized destination: st_read_raw would then write past w_block (heap overflow), and an oversized .qs would overrun s->gs. slot_ensure_allocated sizes those buffers exactly ng+ng+nd bytes and inter+inter+hidden floats, so require an exact match and refuse otherwise — same reject-never-repair contract as qt_resolve_fmt in the GLM engine. Found while reviewing JustVugg#368: that PR hardened the equivalent path in the old load_expert_w, which the olmoe refactor replaced — so the hole was still open on dev in the new code. Weight/scale validation only; no behaviour change on a well-formed container. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…usted-mirror threat model) Server-side hardening (openai_server.py +121), plus json.h/tok.h parser hardening, Makefile build flags, and downloader/requirements pinning. Rebased by maintainer onto current dev: the container-parsing hunks were resolved to dev's side, which already carries an equal-or-stronger guard for the same threats (qt_resolve_fmt validates the quant layout and refuses unknown byte counts; shape-product overflow guard; olmoe dtype-3 validation added in 72d6e93 after this PR identified the hole). A real bug in the hardening was found and fixed during validation: the bounded scale read used the per-row cardinality, which rejects grouped formats — int3-g64 (fmt=5) legitimately carries O*i3_groups(I) scales, and tests/test_int3_load failed. The cap now matches the validated cardinality. Verified: clean build, token-exact unchanged, test_int3_load ok, full make check OK. Thanks @KingIcyCreamProjects — the olmoe finding in particular was live on dev.
…ct resolution make saw them as up-to-date targets in a fresh checkout (mode 100644, no exec bit) and skipped the build, so every CI run against dev now dies at test_e8_kernel with EACCES. The .gitignore listed test binaries one by one and had fallen behind; replaced with a pattern that ignores the build outputs and keeps the sources.
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.