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)
PoC verification
Evidence pack
findings/poc/DF-2323 Β· 30 filesDF-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
sh rig/build.sh DFPCIβ build+install the INVARIANTS rig kernel (thenvm.sh up).- 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 viadfbsd-qemu/vm.sh log 60 > panic_stock.txt). - 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). - Wild variant:
sysctl debug.dfpcih.cmd="vpd wild"β page-fault panic (panic_wild.txt). - Fixed A/B: apply
rig/fix.diffto 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
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) SHRINKSvpd_rosto exactlyoffentries viakrealloc(..., 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 forvpd_w(pci.c:1278). - Error path:
pci.c:1297-1304runs whencksumvalid == 0 || state < -1and walksfor (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 badck2run (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 whatevervpd_roscurrently 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_testableConfirmed 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.
No comments yet.