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

VPD error-cleanup loops use NULL sentinel without guaranteed terminator after krealloc shrink - OOB read and kfree of garbage pointers

Summary

In pci_read_vpd() success-path krealloc at :1225 (vpd_ros) and :1280 (vpd_w) SHRINK arrays to exactly off entries. Error-cleanup loops at :1300 and :1315 iterate using NULL value pointer as sole termination sentinel but shrink removes zeroed padding beyond populated entries. If krealloc allocates new smaller block loop reads past allocation and for each non-NULL garbage value pointer calls kfree corrupting kernel heap. After shrink there are exactly off entries ALL with non-NULL value pointers no trailing sentinel entry with value==NULL. Malicious PCIe device with VPD capability serves one well-formed VPD-R item (triggers shrink) then second item with wrong checksum triggers cksumvalid=0 cleanup. [VERIFIED pass2: reproduced on production-config kernel with trigger CORRECTED - real co-trigger is the state -2 VPD register-read timeout, not bad checksum (which walks a fresh zeroed array, refuted); groom run freed a LIVE adjacent 64-byte chunk re-handed to a second owner - two live owners same memory, silent slab corruption; wild-pointer variant panics in pmap_kvtom; stock INVARIANTS kernels panic earlier at the krealloc M_ZERO KKASSERT; count-bounded cleanup fix-validated]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2323 Β· 30 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 7.4 KB ↓ raw
build.log β€” 819 B view raw
env.txt β€” 2.9 KB view raw
manifest.json β€” 1.6 KB view raw
panic_stock.txt β€” 2.5 KB view raw
panic_wild.txt β€” 2.9 KB view raw
rig/SOURCE_HASHES.txt β€” 244 B view raw
rig/badck2.log β€” 901 B view raw
rig/build.sh β€” 2.1 KB view raw
rig/build_run_DFPCI.out β€” 633 B view raw
rig/cflags_evidence.txt β€” 236 B view raw
rig/check_verbatim.sh β€” 801 B view raw
rig/check_verbatim_fixed.sh β€” 801 B view raw
rig/controls.log β€” 523 B view raw
rig/dfpci_post.c β€” 25.2 KB view raw
rig/dfpci_pre.c β€” 8.8 KB view raw
rig/fix.diff β€” 4.4 KB view raw
rig/fixed_runs.log β€” 6.0 KB view raw
rig/gen.log β€” 204 B view raw
rig/gen.sh β€” 2.6 KB view raw
rig/gen_fixed.sh β€” 2.6 KB view raw
rig/groom.log β€” 1.8 KB view raw
rig/mk_extract.py β€” 4.7 KB view raw
rig/natural_5runs.log β€” 3.3 KB view raw
rig/run_2323_prod.sh β€” 1.5 KB view raw
rig/run_2323_stock.sh β€” 1.0 KB view raw
rig/verbatim.log β€” 239 B view raw
run.log β€” 2.6 KB view raw
verdict.json β€” 4.7 KB view raw

DF-2323 β€” VPD cleanup NULL-sentinel walk after krealloc shrink β€” evidence pack

Finding: sys/bus/pci/pci.c:1225-1315 β€” the VPD error-cleanup loops scan vpd_ros/vpd_w for a NULL .value sentinel, but the success-path krealloc at pci.c:1225/1280 shrinks the arrays to exactly the live entry count, deleting the zero padding the sentinel scan relies on; a later error path walks and kfree()s past the allocation.

Verdict (short)

REPRODUCED (production-config kernel) β€” heap corruption: the verbatim pci_read_vpd walks the shrunk array past its end and kfree()s an attacker-influenced pointer; with a controlled neighbor chunk this is a free-of-LIVE-object (silent on production builds) followed by the same chunk being handed to a second owner (overlapping live kernel objects, demonstrated end-to-end); with a wild pointer it is a kernel page-fault panic. On the STOCK INVARIANTS kernel the walk is never reached: the shrink krealloc(..., M_ZERO) itself panics first (KKASSERT((flags & M_ZERO) == 0), kern_slaballoc.c:1187) β€” also captured. The finding's exact claimed trigger (bad checksum in a second VPD item) is REFUTED (fresh array + leak); the real co-trigger is the state < -1 I/O-error path. Fix A/B validated on both configs.

Rig

Established verbatim-interposition kernel-compiled harness (same class as the pnpparse/firewire/CAM rigs): rig/dfpci_pre.c (rename map + interposition) + sha256-gated VERBATIM extracts of the pci.c functions (rig/gen.sh, blob sha256 in env.txt) + rig/dfpci_post.c (fabricated pcicfg device: 256-byte config space, VPD addr/data register protocol with completion-bit stall point, BAR sizing readback; kmalloc/krealloc/kfree trackers that log and pass through to the REAL allocator; sysctl debug.dfpcih.cmd interface; hard-IRQ systimer watchdog used by the DF-2324 runs). The guest's real PCI stack keeps running untouched (harness TU is fully renamed).

Reproduce

  1. sh rig/build.sh DFPCI β€” build+install the INVARIANTS rig kernel (then vm.sh up).
  2. Baseline stock: sh rig/run_2323_stock.sh (sysctl debug.dfpcih.cmd="vpd errfirst" control stays alive; "vpd stock" panics at the krealloc M_ZERO assert β€” capture via dfbsd-qemu/vm.sh log 60 > panic_stock.txt).
  3. Production config: rebuild with KERNCONF=DFPCIP (gen.sh creates it; INVARIANTS commented out), then sh rig/run_2323_prod.sh (natural x5 + badck2 + controls + groom).
  4. Wild variant: sysctl debug.dfpcih.cmd="vpd wild" β†’ page-fault panic (panic_wild.txt).
  5. Fixed A/B: apply rig/fix.diff to the guest /usr/src (patch -p0), sh rig/gen_fixed.sh (re-extracts from the FIXED file, new sha256 gate), rebuild DFPCIPF and DFPCIF, re-run all scenarios (rig/fixed_runs.log).

Files

  • VERDICT.md β€” full narrative + impact quantification
  • panic_stock.txt β€” stock kernel: krealloc M_ZERO assert at pci.c:1225
  • panic_wild.txt β€” production kernel: page fault in kfree(wild ptr)
  • rig/groom.log β€” the corruption run (UNTRACKED kfree of live canary, kmalloc(64) == canary, TWO LIVE OWNERS SAME MEMORY)
  • rig/natural_5runs.log β€” 5 natural runs + adjacency snapshots
  • rig/badck2.log, rig/controls.log β€” claimed-trigger refutation + controls
  • rig/fixed_runs.log β€” fixed-kernel A/B (0 untracked frees)
  • rig/ β€” harness sources, generators, gates, run scripts, fix.diff
  • env.txt β€” kernels, compiler, CFLAGS, verbatim hashes, reachability
  • manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-2323 β€” VPD error-cleanup loops rely on a NULL sentinel that the success-path shrink krealloc removes β€” VERDICT

status: reproduced (on production-config kernel) / impact: corruption (free-of-live-object + overlapping allocations; wild-pointer variant panics; stock INVARIANTS kernel panics earlier at a krealloc KKASSERT in the same device-driven path).

Line-accurate analysis (verified mechanically)

  • Success path: when a VPD-R item completes, pci.c:1223-1228 (fixed tree line numbers shifted +6) SHRINKS vpd_ros to exactly off entries via krealloc(..., off * sizeof(struct vpd_readonly), M_DEVBUF, M_WAITOK | M_ZERO) β€” 2 keywords => 128 -> 32 bytes. Every kept entry has a non-NULL .value, and the zero padding that used to act as the NULL sentinel is gone (krealloc 128->32 crosses size classes: kern_slaballoc.c krealloc allocates a fresh 32-byte chunk, bcopy(32), frees the 128-byte chunk). Same for vpd_w (pci.c:1278).
  • Error path: pci.c:1297-1304 runs when cksumvalid == 0 || state < -1 and walks for (off = 0; cfg->vpd.vpd_ros[off].value; off++) kfree(...) β€” a NULL-sentinel scan with NO bound (pci.c:1300).
  • Correction to the finding's exact trigger: a second VPD-R item with a bad checksum does NOT reach the shrunk array β€” case 0x10 (pci.c:1117-1124) unconditionally kmallocs a FRESH zeroed 8-entry array (replacing and LEAKING the shrunk one), so the cleanup walk stops at the fresh array's zero sentinel. Proven mechanically by the vpd badck2 run (rig/natural_5runs.log tail + badck2.log): bounded walk, no corruption, 3 leaked blocks (shrunk array + 2 keyword values).
  • The real trigger is the I/O-error path: state < -1 (VPD register-read timeout, pci.c:1073-1076 -> pci_read_vpd_reg ENXIO after 1M completion polls) ALSO enters the same cleanup block, and it hits whatever vpd_ros currently is β€” including a successfully SHRUNK array whose entries are all non-NULL. The malicious device serves one well-formed VPD-R item, then stalls the VPD address register's completion bit.

What was run (all through the verbatim pci_read_vpd)

See README.md for build/run. Runs and logs:

run kernel result
vpd stock (shrink then stall) DFPCI (stock, INVARIANTS) panic assertion "(flags & M_ZERO) == 0" failed in krealloc at kern_slaballoc.c:1187, called from the shrink at pci.c:1225 (log shows krealloc(...,32) flags=0x102 ENTER then panic; trace krealloc <- dfh_krealloc <- dfh_run_vpd). panic_stock.txt
vpd errfirst (error, no prior shrink) DFPCI clean: fresh zeroed array, walk stops at sentinel, guest up (control)
vpd natural x5 (shrink then stall) DFPCIP (production config, no INVARIANTS) shrink runs (krealloc -> new ptr), stall -> state=-2, cleanup walks the shrunk array: 2 valid kfrees then reads entry[1].value OOB. In all 5 runs the adjacent 32-byte-zone memory was ZERO -> walk stopped benign. natural_5runs.log (snapshots logged)
vpd groom (adjacency controlled) DFPCIP corruption: shrink landed on the prepared chunk X; the walk read victim+8 (entry[1].value, i.e. ros+40 β€” 8 bytes past the 32-byte allocation) and kfree()d the LIVE 64-byte canary (logged [*** UNTRACKED -- NOT OURS ***]), stopped at victim+24 == 0, freed the array. Immediately after, kmalloc(64) returned the canary address and the harness wrote 0xAA through the new owner while the original owner still held it: TWO LIVE OWNERS, SAME MEMORY. Guest stayed up (silent heap corruption). groom.log
vpd wild (garbage pointer 0x4141414141414141 at victim+8) DFPCIP kfree(wild) -> pmap_kvtom page fault -> panic: page fault, trace pmap_kvtom <- dfh_run_vpd (the verbatim pci_read_vpd inlined). panic_wild.txt
vpd badck2 (finding's claimed trigger) DFPCIP no corruption; fresh-array walk + 3-block leak (claimed mechanism refuted; real mechanism above)
vpd ok (success control) DFPCIP arrays kept, ros[0] kw=PN val='XY' readable, no cleanup
all of the above scenarios DFPCIPF (production + fix.diff) no OOB: cleanup bounded by vpd_rocnt; groom leaves the canary untouched (kmalloc(64) -> (other); canary bytes now cc...), 0 untracked frees. fixed_runs.log
vpd stock DFPCIF (INVARIANTS + fix.diff) no panic: fix also drops the unsupported M_ZERO from the 4 krealloc calls (krealloc KKASSERTs M_ZERO at kern_slaballoc.c:1187 β€” INVARIANTS builds), so the same scenario that panicked the stock kernel now completes with a bounded cleanup, guest up

Impact quantification on this kernel (team directive)

  • Stock INVARIANTS kernel (as shipped in this dev tree): the claimed OOB-walk corruption is UNREACHABLE β€” every krealloc with M_ZERO (grow at pci.c:1150/1241, shrink at 1225/1280) panics FIRST with the KKASSERT. That is itself a device-triggerable kernel panic (any VPD-R item that completes, i.e. nearly every real VPD), but it is a different failure than claimed.
  • Production-config kernel (INVARIANTS off β€” how release kernels build): the OOB walk is real. Natural runs stopped benignly only because the adjacent heap was zeroed at that moment; the groom run shows deterministic attacker influence over the freed pointer (device-controlled parse allocations and timing shape the 16/32/64 byte zones at enumeration). Consequences observed:
  • kfree of a valid-looking LIVE object: SILENT β€” free lists corrupted, same chunk handed to a new owner while the old owner keeps it (overlapping live objects β€” classic exploitable slab corruption).
  • kfree of a wild pointer: kernel page fault -> panic. Not caught by any zone check on a non-INVARIANTS build (chunk integrity checks are INVARIANTS-only in kern_slaballoc.c).
  • Threat model: malicious PCIe device with a VPD capability (AV:P / crafted passthrough). The lazy pci_read_vpd caller set in-tree is small (vga_pci.c:544, if_age.c), but the parse runs with device data only.

Fix (validated A/B)

fix.diff (pack rig/fix.diff, hunks 4-11 of the combined diff): 1. Track the completed-entry count as entries complete (vpd_rocnt = ++off in state 3, vpd_wcnt in state 6) and reset the counts when a new VPD-R/VPD-W item starts (case 0x10/0x11). 2. Bound both error-cleanup loops by the count (for (off = 0; off < cfg->vpd.vpd_rocnt; off++)), matching the count-bounded convention already used by the device-teardown free path (pci.c:2146-2153) β€” the NULL-sentinel scan is then gone. 3. Drop M_ZERO from the four krealloc calls (krealloc does not support it β€” KKASSERT on INVARIANTS builds; and the sentinel is no longer needed once cleanups are count-bounded; when krealloc reallocates it passes flags through to _kmalloc which handles M_ZERO anyway, but the assert makes the flag unusable as shipped).

A/B: DFPCIPF (production+fix) β€” all scenarios re-run: 0 untracked frees, canary intact in the groom run, success/control semantics unchanged. DFPCIF (INVARIANTS+fix) β€” vpd stock no longer panics at the krealloc assert.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

(No fix.diff for this item: documented recommendation only - see Fix above.)

Fix verification

not_testable
per-fix-DF-2323

Confirmed kernel references

Detail

Exploit chain

malicious PCIe device with VPD capability serves one well-formed VPD-R item (shrink krealloc drops the NULL sentinel) then stalls the VPD ADDR completion bit -> pci_read_vpd state=-2 -> cleanup loop reads vpd_ros[off].value past the shrunk allocation -> attacker-influenced adjacent 32B-zone chunk supplies the pointer -> kfree(live object) -> chunk immediately re-allocatable while original owner retains it -> overlapping live kernel objects (demonstrated: canary vs kmalloc(64) same address, contents cross-visible); wild pointer variant -> kernel page fault panic

Evidence (decisive lines)

panic_stock.txt (stock: krealloc M_ZERO assert at the shrink, trace krealloc<-dfh_run_vpd), rig/groom.log (UNTRACKED kfree of live canary + kmalloc(64)==canary + TWO LIVE OWNERS SAME MEMORY), panic_wild.txt (page fault in pmap_kvtom<-dfh_run_vpd), rig/natural_5runs.log (5 natural runs, adjacency snapshots), rig/badck2.log (claimed trigger refuted: fresh array + 3-block leak), rig/fixed_runs.log (fixed kernel: 0 untracked frees, canary intact), rig/dfpci_post.c dfh_kfree/krealloc trackers

PoC changes

verdict-mode seed replaced by the established verbatim-interposition kernel-compiled harness: extracted pci_read_vpd/vpd_nextbyte/pci_read_vpd_reg byte-for-byte (sha256-gated), fabricated pcicfg device with a VPD register-protocol state machine (completion-bit stall point set by scenario), tracked kmalloc/krealloc/kfree with real-allocator passthrough, adjacency grooming in the 32-byte slab zone; trigger is sysctl debug.dfpcih.cmd; scenario image off-by-one (item length 8 vs 9) fixed after first run

Verified recommended fix

bound both VPD error-cleanup loops by tracked entry counts (vpd_rocnt/vpd_wcnt maintained at every entry completion and reset per item) and drop the unsupported M_ZERO flag from the four krealloc calls

Verdict

Reproduced on a production-config kernel (INVARIANTS off) via the verbatim pci_read_vpd compiled into the DFPCI-family harness kernels: a well-formed VPD-R item completes and shrinks vpd_ros to exactly the entry count with no NULL sentinel, then a VPD register stall drives state=-2 and the cleanup loop at pci.c:1300 walks past the 32-byte allocation, kfree()ing whatever the adjacent heap holds. With controlled adjacency the walk freed a LIVE 64-byte canary (silent on production builds), the freed-live chunk was immediately re-handed by kmalloc(64) to a second owner, and both owners read/wrote the same memory (overlapping live allocations). With a wild pointer planted instead, kfree faulted in pmap_kvtom and panicked. Correction to the claim: a second VPD-R item with a bad checksum does NOT trigger it (case 0x10 allocates a fresh zeroed array, replacing and leaking the shrunk one; proven by the badck2 run) - the actual co-trigger is the state<-2 I/O-error path. On the stock INVARIANTS kernel the claimed corruption is unreachable because krealloc KKASSERTs M_ZERO (kern_slaballoc.c:1187) at the shrink itself - still a device-triggered panic at the same site, captured. fix.diff (count-bounded cleanups + rocnt/wcnt tracking + M_ZERO removal from the 4 kreallocs) A/B validated on both configs: zero out-of-set frees, canary intact, and the stock-config scenario no longer panics.