Skip to content

security: reject malformed model tensors at the untrusted-mirror boundary (C half of #368)#413

Merged
JustVugg merged 1 commit into
devfrom
p-sec-trustboundary
Jul 19, 2026
Merged

security: reject malformed model tensors at the untrusted-mirror boundary (C half of #368)#413
JustVugg merged 1 commit into
devfrom
p-sec-trustboundary

Conversation

@JustVugg

Copy link
Copy Markdown
Owner

What

Minimal, standalone fix for the C model-file trust boundary β€” the memory-safety half of #368. Colibri loads model dirs + safetensors from mirrors it doesn't control, so the file's declared shapes and byte spans are attacker-influenced input.

Findings fixed (all present in v1.0.0, independently verified against the tree)

  1. Heap OOB read + write in st_read_f32 (st.h) β€” the serious one. numel comes from the tensor's shape, nbytes from its offsets; no cross-check. A crafted tensor with an inflated shape made the BF16/F16 loop read past the malloc'd raw buffer and the F32 memcpy write past the caller's config-sized destination. Now numel*esz == nbytes is enforced before any copy.
  2. Integer overflow in the shape product (st.h) β€” numel *= shape[k] could wrap int64 to a small/negative value that then passes the cross-check. Each multiply is now guarded.
  3. Silent int2 fallback β†’ matmul OOB (glm.c) β€” the ?1:?2:3 fmt inference (duplicated at three sites) 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. Replaced by one qt_resolve_fmt() that validates weight and scale byte counts against [O,I] or refuses.
  4. Unbounded config slurp (glm.c) β€” ftell β†’ malloc(n+1) gave a hostile config.json a load-time OOM, and b[got]=0 was a NULL-deref on malloc failure. Capped at 256 MB + NULL-checked.

Why not just wait for #368

#368 is the right full pass (server + build too) but needs a rebase and a split decision. This lands the memory-corruption items now, as a v1.0.1 candidate, with the smallest possible surface. The server-side items (fail-closed bind, Host allowlist, telemetry auth) still come via #368's rebase β€” no overlap.

Verification

  • No regression: TF token-exactness byte-identical to the pre-change binary on every quant format β€” full-precision 32/32, int4 11/32, int2 1/32, mix 5/32 (the lossy numbers are intrinsic to the tiny random model, not this change).
  • fmt=4 grouped preserved: the scale check is by construction the same condition detect_group_size already imposes.
  • Hostile input refused cleanly: a hand-crafted safetensors with an inflated shape exits with a clear message instead of corrupting the heap.
  • ASan + UBSan clean on both legit and hostile loads (only the pre-existing, documented one-time startup leaks remain).

Refs #368

πŸ€– Generated with Claude Code

…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>
@JustVugg
JustVugg merged commit 3ffe4bb into dev Jul 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant