Out-of-domain free discipline: production kernels silently accept a full-child free beyond bl_blocks (unbounded full-cover branch) and poison parent meta bits to ALL-FREE; the likewise-unbounded direct-allocation path then returns block numbers >= bl_blocks (wild PHYS_TO_VM_PAGE precursor); bl_root allocated without M_ZERO
| Field | Value |
|---|---|
| ID | DF-2798 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-20 / CWE-755 |
| File | sys/kern/subr_alist.c |
| Lines | 286 (INVARIANTS-only), 745-753 vs 783-786, 552-568, 166 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-31 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
alist_free's only range bound is the INVARIANTS-only KKASSERT. On production kernels an out-of-range free of a FULL child is accepted silently: the full-cover branch has no bl_blocks check (the unconditional "beyond limit" panic sits in the partial branch only β a partial OOB free panics loudly while a full one is silent, asymmetry demonstrated live), setting the parent's 2-bit pair for an out-of-domain child to 11 and over-counting bl_free. The direct-allocation path likewise lacks a bl_blocks check, so once in-domain space is exhausted it matches the poisoned pair and returns blk β₯ bl_blocks β demonstrated: alist_alloc(bl,0,32) returned 128 with bl_blocks=100. For the sole in-kernel consumer the returned block feeds PHYS_TO_VM_PAGE β a vm_page_t beyond vm_page_array, a wild kernel pointer for the DMA caller. Compounding: bl_root is kmalloc'd without M_ZERO while radix_init over-allocates slot space to radix-16 granularity (5.1M-op poison-persistence fuzz proved no legal sequence writes those slots, but they remain heap garbage one off-by-one away from being parsed as a node). In-tree callers never slip β caller-bug amplification, Low.
Proof of contest
VERIFIED (findings/poc/DF-2798/): KLD on stock INVARIANTS β panic at :286; -DNDEBUG standalone walks the full chain (bl_free=132 with capacity 100, no panic, bits set, then out-of-domain block 128 handed out) plus the partial-free contrast that panics at :784. Fix (unconditional panic on out-of-range free, bound the direct-alloc path, M_ZERO the node array) validated on rebuilt kernel: panics loudly BEFORE any meta bit is touched. No uid0 route β requires a misbehaving kernel caller to seed.
Recommended fix
See findings/poc/DF-2798/fix.diff (validated).
Timeline
- 2026-08-31 Discovered during pass-2 audit of subr_alist.c (GLM 5.3); both manifests reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2798 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 5.0 KB | β raw | |
| trigger_oobfree.c | β | 3.9 KB | view raw | |
| kld_aoobfree/aoobfree.c | β | 1.4 KB | view raw | |
| kld_aoobfree/Makefile | β | 88 B | β download | |
| build.sh | β | 257 B | view raw | |
| run.sh | β | 130 B | view raw | |
| build.log | β | 145 B | view raw | |
| run.log | β | 1.0 KB | view raw | |
| panic.txt | β | 763 B | view raw | |
| env.txt | β | 584 B | view raw | |
| fix.diff | β | 1.1 KB | view raw | |
| fix_validation.log | β | 1.8 KB | view raw |
DF-2798 β out-of-domain free discipline: production kernels silently poison meta bits and hand out blocks beyond bl_blocks
What this pack contains
| artifact | what it is |
|---|---|
trigger_oobfree.c |
standalone trigger (-DNDEBUG, production emulation) of the full silent-corruption chain |
kld_aoobfree/ |
KLD calling alist_free(own_alist, 128, 32) with bl_blocks=100 in kernel mode |
panic.txt |
serial-console capture of the stock-kernel (INVARIANTS) panic at subr_alist.c:286 |
run.log |
full standalone trigger output |
build.sh, run.sh |
exact commands |
fix.diff |
git-apply-able fix (validated: see VERDICT.md) |
Build
cc -O2 -g -DNDEBUG -I <srcroot>/sys -o trigger_oobfree trigger_oobfree.c
Run
./trigger_oobfree # kernel-mode (root): cd kld_aoobfree && make && kldload ./aoobfree.ko
Expected output (baseline, unfixed)
after alist_free(bl,128,32): bl_free=132 (capacity=100!) root bitmap=0000037f <-- NO PANIC, bits 8..9 set (child4=11) 3x alist_alloc(bl,0,32) -> 64 (in-domain exhausted) alist_alloc(bl,0,32) returned 128 <-- bl_blocks=100: OUT-OF-DOMAIN block number handed out
plus the contrast case: a partial out-of-domain free panics loudly at
alst_meta_free:784 (attempt to free block beyond limit), demonstrating the
asymmetry β the full-cover branch (:745-753) has no bl_blocks check at
all, and the top-level bound (alist_free:286) is an INVARIANTS-only
KKASSERT. On a production kernel the caller's range slip is accepted
silently, the parent's meta bits for the out-of-domain child are set to
ALL-FREE (11), bl_free over-counts (132 > capacity 100), and
alst_meta_alloc's direct path (:552-568, also unbounded) later matches
those poisoned bits and returns block numbers β₯ bl_blocks.
For the sole in-kernel consumer (vm_contig_alist, vm_page.c:435) the
returned block feeds PHYS_TO_VM_PAGE(blk << PAGE_SHIFT) (vm_page.c:2835)
β a vm_page_t beyond vm_page_array, i.e. a wild kernel pointer handed to
whichever driver asked for contiguous DMA memory. (In-tree callers are
bug-free β this is caller-bug amplification, not a directly reachable bug;
see threat model in the finding.)
alist_create compounds the exposure: bl_root is allocated without
M_ZERO (:166) while alst_radix_init deliberately over-allocates slot
space to radix-16 granularity, leaving uninitialized slots inside
bl_rootblks that only the blk >= bl->bl_blocks guards (:581, :754)
keep untouched. The randomized differential fuzz (4.4M legal ops, see
DF-2797 pack) verified those guards hold under all legal sequences
(poison-persistence check H7) β but any future off-by-one walks raw heap
memory. The M_ZERO fix removes that ammunition.
Fixed behavior (see VERDICT.md)
alist_free bounds-check panics unconditionally (loud, present on
production kernels, replacing the silent path); the direct-allocation path
additionally refuses spans beyond bl_blocks (defense in depth); bl_root
is zeroed.
DF-2798 β VERDICT
Status: reproduced (kernel panic on stock INVARIANTS guest + the full
silent-corruption chain demonstrated standalone under production-kernel
semantics). Impact: panic on the audit guest (INVARIANTS); on production
(non-INVARIANTS) kernels the demonstrated chain is silent meta-bit
poisoning β out-of-domain block numbers handed to the caller β for the
vm_contig consumer that is a PHYS_TO_VM_PAGE beyond vm_page_array, i.e. a
wild vm_page_t for a driver. No unprivileged reachability in-tree (caller
bug amplification); no leak; class = DF-2789 (removed-terminator discipline).
How reproduced
1. Kernel mode (stock INVARIANTS guest, fresh vm.sh reset with-src)
KLD kld_aoobfree/aoobfree.ko creates its own alist(100), frees it, then
alist_free(bl, 128, 32) with bl_blocks=100:
aoobfree: creating own alist(100) aoobfree: calling alist_free(bl,128,32) with bl_blocks=100 -- expect KKASSERT panic at :286 panic: assertion "blkno + count <= bl->bl_blocks" failed in alist_free at /usr/src/sys/kern/subr_alist.c:286 Trace: alist_free+0xba <- aoobfree_modevent+0x8e <- module_register_init ...
Full capture: panic.txt. Proves the ONLY bound between a caller range slip
and the meta-bit poisoning is an INVARIANTS-only KKASSERT.
2. Production-kernel chain (standalone, -DNDEBUG, identical code)
run.log (decisive lines):
after alist_free(bl,128,32): bl_free=132 (capacity=100!) root bitmap=0000037f <-- NO PANIC, bits 8..9 set (child4=11) 3x alist_alloc(bl,0,32) -> 64 (in-domain exhausted) alist_alloc(bl,0,32) returned 128 <-- bl_blocks=100: OUT-OF-DOMAIN block number handed out uninitialized slots 5..16 written: 0 (corruption is logical, in parent meta bits)
Root-cause trace (all sys/kern/subr_alist.c):
alist_free:286βKKASSERT(blkno + count <= bl->bl_blocks)compiled out on production kernels βalst_meta_freeentered with an out-of-range request.alst_meta_free:745-753β the full-cover branch has nobl_blockscheck (} else if (freeBlk < bl->bl_blocks) {guards only the partial branch; the unconditional "beyond limit" panic at:784sits in that else chain and is unreachable for full-cover children). A free of a whole child beyond the limit silently sets the parent's 2-bit pair to ALL-FREE (11) andbl_free += countβ 132 > capacity 100.alst_meta_alloc:552-568β the direct-allocation path likewise has nobl_blockscheck; once in-domain space is exhausted it matches the poisoned11pair and returns block 128 β₯bl_blocks(the terminator guards that used to catch this class were#if 0-removed at:584-592,:727-733,:861-876, replaced only by theblk >= bl->bl_blocksguards at:581/:754β which these two paths bypass).- Contrast: a partial out-of-domain free panics loudly at
:784(alst_meta_free: attempt to free block 120 beyond limit of 100, child exit=1 in run.log) β the asymmetry (partial=loud, full=silent) is the bug. - Compounding:
alist_create:166allocatesbl_rootwithoutM_ZEROwhilealst_radix_init:839-881over-allocates slot space to radix-16 granularity (memindex += ALIST_META_RADIX per meta node regardless of how many children are in-domain) β e.g. blocks=100 βbl_rootblks=17but only 5 slots ever initialized. The 4.4M-op legal-sequence differential fuzz (DF-2797 pack) proved via poison-persistence (H7) that no legal sequence ever writes those slots β but they are live heap garbage one off-by-one away from being interpreted as a node.M_ZEROremoves that ammunition.
In-kernel sink for the returned out-of-domain block:
vm_page.c:2835 m = PHYS_TO_VM_PAGE((vm_paddr_t)blk << PAGE_SHIFT) β with
blk β₯ 65536 for vm_contig_alist, m indexes past vm_page_array β wild
kernel pointer returned to the DMA caller. In-tree callers never slip
(vm_page.c:2868 bounds by vm_low_phys_reserved; boot frees bounded by the
same value) β hence caller-bug amplification, Low severity.
Exploit chain
None directly (requires a kernel-internal caller bug to seed). Chain when
seeded: range slip β silent 11-pair poisoning (:752) β direct-path
allocation returns blk β₯ bl_blocks (:561) β PHYS_TO_VM_PAGE OOB β driver
writes through a fabricated vm_page_t β arbitrary kernel memory
corruption. Every step demonstrated except the final driver write (needs a
misbehaving in-tree caller, which does not exist).
Fix validation
fix.diff applied to the guest's /usr/src, kernel rebuilt, A/B rerun β see
fix_validation.log: the standalone production-emulation now panics loudly
at the new unconditional alist_free bound (alist_free: free 128+32 beyond
limit of 100) before any meta bit is touched, on both INVARIANTS and
production builds; the direct path additionally refuses out-of-domain spans
(defense in depth); the KLD panic message changes from the KKASSERT text to
the explicit alist_free panic (intentional, production-safe). The silent
path is gone.
Fix verification
fixedfix.diff applied to guest /usr/src, kernel rebuilt and installed; on the fixed kernel alist_free panics unconditionally and explicitly ('alist_free: free 128+32 beyond limit of 100') BEFORE any meta bit is touched - the panic is the intended fail-loud fix behavior (matches the file's existing unconditional panics); the standalone production-emulation now panics at the same bound instead of silently poisoning and handing out block 128. Silent corruption path eliminated.
fix_run.log (kernel + standalone A/B), fix_build.log (build/install/new uname), fix_validation.log
Confirmed kernel references
Detail
Exploit chain
seeded-by-caller-bug chain (demonstrated end-to-end except the final driver write): kernel-internal free with blkno+count > bl_blocks on a production kernel -> silent 11-pair poisoning (alst_meta_free:752) -> alist_alloc direct path returns blk >= bl_blocks (:561) -> PHYS_TO_VM_PAGE OOB (vm_page.c:2835) -> driver operates on a fabricated vm_page_t -> arbitrary kernel memory corruption. No unprivileged/unauthenticated seeding path exists in-tree.
Evidence (decisive lines)
panic.txt (stock-kernel panic at :286 with KLD trace); run.log lines 'after alist_free(bl,128,32): bl_free=132 (capacity=100!) ... NO PANIC, bits 8..9 set' and 'alist_alloc(bl,0,32) returned 128 <-- bl_blocks=100: OUT-OF-DOMAIN block number handed out' plus the partial-free contrast panic ':784'; fix_run.log (fixed kernel: unconditional explicit panic before corruption)
PoC changes
Authored fresh (no seed): standalone -DNDEBUG trigger walks the exact chain with a poisoned-records alist_init (0xAA) to simultaneously prove no raw OOB write occurs (corruption is logical, in parent meta bits); KLD variant for the INVARIANTS guest using a private alist_create(100).
Verified recommended fix
alist_free: unconditional bounds panic; alst_meta_alloc direct path: refuse spans beyond bl_blocks; alist_create: M_ZERO on bl_root (see fix.diff)
Verdict
Reproduced on the stock INVARIANTS guest via a KLD calling alist_free(own_alist, 128, 32) with bl_blocks=100: panic 'assertion "blkno + count <= bl->bl_blocks" failed in alist_free at subr_alist.c:286' (panic.txt) - proving the only bound is an INVARIANTS-only KKASSERT. The full silent-corruption chain was then demonstrated with the identical source compiled -DNDEBUG (production semantics): the out-of-domain free is accepted with NO panic (the full-cover branch alst_meta_free:745-753 has no bl_blocks check; the 'beyond limit' panic at :784 sits in the partial branch only - a partial OOB free does panic, an asymmetry shown live), the parent's 2-bit pair for the out-of-domain child is set to ALL-FREE, bl_free over-counts (132 > capacity 100), and once in-domain space is exhausted alst_meta_alloc's direct path (:552-568, likewise unbounded; terminator guards were #if 0-removed at :584-592/:727-733/:861-876) matches the poisoned pair and RETURNS BLOCK 128 >= bl_blocks (run.log). For the sole in-kernel consumer (vm_contig_alist) the returned block feeds PHYS_TO_VM_PAGE(blk << PAGE_SHIFT) (vm_page.c:2835) - a vm_page_t beyond vm_page_array, i.e. a wild kernel pointer for the DMA caller; chain ends there because no in-tree caller actually slips (vm_page.c:2868/287 bound frees), hence caller-bug amplification, Low. Compounding hardening gap: alist_create:166 allocates bl_root WITHOUT M_ZERO while alst_radix_init over-allocates slot space to radix-16 granularity (blocks=100 -> bl_rootblks=17, only 5 slots initialized); the 5.1M-op legal-sequence fuzz proved via poison-persistence that no legal op ever writes those slots, but they remain live heap garbage one off-by-one away from being parsed as a node. Fix validated on a rebuilt guest kernel (#1 Sep 1 12:02:52): alist_free now panics unconditionally ('alist_free: free 128+32 beyond limit of 100') at the top, before any meta bit is touched, on both INVARIANTS and production builds; the direct path additionally refuses spans beyond bl_blocks; M_ZERO added. The silent path is gone.
No comments yet.