acpi_PkgStr() heap out-of-bounds read past non-NUL-terminated ACPI_TYPE_BUFFER: strlcpy()'s unbounded 'traverse rest of src' scan walks off the end of the ACPICA allocation for a zero-free firmware buffer
Summary
ACPICA externalizes Buffers with no terminator, but PkgStr copies them with strlcpy whose post-truncation scan is unbounded - a zero-free buffer (crafted _BIF via acpi_cmbat) reads past the kmalloc'd blob; 8-aligned trailing buffers land past the block (allocation zeroing contains the unaligned case). No disclosure (return discarded, dst bounded) - panic ceiling. Bound-the-Buffer-copy fix (apply-verified).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5180 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.5 KB | β raw | |
| VERDICT.md | β | 3.9 KB | β raw | |
| trigger.dsl | β | 1.9 KB | β download | |
| fix.diff | β | 1.0 KB | view raw | |
| build.sh | β | 118 B | view raw | |
| run.sh | β | 143 B | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 3.5 KB | view raw |
DF-5180 β acpi_PkgStr() heap OOB read past non-NUL-terminated ACPI_TYPE_BUFFER
- File:
sys/dev/acpica/acpi_package.c - Severity: Low (firmware-gated heap OOB read; usually self-limited, worst-case kernel panic; no disclosure)
- Confidence: certain (code path proven from strlcpy + ACPICA sources)
- Verification: source-confirmed only β the object comes from ACPI
firmware (DSDT/SSDT or EC/battery-supplied data behind AML). There is no
unprivileged runtime entry point on the audit guest (its DSDT is QEMU's
benign one), so no guest run was attempted (
verdict.jsonstatusuntested, attempts 0). Same treatment as the sibling findings DF-2177/DF-2385 (source-confirmed, ACPI-gated).
Root cause
acpi_PkgStr() accepts ACPI_TYPE_BUFFER elements
(sys/dev/acpica/acpi_package.c:86-89) and copies them with strlcpy()
(:95-98):
case ACPI_TYPE_BUFFER:
ptr = obj->Buffer.Pointer;
length = obj->Buffer.Length;
break;
...
/* Make sure string will fit, including terminating NUL */
if (++length > size)
return (E2BIG);
strlcpy(dst, ptr, length);
ACPICA guarantees NUL-termination only for Strings β externalization
copies String.Length + 1 bytes including the terminator
(sys/contrib/dev/acpica/source/components/utilities/utcopy.c:276-278) β
while a Buffer is a plain memcpy of exactly Buffer.Length bytes with no
terminator appended (utcopy.c:288-290).
DragonFly's strlcpy() (sys/libkern/strlcpy.c:56-61) is only safe for
real C strings: when no NUL is found within the first siz-1 bytes it
finishes with an unbounded scan to compute the source length:
if (n == 0) {
if (siz != 0)
*d = '\0'; /* NUL-terminate dst */
while (*s++)
;
}
siz here is Buffer.Length + 1, so the copy loop reads at most
Buffer.Length bytes (in bounds), but if the buffer contains no 0x00
byte, the very first byte of the scan loop β ptr[Buffer.Length] β is
already one past the end of the ACPI buffer, and the scan runs until it
stumbles on a zero byte.
Why it is (usually) contained β and when it is not
The external ACPI_OBJECT blob handed to AcpiEvaluateObject() callers is
one allocation, zeroed up front
(sys/contrib/dev/acpica/source/components/utilities/utalloc.c:527), and
element data regions are laid out back-to-back with 8-byte rounding
(utcopy.c:285-286; size accounting in
sys/contrib/dev/acpica/source/components/utilities/utobject.c:512-514 adds
exactly Buffer.Length). Consequences:
Buffer.Length % 8 != 0: the 1β7 padding bytes after the content are still zero from the initial memset β the scan stops inside the same allocation. Still a read past the logical object, but no fault.Buffer.Length % 8 == 0and the buffer is the last data in the package: the content ends exactly at the end of the kmalloc'd block βptr[Buffer.Length]is the first byte past the heap allocation. The scan then walks adjacent heap (kmalloc/zone metadata, neighboring chunks) until a zero byte; if the block ends at an unmapped boundary (slab/page edge), the read faults β kernel panic.
No information is disclosed: the strlcpy() return value is discarded and
the write to dst is bounded by the checked size. Impact is therefore a
(best-case silent / worst-case panicking) heap OOB read.
Reachability
Callers: acpi_cmbat.c:374-380 extracts _BIF battery strings
(model/serial/type/oeminfo) with acpi_PkgStr(). The helper deliberately
accepts ACPI_TYPE_BUFFER, so any DSDT/SSDT (or EC/battery AML data)
delivering those elements as a Buffer with no zero byte reaches the scan.
Threat model: crafted ACPI tables (VM builder, firmware compromise) or
hostile embedded-controller/battery firmware β the same gate as the known
DF-2177/DF-2385 trio, which this file's pass-1 findings established as
in-scope (this is a distinct bug: not the NULL ptr, not the unbounded
idx β the unbounded scan after a well-formed buffer).
Fix
fix.diff in this directory β for Buffers, bound the copy to the buffer:
find the first NUL with memchr(ptr, 0, Buffer.Length) and memcpy() that
many bytes (dst is already zeroed at acpi_package.c:79, so termination is
in place); Strings keep the strlcpy() path. Not applied to sys/;
not guest-validated (fix_status: not_testable β firmware-gated).
Artifacts
VERDICT.mdβ this narrativetrigger.dslβ DSDT snippet that reaches the scan (documentation)fix.diffβ the bounded-copy fixmanifest.json,verdict.json
DF-5180 β Verification Verdict
Status: untested (source-confirmed; ACPI-firmware-gated β no unprivileged trigger on the audit guest) Impact: dos (heap OOB read; usually silent, worst-case kernel panic at an allocation/page boundary; no information disclosure) Confidence: certain Attempts: 0 (no runtime attempt β see below)
Why the code path is proven without a guest run
acpi_PkgStr()acceptsACPI_TYPE_BUFFERand copies withstrlcpy()(sys/dev/acpica/acpi_package.c:86-98) usingsiz = Buffer.Length + 1.- ACPICA externalizes Buffers as a plain
memcpyof exactlyBuffer.Lengthbytes β no NUL terminator appended β while Strings getLength + 1bytes including the terminator (sys/contrib/dev/acpica/source/components/utilities/utcopy.c:276-290). - DragonFly
strlcpy()ends with an unbounded source scan when no NUL was found withinsiz - 1bytes (sys/libkern/strlcpy.c:56-61:while (*s++) ;). For a zero-free buffer, the first scanned byte (ptr[Buffer.Length]) is already one past the ACPI buffer object. - Size accounting shows the worst case is real: a package's element data
regions are packed with
ACPI_ROUND_UP_TO_NATIVE_WORDrounding, and a Buffer contributes exactlyBuffer.Length(sys/contrib/dev/acpica/source/components/utilities/utobject.c:512-514,utcopy.c:285-286). For a trailing buffer whose length is 8-aligned, the content ends exactly at the end of the zeroed result allocation (utalloc.c:527), so the scan reads past the kmalloc'd block. - Reachability:
acpi_cmbat.c:374-380extracts_BIFmodel/serial/type/ oeminfo viaacpi_PkgStr(); the helper deliberately handles Buffer-typed elements, so crafted ACPI tables or hostile EC/battery AML reach the scan.
Containment (why Low): the result blob is zeroed on allocation
(utalloc.c:527), so when Buffer.Length % 8 != 0 the 1β7 padding bytes
after the content are zero and the scan stops inside the same allocation.
Even in the worst case the scan stops at the first zero byte in adjacent
heap; the strlcpy() return value is discarded and the write to dst is
bounded by the pre-checked size, so no OOB data is disclosed β the
impact ceiling is a kernel panic at an unlucky slab/page boundary.
Distinct from the known trio: DF-2177 is the zero-length ptr == NULL
dereference; DF-2385 is the unbounded Elements[idx]; DF-2178 the dead
NULL checks. This finding is the unbounded post-buffer scan on a
well-formed, non-empty buffer with valid ptr and in-bounds idx.
Why no guest run
The object must originate from ACPI firmware. The audit guest's QEMU DSDT is
benign; delivering a crafted table requires rebuilding the guest's firmware
configuration (root), and there is no post-boot entry point (no ioctl/sysctl
reaches acpi_PkgStr). A KLD that fabricates an ACPI_OBJECT would only
demonstrate a read that is usually contained within the same allocation β
not an observable effect. Marked untested (static proof), matching the
family precedent (DF-2177/DF-2178/DF-2385 were source-confirmed).
Fix validation
fix.diff (in this directory) bounds the Buffer path: scan for the first
NUL strictly inside Buffer.Length, then memcpy() the bounded prefix
(dst is already zeroed at acpi_package.c:79). Verified to apply cleanly
(patch -p1 --dry-run OK against a pristine copy of the file). Kernel
build/boot validation not performed (Low severity, firmware-gated trigger):
fix_status: not_testable.
Source File
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 after source verification (kernel-idiomatic bounded loop; no libkern memchr dependency); verified to apply cleanly to a pristine copy (patch -p1 --dry-run OK). Kernel build/boot validation skipped: Low-severity firmware-gated trigger, no runtime path on the audit guest.
findings/poc/DF-5180/fix.diff (sha256 76e6a177410b6d51ade09ac3ca45649bd478643404cee8002ef026ab9675c7d4); dry-run apply check in VERDICT.md
Confirmed kernel references
- sys/dev/acpica/acpi_package.c:86
- sys/dev/acpica/acpi_package.c:95
- sys/dev/acpica/acpi_package.c:98
- sys/libkern/strlcpy.c:56
- sys/contrib/dev/acpica/source/components/utilities/utcopy.c:288
- sys/contrib/dev/acpica/source/components/utilities/utobject.c:512
- sys/contrib/dev/acpica/source/components/utilities/utalloc.c:527
- sys/dev/acpica/acpi_cmbat.c:374
Detail
Evidence (decisive lines)
findings/poc/DF-5180/VERDICT.md (full chain incl. containment analysis); trigger.dsl (crafted _BIF reaching the scan); fix.diff (applies cleanly, patch -p1 --dry-run OK)
PoC changes
n/a (no seed PoC; trigger documented as crafted-DSDT snippet in trigger.dsl)
Verified recommended fix
For ACPI_TYPE_BUFFER, bound the copy to the buffer: scan for the first NUL strictly within Buffer.Length and memcpy() that prefix (dst is already zeroed); keep strlcpy() only for NUL-terminated Strings.
Verdict
Source-confirmed heap OOB read: acpi_PkgStr() (acpi_package.c:86-98) accepts ACPI_TYPE_BUFFER elements and copies them with strlcpy() using siz = Buffer.Length + 1, but ACPICA externalizes Buffers without a NUL terminator (utcopy.c:288-290 memcpy of exactly Buffer.Length bytes; only Strings get Length+1 with terminator at :276-278). DragonFly's strlcpy() ends with an unbounded 'while (*s++) ;' source scan when no NUL is found within siz-1 bytes (sys/libkern/strlcpy.c:56-61), so a zero-free Buffer makes the scan read ptr[Buffer.Length] and beyond. Size accounting (utobject.c:512-514, utcopy.c:285-286) shows a trailing 8-aligned buffer ends exactly at the end of the zeroed result allocation (utalloc.c:527), putting the first OOB byte past the kmalloc'd block. Containment: the allocation is zeroed up front, so Length%8!=0 buffers stop on zero padding inside the same block; the strlcpy return value is discarded and the dst write is bounded by the checked size, so nothing is disclosed - impact ceiling is a kernel panic at an unlucky slab/page boundary. Reachable via acpi_cmbat.c:374-380 (_BIF strings) with crafted ACPI tables or hostile EC/battery AML. Distinct from known DF-2177 (NULL ptr), DF-2385 (unbounded idx), DF-2178 (dead checks). No guest run: the guest's QEMU DSDT is benign and no post-boot entry point reaches acpi_PkgStr; static proof chain is complete.
No comments yet.