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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4484 Β· 10 files
FileTypeDescriptionSize
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 β€” frees vo->vo_levels, then assigns vo->vo_numlevels = vo_get_brightness_levels(handle, &vo->vo_levels).
  • vo_get_brightness_levels() :966-1009 β€” on EVERY failure path (AcpiEvaluateObject failure, ACPI_PKG_VALID failure, alloc failure, n < 2) it returns 0 without writing *levelp (:1005-1008).
  • β‡’ after a failed rebind: vo->vo_levels points at the just-freed allocation, vo->vo_numlevels == 0, and the vo_levels != NULL guards in all consumers pass.
  • Read primitive: acpi_video_vo_presets_sysctl() :834-837 β€” writing -1 to hw.acpi.video.<out>.fullpower executes level = vo->vo_levels[BCL_FULLPOWER] β€” a read of freed kernel heap that is then stored in vo_fullpower and readable back from userspace.
  • Double free: acpi_video_vo_destroy() :627-631 frees the dangling vo->vo_levels again 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'
  1. kldload acpi_video β†’ attach, lcd0.levels: 100 55 90 75 55 25, fullpower: 100.
  2. read lcd0.active Γ—2 β†’ 2nd _DCS fires Notify(VGT0, 0x81) β†’ REPROBE β†’ rebind β†’ _BCL #2 fails (dmesg: "evaluation of ..._BCL makes no sense").
  3. lcd0.levels now prints EMPTY (vo_numlevels == 0) while crt0.levels is intact β€” rebind-failure state reached.
  4. sysctl -w hw.acpi.video.lcd0.fullpower=-1 β†’ kernel reads freed vo_levels[0]; readback: -1920985560 (2nd boot: -1925046824) β€” heap freelist-pointer garbage, not any legal level.
  5. kldunload acpi_video β†’ vo_destroy frees the dangling pointer again β†’ kernel panic (see panic.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.md
↓ download raw

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)

  1. Table: VGT0 (_ADR 0x00020000) binds to vgapci0 (0:2:0) at boot via acpi_pci_save_handle (sys/dev/acpica/acpi_pci.c:246-268). _DOD = {0x110, 0x120}, _DOS present β‡’ acpi_video_probe() succeeds (acpi_video.c:222-238) after kldload acpi_video.
  2. 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.
  3. REPROBE: _DCS fires Notify(VGT0, 0x81) on its 2nd evaluation (two sysctl reads of lcd0.active). Handler acpi_video_notify_handler (:384-397) NULLs all vo->handle, rebinds. vo_bind frees vo->vo_levels (:597-598) then calls vo_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_levels dangles, vo_numlevels = 0. Observed: lcd0.levels prints empty; crt0.levels intact; dmesg: evaluation of \_SB_.PCI0.VGT0.LCD0._BCL makes no sense.
  4. UAF read: sysctl -w hw.acpi.video.lcd0.fullpower=-1 β†’ acpi_video_vo_presets_sysctl (:808-848): vo_levels != NULL check passes on the dangling pointer; level == -1 β‡’ level = vo->vo_levels[0] (:834-837) reads the freed 24-byte M_ACPICA chunk; *preset = level. Readback: -1920985560 (boot 1), -1925046824 (boot 2) β€” freed-chunk freelist-pointer garbage; not -1 (written value), not 100 (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.
  5. Double free: kldunload acpi_video β†’ acpi_video_detach (:300-304) β†’ acpi_video_vo_destroy (:627-631) frees the dangling vo_levels again: 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+0x74 Guest wedged in DDB (vm.sh status β†’ down, serial log captured in panic.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 -1 write is root-only (sysctl write) and the notify path only reads vo_levels[i] under vo_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 0x81 reprobe + failing _BCL on 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.