β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2798

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2798 Β· 13 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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):

  1. alist_free:286 β€” KKASSERT(blkno + count <= bl->bl_blocks) compiled out on production kernels β†’ alst_meta_free entered with an out-of-range request.
  2. alst_meta_free:745-753 β€” the full-cover branch has no bl_blocks check (} else if (freeBlk < bl->bl_blocks) { guards only the partial branch; the unconditional "beyond limit" panic at :784 sits 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) and bl_free += count β†’ 132 > capacity 100.
  3. alst_meta_alloc:552-568 β€” the direct-allocation path likewise has no bl_blocks check; once in-domain space is exhausted it matches the poisoned 11 pair 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 the blk >= bl->bl_blocks guards at :581/:754 β€” which these two paths bypass).
  4. 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.
  5. Compounding: alist_create:166 allocates bl_root without M_ZERO while alst_radix_init:839-881 over-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=17 but 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_ZERO removes 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 12:02:52 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.