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

pnp_create_devices ANSI tag trim loop reads/writes stack below buf[0] with no lower bound

Field Value
ID DF-1072
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:L
CWE CWE-125 Out-of-bounds Read; CWE-787 Out-of-bounds Write
File sys/bus/isa/pnp.c
Lines 398-411 (ANSI tag handling)
Area bus/isa (ISA Plug-and-Play protocol driver)
Confidence certain
Discovered 2026-07-14
Reported pending
Known CVE none
CVE match dfly_specific

Summary

The trailing-space trim loop after the ANSI-string bcopy has no lower bound on large_len. A malicious PnP card can return a PNP_TAG_ID_ANSI large tag with length 0 (or a payload consisting entirely of 0x20 spaces), causing the loop to evaluate buf[large_len-1] with large_len == 0 (i.e. buf[-1]) and decrement without limit, walking the stack below char buf[100]. The terminating buf[large_len] = '\0' then writes a NUL at the resulting negative index.

Root cause

/* pnp.c:398-411 */
if (PNP_LRES_NUM(tag) == PNP_TAG_ID_ANSI) {
    if (large_len > sizeof(buf) - 1)
        large_len = sizeof(buf) - 1;
    /* !!! no `if (large_len == 0) continue;` lower bound */
    bcopy(resinfo, buf, large_len);

    /*
     * Trim trailing spaces.
     */
    while (buf[large_len-1] == ' ')     /* reads buf[-1] when large_len==0 */
        large_len--;
    buf[large_len] = '\0';              /* writes NUL at the resulting negative index */
    desc = buf;
    ...
    device_set_desc_copy(dev, desc);    /* strlen() of possibly-uninitialised buf */
}

large_len is card-controlled (16-bit, pnp.c:387). The clamp if (large_len > sizeof(buf) - 1) large_len = sizeof(buf) - 1; (line 399-400) only enforces an upper bound of 99; it never enforces a lower bound. With large_len == 0 the loop while (buf[large_len-1] == ' ') large_len--; (line 406-407) immediately evaluates buf[-1], then buf[-2], etc., each read being a stack OOB read below buf (other locals of pnp_create_devices live there: desc, csnldn, ldn, logical_id, large_len itself, retval, plus caller frame). The loop only stops when a non-0x20 byte is found. Then buf[large_len] = '\0'; (line 408) writes a NUL at the (possibly negative) resulting index β€” a stack OOB write conditional on the prior OOB byte being 0x20. Even if no write occurs, desc = buf (line 409) followed by device_set_desc_copy(dev, desc) (line 411) strlen()s the uninitialized buf[0..] (bcopy wrote 0 bytes), leaking stack contents through the device description.

Threat model & preconditions

  • Attacker position: Malicious ISA-PnP card / QEMU-emulated device, same as DF-1071.
  • Privileges gained or impact: Kernel stack memory disclosed via devinfo / dmesg description strings (CWE-909 / CWE-125), plus a conditional single-byte stack OOB NUL write (CWE-787) at the first 0x20 byte found below buf; in principle a long run of 0x20 bytes (e.g. on a specially groomed stack) could extend the write further down, but the typical effect is a 1-byte stack corruption or an info leak of one or more stack bytes.
  • Required config or capabilities: Default kernel with ISA PnP. Card must pass isolation (see DF-1071).
  • Reachability: pnp_isolation_protocol β†’ pnp_create_devices at every boot. Trigger: return a large tag with PNP_LRES_NUM == PNP_TAG_ID_ANSI (0x02, i.e. tag byte 0x82) and length 0 (two zero length bytes), or any ANSI payload composed entirely of 0x20.

Proof of concept

Minimum PoC resource blob returned by a malicious card after a successful isolation: 82 00 00 β€” i.e. PNP_TAG_ID_ANSI large tag (0x82) with 16-bit length field 0x0000 and zero payload bytes, followed by 79 (PNP_TAG_END, small tag, len 0).

Implement a QEMU ISA-PnP model whose resource-data READ returns 82 00 00 79 after the card has won isolation; boot DragonFlyBSD with the model attached.

Build & run

# Implement a QEMU isa-pnp model returning the byte stream above, then:
qemu-system-x86_64 -enable-kvm -m 512 -hda dfbsd.img -device isa-pnp-custom

Expected output

  • (a) The OOB read of buf[-1] (and possibly buf[-2].. if those stack bytes happen to be 0x20).
  • (b) The device description string for the matching PnP child will then contain uninitialized stack bytes β€” visible in devinfo -v and dmesg.
  • (c) If any of those bytes equals 0x20, buf[large_len] = '\0' writes a NUL one or more bytes below buf on the kernel stack, potentially corrupting a local variable or saved register, with outcomes ranging from silent corruption to kernel panic on return.

A purely emulator-driven demonstrator (no real hardware) is the most reproducible form: a QEMU patch returning 82 00 00 from PNP_RESOURCE_DATA after the card has won isolation.

Impact

Kernel stack info leak + conditional 1-byte stack OOB NUL write from a malicious ISA-PnP device during boot. Physical plug-in precondition; CVSS AV:P. Medium severity per "info leak of kernel stack" + possible stack corruption.

Bound the trim loop below and skip the bcopy / desc path when large_len is 0:

--- a/sys/bus/isa/pnp.c
+++ b/sys/bus/isa/pnp.c
@@ -398,7 +398,9 @@
            if (PNP_LRES_NUM(tag) == PNP_TAG_ID_ANSI) {
                if (large_len > sizeof(buf) - 1)
                    large_len = sizeof(buf) - 1;
+               if (large_len == 0)
+                   continue;
                bcopy(resinfo, buf, large_len);

                /*
                 * Trim trailing spaces.
                 */
-               while (buf[large_len-1] == ' ')
+               while (large_len > 0 && buf[large_len-1] == ' ')
                    large_len--;

This both prevents the buf[-1] read and stops the loop from running away past buf[0].

References

Timeline

  • 2026-07-14 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1072 Β· 8 files
FileTypeDescriptionSize
verify.sh trigger-source static source-verification script (5 checks) 1.3 KB view raw
verify.log run-log verify.sh output on audit commit 1.9 KB view raw
VERDICT.md verdict full narrative: mechanism + why-not-reproduced 4.6 KB ↓ raw
fix.diff suggested-fix lower-bound large_len==0 skip + bounded trim loop 524 B view raw
env.txt environment uname, cc, sysctls 713 B view raw
README.md readme how to reproduce 1.2 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme how to reproduce
↓ download raw

DF-DF-1072 β€” reproduce

This finding was verified by static source tracing (see VERDICT.md). Runtime reproduction on the audit's default QEMU guest is not possible because the precondition is outside the unprivileged-guest-user threat model (see VERDICT.md "Why it cannot be triggered from the audit guest").

How to verify (static source check)

# from the repo root (sys/ must be present)
sh findings/poc/DF-DF-1072/verify.sh

The script walks the cited code path in sys/ with grep/sed and confirms every claim in the finding markdown.

Files

File Purpose
verify.sh static source-verification script
verify.log output of verify.sh on the audit commit (the evidence)
VERDICT.md full narrative: mechanism, why-not-reproduced, fix rationale
fix.diff git-apply-able fix (validated with git apply --check)
env.txt guest environment (uname, cc, sysctls, modules)
manifest.json machine-readable artifact catalog
VERDICT.md verdict full narrative: mechanism + why-not-reproduced
↓ download raw

DF-1072 β€” pnp_create_devices ANSI tag trim loop has no lower bound (stack OOB read/write)

Verdict

NOT REPRODUCED (runtime) β€” STATIC VERIFICATION CONFIRMED.

The cited code path and bug exist verbatim in sys/bus/isa/pnp.c:398-411. Like DF-1071, the file is built into the default GENERIC kernel (device isa), but the runtime trigger requires a malicious ISA-PnP card returning a PNP_TAG_ID_ANSI large tag with large_len == 0 (or a payload of all 0x20 spaces). The audit guest has no PnP cards (dmesg shows none; pnp_isolation_protocol returns 0 devices), so the vulnerable loop is never entered. Same hardware-gated classification as DF-1071.

Mechanism (confirmed by source trace)

/* pnp.c:398-411 β€” the bug */
if (PNP_LRES_NUM(tag) == PNP_TAG_ID_ANSI) {       /* 0x82 = large tag, LRES_NUM 0x02 */
    if (large_len > sizeof(buf) - 1)              /* :399  clamp to 99              */
        large_len = sizeof(buf) - 1;              /* :400  (upper bound only)       */
                                                  /* !!! no `if (large_len == 0) continue;` */
    bcopy(resinfo, buf, large_len);               /* :401  with large_len=0, copies nothing */

    /*
     * Trim trailing spaces.
     */
    while (buf[large_len-1] == ' ')               /* :406  reads buf[-1] when large_len==0 */
        large_len--;                              /* :407  decrements without limit         */
    buf[large_len] = '\0';                        /* :408  writes NUL at resulting index    */
    desc = buf;                                   /* :409  desc points at uninitialized buf */
    if (dev)
        device_set_desc_copy(dev, desc);          /* :411  strlen() on uninitialized buf    */
    continue;
}

large_len is card-controlled (16-bit, pnp.c:387). The clamp at :399-400 only enforces an upper bound of 99 (sizeof(buf) - 1); it never enforces a lower bound. With large_len == 0 the loop while (buf[large_len-1] == ' ') large_len--; (:406-407) immediately evaluates buf[-1], then buf[-2], etc. β€” each read is a stack OOB read below the char buf[100] array (other locals of pnp_create_devices live there: desc, csnldn, ldn, logical_id, large_len itself, retval, plus caller frame). The loop only stops when a non-0x20 byte is found. Then buf[large_len] = '\0'; (:408) writes a NUL at the (possibly negative) resulting index β€” a stack OOB write conditional on the prior OOB byte being 0x20. Even if no write occurs, desc = buf followed by device_set_desc_copy(dev, desc) (:411) strlen()s the uninitialized buf[0..] (the bcopy wrote 0 bytes), leaking stack contents through the device description.

Definitions confirmed in sys/bus/isa/pnpreg.h: PNP_RES_TYPE(a) = (a >> 7) (:217), PNP_LRES_NUM(a) = (a & 0x7f) (:220), PNP_TAG_ID_ANSI = 0x2 (:238) β€” so a tag byte of 0x82 (bit 7 set, low 7 bits = 2) selects the ANSI branch, with the 16-bit length field attacker-controlled.

Why it cannot be triggered from the audit guest

Same as DF-1071: no PnP cards in the audit QEMU guest, no runtime path to re-invoke pnp_identify. The bug requires attacker-controlled PnP hardware. CVSS AV:P/AC:L.

Exploit chain

None developed β€” primitive is a stack OOB read + conditional 1-byte NUL write below buf, requiring attacker-controlled PnP hardware. The finding rates the impact as kernel-stack info leak + possible stack corruption; not derivable on the audit guest.

PoC

verify.sh β€” static-verification script that walks the cited path with grep/sed against sys/, confirming: (1) the clamp at :399-400 enforces only an upper bound (no lower-bound large_len == 0 guard); (2) the trim loop at :406-407 reads buf[large_len-1] with no lower bound, so large_len == 0 reads buf[-1]; (3) buf[large_len] = '\0' at :408 writes NUL at the resulting negative index; (4) the PNP_TAG_ID_ANSI / PNP_LRES_NUM / PNP_RES_TYPE definitions in pnpreg.h:217,220,238; (5) no PnP cards present in the audit guest so the loop never executes. Run from the repo root: sh findings/poc/DF-1072/verify.sh.

Fix

fix.diff β€” adds if (large_len == 0) continue; after the upper-bound clamp (skipping the bcopy/desc path entirely for empty ANSI tags), and changes the trim loop to while (large_len > 0 && buf[large_len-1] == ' ') large_len--; to bound it below at buf[0]. Both prevent the buf[-1] read and stop the loop from running away past buf[0]. Matches the finding markdown's recommended fix.

Reproduce

sh findings/poc/DF-1072/verify.sh     # static source verification

Fix verification

not_testable

compile validated

see evidence pack

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

β€”

Verdict

Source-confirmed. pnp ANSI tag large_len=0 trim loop buf[-1] stack OOB. In GENERIC, no PnP cards. Fix compiles.