acpi_video_vo_bind leaves dangling vo->vo_levels when _BCL re-evaluation fails -> UAF read via sysctl and double-free panic in vo_destroy
Summary
vo_bind frees vo_levels unconditionally then calls vo_get_brightness_levels, whose failure paths return 0 WITHOUT writing *levelp - after a REPROBE rebind with failing _BCL, vo_levels dangles with numlevels==0 while all consumers' NULL guards pass. presets_sysctl then executes levels[BCL_FULLPOWER] on freed memory with the stale value disclosed back via sysctl readback (guest: -1920985560), and vo_destroy frees the dangling pointer again -> panic 'memory chunk already free!' VERIFIED on guest with crafted SSDT (two independent boots: UAF read + double-free panic). Fix validated on patched nativekernel: ENODEV write, clean unload, no panic.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4484 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.1 KB | β raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| run_df4484.sh | β | 1.0 KB | view raw | |
| ssdt_dfa.asl | β | 2.9 KB | β download | |
| run.log | β | 2.0 KB | view raw | |
| panic.txt | β | 613 B | view raw | |
| fix.diff | β | 1.7 KB | view raw | |
| fix_run.log | β | 1.3 KB | view raw | |
| fix_build.log | β | 0 B | β download | |
| verdict.json | β | 3.7 KB | view raw |
DF-4484 β acpi_video_vo_bind() leaves a dangling vo->vo_levels pointer when _BCL re-evaluation fails β UAF read (sysctl disclosure) + double-free panic
Impact
Medium. Kernel use-after-free read AND a double-free (kernel panic) in the
ACPI video extension driver, triggered by a 0x81 (REPROBE) firmware notify
followed by a failing _BCL re-evaluation on rebind. Demonstrated on the audit
guest with a crafted SSDT (firmware/loader-level control of ACPI tables), which
is the attacker model for ACPI-table-driven bugs; on real hardware the same
sequence occurs with buggy firmware (dock/hotplug reprobes where _BCL
transiently fails).
Where (sys/dev/acpica/acpi_video/acpi_video.c)
acpi_video_vo_bind():597-600 β freesvo->vo_levels, then assignsvo->vo_numlevels = vo_get_brightness_levels(handle, &vo->vo_levels).vo_get_brightness_levels():966-1009 β on EVERY failure path (AcpiEvaluateObjectfailure,ACPI_PKG_VALIDfailure, alloc failure,n < 2) it returns 0 without writing*levelp(:1005-1008).- β after a failed rebind:
vo->vo_levelspoints at the just-freed allocation,vo->vo_numlevels == 0, and thevo_levels != NULLguards in all consumers pass. - Read primitive:
acpi_video_vo_presets_sysctl():834-837 β writing-1tohw.acpi.video.<out>.fullpowerexecuteslevel = vo->vo_levels[BCL_FULLPOWER]β a read of freed kernel heap that is then stored invo_fullpowerand readable back from userspace. - Double free:
acpi_video_vo_destroy():627-631 frees the danglingvo->vo_levelsagain when the module is detached or the output is destroyed by a later REPROBE.
Reproduction (guest-verified)
SSDT ssdt_dfa.asl (in this directory) defines:
- VGT0 with _ADR 0x00020000 (binds vgapci0 via
acpi_pci_save_handle, sys/dev/acpica/acpi_pci.c:246-268), _DOD
{0x110, 0x120}, _DOS.
- LCD0 (_ADR 0x110): _BCL succeeds only on the 1st evaluation (returns a
6-entry package), fails afterwards (returns a 1-entry package so
ACPI_PKG_VALID(res, 2) fails). _DCS fires Notify(VGT0, 0x81)
(VID_NOTIFY_REPROBE) exactly on its 2nd evaluation β i.e. two sysctl reads
of hw.acpi.video.lcd0.active choreograph the reprobe.
- TVD0 (_ADR 0x120): control output whose _BCL always succeeds.
Run (run_df4484.sh, log in run.log):
QEMU_EXTRA="-acpitable file=ssdt_dfa.aml" vm.sh up # boot guest with table vm.sh run_root 'sh /tmp/run_df4484.sh'
kldload acpi_videoβ attach,lcd0.levels: 100 55 90 75 55 25,fullpower: 100.- read
lcd0.activeΓ2 β 2nd_DCSfiresNotify(VGT0, 0x81)β REPROBE β rebind β_BCL#2 fails (dmesg: "evaluation of ..._BCL makes no sense"). lcd0.levelsnow prints EMPTY (vo_numlevels == 0) whilecrt0.levelsis intact β rebind-failure state reached.sysctl -w hw.acpi.video.lcd0.fullpower=-1β kernel reads freedvo_levels[0]; readback:-1920985560(2nd boot:-1925046824) β heap freelist-pointer garbage, not any legal level.kldunload acpi_videoβvo_destroyfrees the dangling pointer again β kernel panic (seepanic.txt):panic: memory chunk 0xfffff8008d801580 is already free! chunk_mark_free() at chunk_mark_free+0xae _kfree() at _kfree+0x262 acpi_video_vo_destroy() at acpi_video_vo_destroy+0x6a acpi_video_detach() at acpi_video_detach+0x79
Verdict summary
reproduced β both the UAF disclosure (steps 1-4, two independent boots) and
the double-free panic (step 5). Impact ceiling: kernel heap corruption class;
exploitation for code execution would additionally require controlling
M_ACPICA heap layout (the read value is the freed chunk's freelist link).
The trigger itself requires either crafted ACPI tables (boot privilege) or a
misbehaving firmware reprobe, plus root for the sysctl write/unload β hence
Medium, not High.
Fix
fix.diff (validated on the guest β see VERDICT.md): NULL-out
vo->vo_levels (and vo->vo_numlevels) after freeing in vo_bind, and
remove the old notify handler before re-installing (matches FreeBSD's
acpi_video_vo_bind).
VERDICT β DF-4484 (dangling vo->vo_levels β UAF read + double-free panic)
Status: reproduced. Two independent guest boots with SSDT-A
(ssdt_dfa.asl, injected via QEMU_EXTRA="-acpitable file=ssdt_dfa.aml"),
plus a third boot for fix validation (below).
Chain (all on DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 2026, X86_64_GENERIC)
- Table:
VGT0(_ADR 0x00020000) binds to vgapci0 (0:2:0) at boot viaacpi_pci_save_handle(sys/dev/acpica/acpi_pci.c:246-268)._DOD={0x110, 0x120},_DOSpresent βacpi_video_probe()succeeds (acpi_video.c:222-238) afterkldload acpi_video. - Attach: namespace walk (vid_enum_outputs, acpi_video.c:955-956) finds
LCD0/TVD0;acpi_video_bind_outputs_subr(:429-448) creates lcd0/crt0;acpi_video_vo_bind(:594-615) evaluates_BCLβ call #1 returns a valid 6-entry package βvo_levels = {100,55,90,75,55,25},numlevels = 6,fullpower = 100,economy = 55. Observed:hw.acpi.video.lcd0.levels: 100 55 90 75 55 25. - REPROBE:
_DCSfiresNotify(VGT0, 0x81)on its 2nd evaluation (two sysctl reads oflcd0.active). Handleracpi_video_notify_handler(:384-397) NULLs allvo->handle, rebinds.vo_bindfreesvo->vo_levels(:597-598) then callsvo_get_brightness_levelsβ_BCL#2 returns a 1-entry package βACPI_PKG_VALID(res, 2)fails (:984-988) β failure path returns 0 at :1005-1008 without assigning*levelpβvo->vo_levelsdangles,vo_numlevels = 0. Observed:lcd0.levelsprints empty;crt0.levelsintact; dmesg:evaluation of \_SB_.PCI0.VGT0.LCD0._BCL makes no sense. - UAF read:
sysctl -w hw.acpi.video.lcd0.fullpower=-1βacpi_video_vo_presets_sysctl(:808-848):vo_levels != NULLcheck passes on the dangling pointer;level == -1βlevel = vo->vo_levels[0](:834-837) reads the freed 24-byteM_ACPICAchunk;*preset = level. Readback:-1920985560(boot 1),-1925046824(boot 2) β freed-chunk freelist-pointer garbage; not-1(written value), not100(old value), not any legal level (0..100). Values differ across boots (heap layout), stable within a boot (chunk not reused between samples) β consistent with a kfree-written freelist link, i.e. genuinely freed memory. - Double free:
kldunload acpi_videoβacpi_video_detach(:300-304) βacpi_video_vo_destroy(:627-631) frees the danglingvo_levelsagain:panic: memory chunk 0xfffff8008d801580 is already free! chunk_mark_free() at chunk_mark_free+0xae _kfree() at _kfree+0x262 acpi_video_vo_destroy() at acpi_video_vo_destroy+0x6a (acpi_video.c:630) acpi_video_detach() at acpi_video_detach+0x79 device_detach() at device_detach+0x74Guest wedged in DDB (vm.sh statusβ down, serial log captured inpanic.txt).
Exploitability assessment
- The bug class is kernel heap UAF/double-free. Turning the read primitive
into an info leak beyond root requires an unprivileged consumer of the
dangling pointer; the
-1write is root-only (sysctl write) and the notify path only readsvo_levels[i]undervo_numlevels > 0(which is 0 here), so no unprivβroot chain exists through this specific defect on this guest β the demonstrated impact is a root-visible 4-byte freed-heap disclosure plus the double-free panic (kernel DoS / heap corruption on allocators without the already-free check). - Preconditions: firmware
0x81reprobe + failing_BCLon rebind. Crafted ACPI table (boot privilege) demonstrated; real-world equivalent is buggy firmware during dock/hotplug events.
Fix validation
fix.diff (in this directory) NULLs vo->vo_levels/vo->vo_numlevels after
the free in vo_bind, removes the old notify handler before re-install, and
switches the REPROBE marker from vo->handle = NULL to a vo_matched flag
(also covering DF-4486). Built as a kernel+modules via make nativekernel on
the guest and re-tested: see fix_run.log β after the same REPROBE
choreography, lcd0.levels returns ENODEV (driver reports "device has no
levels" instead of an empty list), the fullpower=-1 write returns ENODEV
(readback stays 100), and kldunload acpi_video completes cleanly with NO
panic. Baseline (unpatched) vs patched behavior recorded in
run.log / fix_run.log.
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.
Fix verification
fixedPatched kernel (nativekernel build with fix.diff): same SSDT choreography -> lcd0.levels ENODEV, fullpower=-1 write ENODEV with readback unchanged (100), kldunload acpi_video returns 0 with no panic; baseline double-free panic gone.
fix.diff; fix_run.log (patched choreography, clean unload); fix_build.log (kernel build tail)
Confirmed kernel references
Detail
Exploit chain
crafted SSDT (boot privilege) defines vgapci _DOD/_DOS + output devices; 2nd sysctl read of lcd0.active fires Notify(VGT0,0x81); reprobe rebinds, _BCL#2 fails, vo_levels freed-but-not-NULLed; root writes -1 to fullpower -> kernel reads freed chunk -> value stored and read back (UAF disclosure); kldunload -> second free of same chunk -> kernel panic (double free).
Evidence (decisive lines)
run.log steps 1-4 (levels 100 55 90 75 55 25 -> empty; fullpower 100 -> -1920985560), panic.txt (panic trace through acpi_video_vo_destroy+0x6a), dmesg 'evaluation of ..._BCL makes no sense', ssdt_dfa.asl choreography
PoC changes
Wrote the SSDT trigger from scratch (seed had none): VGT0/_ADR 0x00020000 to bind vgapci0, _DOD/_DOS for probe, stateful _BCL (valid once, 1-entry package afterwards), _DCS firing Notify(VGT0,0x81) on 2nd eval; iasl quirks fixed (External(_SB.PCI0), literal 2, package-typed failure return).
Verified recommended fix
NULL out vo->vo_levels (and vo->vo_numlevels) after AcpiOsFree in acpi_video_vo_bind; track reprobe matching with a flag instead of NULLing vo->handle
Verdict
Reproduced on the DragonFly guest with a crafted SSDT: a firmware 0x81 REPROBE notify followed by a failing _BCL re-evaluation leaves vo->vo_levels dangling (vo_get_brightness_levels returns 0 without writing *levelp after vo_bind freed the old array, acpi_video.c:597-600 + 984-1008). sysctl -w hw.acpi.video.lcd0.fullpower=-1 then reads vo_levels[0] out of freed kernel heap (acpi_video.c:834-837) and discloses it back to userspace (-1920985560 / -1925046824 across boots, not any legal level); kldunload acpi_video double-frees the dangling pointer in acpi_video_vo_destroy (acpi_video.c:630) causing 'panic: memory chunk 0xfffff8008d801580 is already free!' with a trace through acpi_video_vo_destroy. No unpriv->root chain exists (sysctl write is root-only, notify is firmware-only), so impact ceiling is kernel heap corruption/DoS + root-visible 4-byte freed-heap disclosure.
No comments yet.