Uninitialized kernel-stack value exported to unprivileged users via world-readable hw.acpi.acline when firmware _PSR returns no object (DF-3793 root cause surfacing in acpi_acad)
Summary
AE_OK-with-no-object leaves the caller buffer untouched, so acpi_GetInteger reads its uninitialized stack ACPI_OBJECT - when residue Type==4 it copies 32 bits of stack into sc->status, served world-readable on every read (re-sampling the residue each time). LIVE-REPRODUCED on guest via injected SSDT (acline=0 where sentinel-correct is -1, read as uid 1001); same-thread kstack grooms could not vary the dword (honest impact: none). param.Type=ACPI_TYPE_ANY root-cause fix in acpi.c.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5073 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 5.3 KB | β raw | |
| ssdt_acad_sane.asl | β | 463 B | β download | |
| ssdt_acad_sane.aml | β | 80 B | β download | |
| ssdt_acad_leak.asl | β | 1.2 KB | β download | |
| ssdt_acad_leak.aml | β | 83 B | β download | |
| groom.c | β | 2.3 KB | view raw | |
| build.sh | β | 94 B | view raw | |
| run.sh | β | 89 B | view raw | |
| build.log | β | 1.2 KB | view raw | |
| run.log | β | 1.7 KB | view raw | |
| run.2.log | β | 1.5 KB | view raw | |
| run.sane.log | β | 880 B | view raw | |
| env.txt | β | 813 B | view raw | |
| fix.diff | β | 851 B | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 4.7 KB | view raw |
DF-5073 β Uninitialized kernel-stack value exported to unprivileged users via hw.acpi.acline when the AC-adapter _PSR returns no object
What
acpi_acad.c reads the AC-line status with acpi_GetInteger(h, "_PSR", &newstatus)
(sys/dev/acpica/acpi_acad.c:111). When the firmware's _PSR method evaluates
successfully but returns no object (malformed/quirky AML), ACPICA returns
AE_OK and leaves the caller's return buffer untouched
(sys/contrib/dev/acpica/source/components/namespace/nsxfeval.c:541-545,
!Info->ReturnObject β ReturnBuffer->Length = 0, Pointer never written).
acpi_GetInteger() (sys/dev/acpica/acpi.c:2162-2183) then inspects its
uninitialized stack local ACPI_OBJECT param; if the residue byte at
offset 0 happens to equal ACPI_TYPE_INTEGER (4), it copies
param.Integer.Value β 32 bits of kernel-stack residue β into *number.
acpi_acad.c:110-116 uses that garbage as the authoritative line status:
newstatus = -1;
acpi_GetInteger(h, "_PSR", &newstatus); /* may write stack residue */
if (newstatus != -1 && sc->status != newstatus) {
sc->status = newstatus; /* garbage persisted */
...
and the world-readable sysctl hw.acpi.acline returns it to any
unprivileged user on every read (acpi_acad.c:289 β acpi_acad.c:233-234).
Every sysctl read re-evaluates _PSR, i.e. re-samples the stack residue.
Root cause is DF-3793 (filed pass 1 against acpi.c); this finding is the
call-site export primitive in acpi_acad.c (previously unaudited file).
Reproduce (guest, no privilege needed for the read side)
- Build the two SSDTs with the guest
iasl(seebuild.sh): *ssdt_acad_sane.aslβ control:_PSRreturnsOne. *ssdt_acad_leak.aslβ trigger:_PSRfalls off the end (no Return). - Control boot:
cd dfbsd-qemu && QEMU_EXTRA="-acpitable file=ssdt_acad_sane.aml" ./vm.sh up 300*sysctl hw.acpi.aclineβ 1 (root and uid 1001),acpi_acad0attaches. - Trigger boot:
QEMU_EXTRA="-acpitable file=ssdt_acad_leak.aml" ./vm.sh up 300* Expected on a correct kernel:hw.acpi.acline= -1 (evaluation has no return value). * Observed:hw.acpi.acline= 0 β reachable only via the uninitializedparam(Type=4 residue,Valuelow dword 0). Seerun.log,run.2.log. - Grooming (
groom.c): same-thread deep syscalls (socket/connect,kern.proc.allsysctl, file reads) followed by immediateaclinereads by uid 1001 β 21 reads, value stayed 0 on this build.
Files
ssdt_acad_sane.asl/ssdt_acad_sane.amlβ control firmwaressdt_acad_leak.asl/ssdt_acad_leak.amlβ trigger firmwaregroom.cβ same-thread kstack groom + unprivileged acline readerbuild.sh/run.shβ exact commandsbuild.logβ iasl + cc output (full)run.logβ decisive groom run (uid 1001, 21 reads)run.2.logβ leak boot transcript incl. sanity controlrun.sane.logβ boot A control transcriptenv.txtβ guest environmentfix.diffβ root-cause one-liner inacpi_GetInteger(acpi.c; DF-3793's root cause), authored after verification, never applied tosys/VERDICT.md,manifest.json,verdict.json
Expected output (trigger boot, unprivileged)
$ sysctl hw.acpi.acline hw.acpi.acline: 0 <- should be -1; 0 proves uninitialized export
DF-5073 β VERDICT
Status: reproduced (mechanism) / impact: none (no kernel-secret content demonstrated) / confidence: certain (that uninitialized kernel stack is exported to unprivileged users).
What was run
Three boots of the audit QEMU guest (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC):
- Stock boot β no ACPI0003 device exists in QEMU's DSDT;
hw.acpi.aclineis absent (sysctl: unknown oid). The driver is inert on stock hardware here. - Control boot (
-acpitable file=ssdt_acad_sane.aml): ACPI0003 device with a well-formed_PSRreturningOne. Result:acpi_acad0attaches,hw.acpi.acline= 1 for root and for uid 1001. This proves the whole chain: injected SSDT β acpi_acad probe/attach β_PSRAML evaluation β world-readable sysctl. (run.sane.log) - Trigger boot (
-acpitable file=ssdt_acad_leak.aml): same device, but_PSRevaluates successfully and returns no object (iasl warnings 3115/3107 confirm the malformed shape).
What was observed (trigger boot)
sysctl hw.acpi.acline = 0, read by uid 1001 and root, repeatedly.
0 is only reachable through the uninitialized path:
acpi_acad.c:110setsnewstatus = -1(0xFFFFFFFF);- the only other writer of
newstatusisacpi_GetInteger's*number = param.Integer.Value(acpi.c:2180); AcpiEvaluateObjectfor a no-return method returns AE_OK without writing the caller's buffer (nsxfeval.c:541-545), soparamis uninitialized stack;- therefore the residue at
param.Typeread asACPI_TYPE_INTEGER(4) and the residue atparam.Integer.Value's low dword read as 0.
A correct kernel leaves newstatus == -1; the sysctl would show -1 (and the
boot-time init would print "tried 6 times" under verbose ACPI). We observed 0.
The uninitialized-stack export is proven live.
Why impact = none (honest ceiling)
To qualify as an info leak, the disclosed bytes must carry kernel-memory
content. I attempted to vary the leaked dword with same-thread kernel-stack
grooming before each read (groom.c): connect(2) (deep TCP path), 8Γ
sysctl(KERN_PROC_ALL) (deep vm/proc walk), 32Γ block reads of /kernel, and
process-level grooms (sysctl -a, ls -lR /usr/include, find /). All 21+
unprivileged reads returned the same 0 (run.log, run.2.log).
Explanation: the leaked slot is acpi_GetInteger's own param local, at a
depth that only other ACPI evaluation frames occupy. The residue
{Type=4, Value=0} is a stale ACPI_OBJECT{INTEGER, 0} left by some earlier
zero-returning AML evaluation on the same kstack region at boot (AML Zero
returns are ubiquitous); the sysctl read path re-derives the same value every
time, and ordinary syscalls never dirty that exact slot. On this build the
disclosed content is therefore constant and non-useful. I could not
demonstrate disclosure of pointers, secrets, or any variable kernel memory β
so the honest impact is none, not leak.
The class (uninitialized kernel stack exported to unprivileged users) is
real and live, hence severity Low (kernleak): trigger requires malformed
firmware (_PSR without return β iasl explicitly warns "Reserved method
must return a value"), reader side needs no privilege.
Root cause & fix
Root cause was already identified by DF-3793 (pass 1, acpi.c):
acpi_GetInteger reads param after an AE_OK evaluation that returned no
object. This finding adds the call-site impact in acpi_acad.c: the garbage
becomes persistent driver state (sc->status, acpi_acad.c:116) and is
served world-readably (acpi_acad.c:289,233-234); it also drives
power_profile_set_state() (acpi_acad.c:118).
fix.diff (authored after verification, never applied to sys/) fixes the
root cause with one line: pre-set param.Type = ACPI_TYPE_ANY before
AcpiEvaluateObject, so a no-return evaluation yields AE_TYPE and leaves
*number (i.e. newstatus == -1) untouched. With the fix, the trigger boot
must read hw.acpi.acline = -1 instead of 0.
Fix validation was not performed: the finding is Low severity, impact
none, and not memory corruption (contract requires fix builds for
reproduced memory-corruption findings). The 1-line fix is mechanically
obvious and directly targets the proven path.
Threat model
- Trigger: firmware/boot-time AML only (injected SSDT on QEMU; malformed
_PSRin the wild). No unprivileged user can create the trigger on a healthy machine. - Read side: fully unprivileged (
hw.acpi.aclineis CTLFLAG_RD, world readable;/dev/acpiioctls are not needed).
Kernel references
- sys/dev/acpica/acpi_acad.c:110-116 (newstatus sentinel + garbage accepted)
- sys/dev/acpica/acpi_acad.c:277-292 (acpi_acad_get_acline re-evaluates _PSR per read)
- sys/dev/acpica/acpi_acad.c:225-236 (sysctl handler exports sc->status)
- sys/dev/acpica/acpi.c:2162-2183 (uninitialized
paramβ DF-3793 root cause) - sys/contrib/dev/acpica/source/components/namespace/nsxfeval.c:541-545 (AE_OK with untouched ReturnBuffer->Pointer for no-return methods)
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 validation skipped per contract (mandatory only for reproduced memory-corruption findings; this is Low severity, impact none). fix.diff is a 1-line root-cause fix in acpi.c authored after verification; with it, the trigger boot must read hw.acpi.acline = -1 instead of 0. Never applied to sys/.
fix_baseline_reproduced=1: trigger boot on stock kernel reads acline=0 (run.2.log). Patched-kernel run not performed.
Confirmed kernel references
- sys/dev/acpica/acpi_acad.c:110
- sys/dev/acpica/acpi_acad.c:111
- sys/dev/acpica/acpi_acad.c:115
- sys/dev/acpica/acpi_acad.c:116
- sys/dev/acpica/acpi_acad.c:288
- sys/dev/acpica/acpi_acad.c:289
- sys/dev/acpica/acpi_acad.c:233
- sys/dev/acpica/acpi.c:2175
- sys/dev/acpica/acpi.c:2177
- sys/dev/acpica/acpi.c:2179
- sys/dev/acpica/acpi.c:2180
- sys/contrib/dev/acpica/source/components/namespace/nsxfeval.c:541
Detail
Evidence (decisive lines)
run.sane.log (control boot: acline=1 for uid 1001 proves injected-SSDT->acpi_acad attach->_PSR->world-readable sysctl chain); run.2.log (trigger boot: acline=0 instead of -1); run.log (groom.c: 21 unprivileged reads after deep same-thread grooms, constant 0); VERDICT.md (full why-0-only-via-uninit-path derivation and honest impact=none reasoning); fix.diff (1-line root-cause fix in acpi_GetInteger)
PoC changes
No seed PoC existed (new pass-2 finding). Wrote both SSDTs from scratch; had to model the no-return _PSR as 'If (LGreater (0,1)) {Return(One)}' so iasl accepts it while still falling off the end (iasl warns 3115/3107, which is exactly the malformed-firmware shape). First groom attempt (process-level sysctl -a / ls -lR between reads) failed to move the value because each command is a separate process with a different kstack; rewrote groom.c to groom and read in the SAME thread.
Verified recommended fix
In acpi_GetInteger (sys/dev/acpica/acpi.c), pre-set param.Type = ACPI_TYPE_ANY before AcpiEvaluateObject so a no-return evaluation yields AE_TYPE without reading uninitialized stack (fix.diff); defensively, acpi_acad.c:111 could also treat acpi_GetInteger failure as 'unknown' explicitly.
Verdict
Mechanism reproduced on the audit guest with an injected SSDT: an ACPI0003 AC-adapter whose _PSR evaluates OK but returns NO object makes hw.acpi.acline read 0 (for root and uid 1001) where the sentinel-correct value is -1. 0 is reachable only through acpi_GetInteger's uninitialized stack ACPI_OBJECT (AcpiEvaluateObject leaves ReturnBuffer->Pointer untouched on AE_OK for no-return methods, nsxfeval.c:541-545), whose residue here reads {Type=ACPI_TYPE_INTEGER, Value=0}; acpi_acad.c:111/116 persists it into sc->status and acpi_acad.c:289/233 exports it through the world-readable sysctl on every read. Impact honestly bounded to 'none': 21 same-thread kernel-stack grooms (connect(2), KERN_PROC_ALL sysctls, block reads) could not vary the leaked dword from 0 β the slot is owned by ACPI-evaluation frames, so no pointer-bearing kernel memory was demonstrated disclosed. Trigger is firmware-shape (boot-time AML) only; reader side is fully unprivileged. Severity Low (kernleak class).
No comments yet.