AcpiOsAcquireLock implements one ACPICA lock as two disjoint primitives (idle thread β spinlock, everyone else β lockmgr+crit): no mutual exclusion between the two holder classes across CPUs
Summary
The acquire/release pick the primitive by caller identity - the idle thread takes only the spinlock, everyone else only lockmgr+crit, so an idle holder on CPU A and a normal holder on CPU B can be inside the SAME ACPICA critical section simultaneously. Live via acpi_cst_idle's AcpiWriteBitRegister on the C3 BM/ARB paths (idle side) racing CST re-evaluation/RLD writes/S3 resume status clears (normal side) - interleaved RMWs lose updates (missed BM_STS clears, C3 with arbitration enabled, mangled resume clears). Platform-state corruption/DoS only (no kernel memory written by either side). QEMU guest lacks C2/C3 so preconditions absent (probe in pack). Serialize-both-classes-on-the-slock fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4890 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.8 KB | β raw | |
| VERDICT.md | β | 7.0 KB | β raw | |
| precond_probe.sh | β | 1.3 KB | view raw | |
| race_trigger.sh | β | 1.0 KB | view raw | |
| run.log | β | 1.0 KB | view raw | |
| env.txt | β | 1.0 KB | view raw | |
| fix.diff | β | 1.8 KB | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-4890 β AcpiOsAcquireLock: idle-thread and normal-thread callers use
disjoint locking primitives (spinlock vs lockmgr) β no mutual exclusion
between them
Files audited in this batch
sys/dev/acpica/Osd/OsdSynch.c(this finding)sys/dev/drm/amd/amdgpu/psp_v10_0.c(clean, no finding)
What the bug is (one paragraph)
AcpiOsAcquireLock() (sys/dev/acpica/Osd/OsdSynch.c:374-403) picks the
underlying primitive based on WHO the caller is:
- idle thread (
gd_curthread == &gd_idlethread):spin_lock(&Spin->slock)(OsdSynch.c:383-384) - any normal thread:
lockmgr(&Spin->lock, LK_EXCLUSIVE)+crit_enter()(OsdSynch.c:385-387) βslockis never touched
The two primitives have nothing to do with each other: a holder on the
idle-thread path and a holder on the normal-thread path can be inside the
"same" ACPICA critical section simultaneously on different CPUs. ACPICA's
contract for AcpiOsAcquireLock is a single exclusive lock per handle
(ACPI OSPM/OVL layer). The result is a data race on whatever the lock
protects.
What the lock protects
In this ACPICA version the OSL spinlocks created by AcpiUtMutexInitialize
(sys/contrib/dev/acpica/source/components/utilities/utmutex.c:206-218) are
AcpiGbl_GpeLock, AcpiGbl_HardwareLock, AcpiGbl_ReferenceCountLock.
Only AcpiGbl_HardwareLock is reachable from the idle thread:
cpu_idle_hook = acpi_cst_idle (acpi_cpu_cstate.c:786)
acpi_cst_idle: C3 with BM_STS flag ->
AcpiReadBitRegister(BUS_MASTER_STATUS) (acpi_cpu_cstate.c:933)
AcpiWriteBitRegister(BUS_MASTER_STATUS,1) (acpi_cpu_cstate.c:935)
C3 preamble ARB ->
AcpiWriteBitRegister(ARB_DISABLE, 1|0) (acpi_cpu_cstate.c:958,977)
-> AcpiWriteBitRegister takes AcpiGbl_HardwareLock around a
read-modify-write of PM1_STS / PM2_CNT
(hwxface.c:411 `AcpiOsAcquireLock(AcpiGbl_HardwareLock)` .. :471)
Normal-thread takers of the same lock: AcpiWriteBitRegister() from
non-idle contexts β e.g. acpi_cpu_cstate.c:668 and :810
(BUS_MASTER_RLD writes in CST probe/attach/notify paths), and ACPICA's
AcpiHwClearAcuiStatus() (hwregs.c:574) on sleep-entry/exit
(AcpiEnableSubsystem, resume path).
Both sides do masked read-modify-write of PM1/PM2 registers under the "lock"; interleaving loses updates (lost BM_STS clear, lost ARB_DISABLE set/clear, lost BM_RLD write, mangled PM1 status clears during resume).
How to reproduce (real hardware)
Preconditions: multi-CPU system, ACPI _CST advertising C3 (or P_BLK C3)
with BM/ARB preamble, and a concurrent normal-thread bit-register write
(CST re-evaluation via hw.acpi.cpu.cx_lowest/notify, or S3 resume's
AcpiHwClearAcuiStatus).
sysctl hw.ncpu>= 2,sysctl hw.acpi.cpu0.cx_supportedshows C2/C3.sysctl hw.acpi.cpu.cx_lowest=C3so idle threads take the C3 path.- Generate normal-thread
AcpiWriteBitRegistertraffic while CPUs idle (race_trigger.shhammers CST notify/RLD paths). - Observe with an instrumented kernel (kprintf in both branches keyed on
Spin == AcpiGbl_HardwareLock) β on stock kernels the lost update is silent; the visible symptoms are missed wake events / wrong sleep transitions / C3 entered with arbitration disabled.
On this QEMU guest
precond_probe.sh output is in env.txt: 6 CPUs, cpu_cst0..5 attached,
but cx_supported: C1/0 only β QEMU's DSDT/FADT exposes no C2/C3, so the
idle-thread side of the race (C3 BM/ARB register writes) never executes in
this guest. The race therefore cannot be hosted here; see VERDICT.md.
Fix
fix.diff: make the normal-thread path take the same spinlock for the
critical section (lockmgr stays as the sleepable contention lock; the
HardwareLock sections are short, non-blocking register RMWs β verified at
hwxface.c:411-471 and hwregs.c:574-581).
DF-4890 β VERDICT
Finding: AcpiOsAcquireLock/AcpiOsReleaseLock
(sys/dev/acpica/Osd/OsdSynch.c:374-429) implement one ACPICA lock with two
disjoint primitives chosen by caller class β spin_lock(&Spin->slock) for
the idle thread (OsdSynch.c:383-384, :423-424) and lockmgr(&Spin->lock,
LK_EXCLUSIVE)+crit_enter() for every other thread (OsdSynch.c:385-387,
:425-428). The primitives are unrelated: an idle-thread holder on CPU A and a
normal-thread holder on CPU B are both "inside" the lock at the same time.
ACPICA requires AcpiOsAcquireLock to be a single exclusive lock
(see the file's own comment, OsdSynch.c:367-373: "OS-dependent locking
primitives... called from an interrupt-handler or cpu_idle thread").
Status: not_reproduced (on this guest) / statically certain (code-provable).
Static proof chain (every hop verified in-tree)
- Idle-thread entry into ACPICA under the OSL lock:
-
cpu_idle_hook = acpi_cst_idleβ sys/dev/acpica/acpi_cpu_cstate.c:786 (runs ingd_idlethreadcontext by definition of the idle hook). - C3-with-bus-master path:AcpiReadBitRegister(ACPI_BITREG_BUS_MASTER_STATUS)(acpi_cpu_cstate.c:933) andAcpiWriteBitRegister(...,1)(:935); ARB preamble:AcpiWriteBitRegister(ACPI_BITREG_ARB_DISABLE,1|0)(:958, :977). -AcpiWriteBitRegisterwraps a read-modify-write of PM1_STS/PM2_CNT inAcpiOsAcquireLock(AcpiGbl_HardwareLock)..AcpiOsReleaseLockβ sys/contrib/dev/acpica/source/components/hardware/hwxface.c:411-471. - In the idle thread that acquire takes thespin_lock(&Spin->slock)branch only (OsdSynch.c:383-384). - Normal-thread takers of the same lock instance:
-
AcpiWriteBitRegister(BUS_MASTER_RLD)in CST probe/attach/notify paths β acpi_cpu_cstate.c:668, :810, :1082-1086 (normal kernel threads). -AcpiHwClearAcuiStatus()β sys/contrib/dev/acpica/source/components/hardware/hwregs.c:574-581, taken on ACPI subsystem enable and sleep-state transitions (S3 enter/exit). - Those acquires take thelockmgr(&Spin->lock)branch only (OsdSynch.c:385-387) βslockis never taken by normal threads, andSpin->lockis never taken by the idle thread. No exclusion between the two classes. - Both sides perform masked RMW of the same PM1/PM2 registers inside their "locked" region β interleaving loses updates (lost BM_STS clear, lost ARB_DISABLE set/clear, lost RLD write, mangled PM1_STS clears during resume).
Net effect: the ACPICA hardware-lock invariant is broken across CPUs;
real-world symptoms are ACPI power-management malfunction (missed wake
events, wrong sleep transitions, C3 entered with bus-master arbitration
disabled). No kernel-memory corruption: the lock protects register I/O
serialization only (AcpiGbl_GpeLock/AcpiGbl_ReferenceCountLock are never
touched from the idle thread β verified by rg over sys/dev/acpica:
gd_idlethread appears only in OsdSynch.c).
Why the mutex/semaphore side is NOT broken (audit answers for the record)
- ACPICA mutexes on DFly are 1-unit counting semaphores:
#define AcpiOsAcquireMutex(H,T) AcpiOsWaitSemaphore(H,1,T)β sys/contrib/dev/acpica/source/include/actypes.h:382-385. - Recursive AML-mutex acquire is handled inside ACPICA before the OSL is
called (
AcquisitionDepth, exmutex.c:302-308/461-469), so the non-recursive semaphore mapping cannot self-deadlock via AML. - ACPICA's own global-mutex ordering/deadlock check is compiled only under
ACPI_MUTEX_DEBUG(utmutex.c:381-415); a coding slip there would self-block β but bounded:ACPI_WAIT_FOREVERis capped at ~66 s by OsdSynch.c:158-161, and PCATCH signals break the ssleep (OsdSynch.c:215, EINTR falls to the absolute-deadline check :236-245). - No ACPICA site nests the same OSL spinlock (HardwareLock sections are
leaf), so the non-recursive
lockmgr LK_EXCLUSIVEcannot self-panic.
Guest run (attempt)
Command (see run.log): dfbsd-qemu/vm.sh run_root 'uname -a; sysctl hw.ncpu;
sysctl -a | grep cx_; dmesg | grep cst' β env.txt.
Result: hw.ncpu: 6, cpu_cst0..5 attached, but every CPU reports
cx_supported: C1/0 and hw.acpi.cpu.cx_lowest: C1. QEMU's ACPI tables
expose no C2/C3, so acpi_cst_idle always selects the C1 HLT path
(acpi_cpu_cstate.c:943-947) which performs no ACPICA bit-register
access β the idle-thread side of the race never executes in this guest.
sysctl -w hw.acpi.cpu.cx_lowest=C3 cannot create states the platform does
not advertise. Hence: not_reproduced on this guest, preconditions absent
(target hardware feature β deep ACPI C-states β not implemented by QEMU's
DSDT). The defect itself is code-provable; on real SMP laptops/servers with
CST C3 (the default configuration of the hardware class) both sides are live.
How to demonstrate on real hardware (methodology, for whoever has it)
Instrumented kernel (in-guest /usr/src copy, NOT the audit tree): add
counters in both branches of AcpiOsAcquireLock gated on
Spin == AcpiGbl_HardwareLock, and a kprintf when both counters are
non-zero with overlapping TSC intervals; boot with cx_lowest=C3, then run
race_trigger.sh (CST notify + RLD churn) for minutes. Expected: overlap
counter > 0. Stock-kernel observable: dmesg ACPI errors / missed wakeups /
S3 anomalies under the same load, non-deterministic.
Exploitability assessment
Impact ceiling is availability + platform-state integrity (PM1/PM2 register lost updates): no user-controllable data crosses the race, no kernel memory is corrupted, no privilege boundary is crossed. An unprivileged user can only amplify the coincidence rate (inducing idle+resume/notify overlap); there is no weaponizable primitive beyond DoS/malfunction. Severity Low per the project rubric ("narrow trigger, low impact, specific hardware class"), confidence certain for the lock-exclusion defect itself.
Fix validation
fix.diff authored against the read-only sys/ tree (normal threads also take
slock around the section; lockmgr retained as the sleepable contention
lock; idle threads keep pure-spin). Not built/validated: the baseline race
is not hostable on the QEMU guest (no C2/C3), so there is no baseline to
compare a patched kernel against. Fix reasoning is line-accurate:
HardwareLock holders never block inside the section (hwxface.c:411-471,
hwregs.c:574-581 β pure register I/O), so holding slock across the
section is safe; the idle thread never takes GpeLock/ReferenceCountLock,
so no idle-thread spin-against-sleeper hazard is introduced.
| aspect | value |
|---|---|
| status | not_reproduced (guest lacks C2/C3 ACPI states; static proof certain) |
| reproduced | 0 |
| impact | none observed (potential dos on affected hardware) |
| confidence | certain (defect), likely (real-world manifestation) |
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
not_testablefix.diff authored against read-only sys/; not built/validated because the baseline race cannot be hosted on the QEMU guest (no ACPI C2/C3 states advertised β see env.txt). The patch is mechanically minimal and its safety argument (OSL-spinlock sections are short non-blocking register RMWs, so the idle thread never spins against a sleeping holder) is documented in VERDICT.md and the diff comment.
findings/poc/DF-4890/fix.diff; VERDICT.md 'Fix validation' section; env.txt (guest C1-only)
Confirmed kernel references
- sys/dev/acpica/Osd/OsdSynch.c:374-403
- sys/dev/acpica/Osd/OsdSynch.c:405-429
- sys/dev/acpica/acpi_cpu_cstate.c:786
- sys/dev/acpica/acpi_cpu_cstate.c:933-935
- sys/dev/acpica/acpi_cpu_cstate.c:957-977
- sys/contrib/dev/acpica/source/components/hardware/hwxface.c:411-471
- sys/contrib/dev/acpica/source/components/hardware/hwregs.c:574-581
- sys/contrib/dev/acpica/source/components/utilities/utmutex.c:206-218
Detail
Evidence (decisive lines)
VERDICT.md (full static proof with path:line and guest-run explanation); env.txt + run.log (guest probe: hw.ncpu=6, cpu_cst0-5 attached, cx_supported C1/0 only -> C2/C3 precondition ABSENT on this guest); precond_probe.sh (runnable probe); race_trigger.sh (real-hardware amplifier); fix.diff (single-primitive fix)
PoC changes
n/a (finding authored in verify-of-self audit; probe written fresh β no seed PoC existed)
Verified recommended fix
Take spin_lock(&Spin->slock) in BOTH branches of AcpiOsAcquireLock/AcpiOsReleaseLock around the critical section, keeping lockmgr only as the sleepable contention lock for normal threads
Verdict
Defect is statically certain: AcpiOsAcquireLock (sys/dev/acpica/Osd/OsdSynch.c:383-388) takes spin_lock(&Spin->slock) when called from the idle thread but lockmgr(&Spin->lock)+crit_enter() for all other threads, and release (OsdSynch.c:423-428) mirrors the split β the two classes never exclude each other across CPUs. The idle-thread side is live via cpu_idle_hook=acpi_cst_idle (acpi_cpu_cstate.c:786) whose C3 BM/ARB paths call AcpiWriteBitRegister (acpi_cpu_cstate.c:935,958,977), which holds AcpiGbl_HardwareLock around a PM1/PM2 register RMW (hwxface.c:411-471); normal-thread takers of the same lock (acpi_cpu_cstate.c:668,810; AcpiHwClearAcuiStatus hwregs.c:574 on resume/sleep) run concurrently on other CPUs, interleaving masked RMWs of the same registers (lost BM_STS clears, lost ARB_DISABLE, mangled PM1_STS clears). Not reproduced at runtime: the QEMU guest has 6 CPUs and cpu_cst attached but advertises only C1/0 (env.txt/run.log), so the idle-side taker never executes there; reproduction requires real hardware with CST C2/C3 plus an instrumented kernel (methodology in VERDICT.md). Impact ceiling is availability/platform-state malfunction, not kernel memory corruption; severity Low.
No comments yet.