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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5180 Β· 8 files
FileTypeDescriptionSize
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.json status untested, 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 == 0 and 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 narrative
  • trigger.dsl β€” DSDT snippet that reaches the scan (documentation)
  • fix.diff β€” the bounded-copy fix
  • manifest.json, verdict.json
VERDICT.md
↓ download raw

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

  1. acpi_PkgStr() accepts ACPI_TYPE_BUFFER and copies with strlcpy() (sys/dev/acpica/acpi_package.c:86-98) using siz = Buffer.Length + 1.
  2. ACPICA externalizes Buffers as a plain memcpy of exactly Buffer.Length bytes β€” no NUL terminator appended β€” while Strings get Length + 1 bytes including the terminator (sys/contrib/dev/acpica/source/components/utilities/utcopy.c:276-290).
  3. DragonFly strlcpy() ends with an unbounded source scan when no NUL was found within siz - 1 bytes (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.
  4. Size accounting shows the worst case is real: a package's element data regions are packed with ACPI_ROUND_UP_TO_NATIVE_WORD rounding, and a Buffer contributes exactly Buffer.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.
  5. Reachability: acpi_cmbat.c:374-380 extracts _BIF model/serial/type/ oeminfo via acpi_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

sys/dev/acpica/acpi_package.c

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_testable
baseline no→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5180

Confirmed kernel references

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.