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

CPUID brand string assembled into cpu_brand[48] without NUL termination β†’ out-of-bounds read in printcpuinfo() (layout-dependent .bss disclosure into world-readable hw.model; potential overflow of cpu_model[128])

Summary

3x16 CPUID brand bytes memcpy'd with no forced NUL then space-skip + unbounded strcpy: all-48-non-NUL (hypervisor-controlled, e.g. -cpu model-id=48 chars) walks past cpu_brand[47] - on the shipped kernel the zero hw_clockrate neighbor terminates after 1 byte; any non-zero boot-time neighbor discloses via world-readable sysctl hw.model, and >=80 contiguous non-NUL bytes overflow cpu_model[128]. LIVE-REPRODUCED on the guest kernel (48-char model-id -> hw.model exactly 48+NUL, strcpy read its terminator past [47]) + guard-page fault localizer for both walk paths + one-line fix validated on rebuilt kernel (47 bytes, in-bounds). Force-termination fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5344 Β· 14 files
FileTypeDescriptionSize
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 3.0 KB ↓ raw
brand_guard.c β€” 2.1 KB view raw
boot-brand48.log β€” 13.0 KB view raw
build.sh β€” 103 B view raw
build.log β€” 67 B view raw
run.sh β€” 163 B view raw
run.log β€” 354 B view raw
run.2.log β€” 402 B view raw
env.txt β€” 631 B view raw
fix.diff β€” 473 B view raw
fix_build.log β€” 215.9 KB view raw
fix_run.log β€” 875 B view raw
verdict.json β€” 4.6 KB view raw

DF-5344 β€” cpu_brand assembled from CPUID 0x80000002..4 with no NUL termination β†’ out-of-bounds read (and layout-dependent .bss disclosure / overflow) in printcpuinfo()

Where

sys/platform/pc64/x86_64/identcpu.c

 84: static char cpu_brand[48];
...
118:    if (cpu_exthigh >= 0x80000004) {
119:        brand = cpu_brand;
120:        for (i = 0x80000002; i < 0x80000005; i++) {
121:            do_cpuid(i, regs);
122:            memcpy(brand, regs, sizeof(regs));
123:            brand += sizeof(regs);
124:        }
125:    }
...
160:    brand = cpu_brand;
161:    while (*brand == ' ')
162:        ++brand;
163:    if (*brand != '\0')
164:        strcpy(cpu_model, brand);

The 3Γ—16-byte CPUID brand leaves are copied into cpu_brand[48] with no terminating NUL ever written. The brand string is fully controlled by the hypervisor (-cpu ...,model-id= in QEMU) or, on bare metal, by CPU firmware/microcode. With all 48 bytes non-NUL:

  • the space-skip loop at :161 reads cpu_brand[48] out of bounds;
  • strcpy(cpu_model, brand) at :164 reads past cpu_brand[47] hunting for a terminator and copies everything it finds into cpu_model[128], which is exported world-readable via sysctl hw.model (SYSCTL_STRING at :76-78).

Real-kernel .bss adjacency on the shipped guest kernel (nm /boot/kernel/kernel):

ffffffff818d9c20 b cpu_brand      # 48 bytes, ends at ...c50
ffffffff818d9c50 b hw_clockrate   # 4 bytes β€” still 0 when the strcpy runs
                                  # (it is only set at identcpu.c:169, after)
ffffffff818d9c60 b cpu_model

So on this build the walk terminates 1 byte past the array (disclosure empty, write stays in bounds). Any future link order / earlier-initialized neighbor turns the same defect into (a) disclosure of adjacent static kernel data through hw.model to unprivileged users, or (b) with β‰₯80 contiguous non-NUL bytes, a write past cpu_model[128].

How to reproduce (done, on the audit QEMU guest)

1. Live kernel proof (boot the guest with a 48-char, non-NUL brand)

MID=$(python3 -c "print('A'*48)")
qemu-img create -f qcow2 -b dfbsd-qemu/disk.qcow2 -F qcow2 /tmp/ovl-brand.qcow2
qemu-system-x86_64 -enable-kvm -m 2048 -smp 2 \
  -drive file=/tmp/ovl-brand.qcow2,if=virtio,format=qcow2 \
  -cpu "qemu64,model-id=$MID" \
  -netdev user,id=n0,hostfwd=tcp:127.0.0.1:5599-:22 \
  -device virtio-net-pci,netdev=n0 \
  -display none -serial file:boot-brand48.log -no-reboot
# once booted:
ssh -p 5599 maxx@127.0.0.1 'sysctl -n hw.model | wc -c'

Expected (and observed): 49 β€” hw.model holds exactly 48 'A' bytes. A brand containing a NUL among bytes 0..47 could never yield 48 characters, therefore the strcpy at :164 found its terminator outside the array: an out-of-bounds read executed by the real guest kernel. Serial console shows CPU: AAAAAAAA…(48)… β€” boot-brand48.log:32; unprivileged (uid=1001) sysctl hw.model shows the same 48 bytes β€” run.2.log.

2. Fault-localizing harness (guard page instead of sanitizer)

brand_guard.c places a 48-byte cpu_brand flush against a PROT_NONE page and runs the exact identcpu.c:118-125 + :160-164 algorithm:

cc -O0 -g -o brand_guard brand_guard.c
./brand_guard 0   # 48 'A'        -> SIGSEGV (strcpy reads brand[48])  EXIT=139
./brand_guard 1   # 48 spaces     -> SIGSEGV (skip-loop reads brand[48]) EXIT=139
./brand_guard 2   # 47 'A' + NUL  -> survives, model = 47 chars        EXIT=0

Observed verbatim on the guest β€” see run.log.

Files

  • brand_guard.c β€” guard-page harness (exact algorithm replica)
  • boot-brand48.log β€” full serial console of the 48-char-brand boot
  • build.log / run.log / run.2.log β€” harness build + runs + sysctl/dmesg/id
  • env.txt β€” guest environment
  • fix.diff β€” one-line fix (force termination)
VERDICT.md
↓ download raw

DF-5344 β€” VERDICT

Status: reproduced (OOB read executed by the live guest kernel). Impact: leak-class primitive (bounded by link layout); potential write overflow of cpu_model[128] in worse layouts. No uid0 path β€” the corrupted object is a fixed static string; control is limited to kernel .bss contents that follow cpu_brand until the first NUL.

What was proven, on what

  1. Hostile input constructible: QEMU 7.2 -cpu qemu64,model-id=<48 chars> is accepted and copied into CPUID leaves 0x80000002..4 without a terminating NUL (proven by the kernel observing 48 non-NUL bytes).
  2. Live kernel OOB read: booting the stock guest kernel (DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 2026, X86_64_GENERIC) with that brand yields hw.model == exactly 48 'A's (49 bytes with newline), read by an unprivileged uid=1001 user (run.2.log, boot-brand48.log:32). In-spec brand strings always contain a NUL within bytes 0..47, so a 48-character result is only possible if strcpy(cpu_model, brand) (identcpu.c:164) read its terminating byte past cpu_brand[47] β€” an out-of-bounds read of kernel static memory, executed at boot.
  3. Fault-localized harness: brand_guard.c (exact algorithm replica, buffer flush against a PROT_NONE page) faults precisely at brand[48] for both the strcpy path (mode 0) and the space-skip loop path (mode 1); the 47-char control survives (mode 2). Run on the guest with base cc 8.3 (run.log).
  4. Layout analysis (nm /boot/kernel/kernel): hw_clockrate (zero at that instant β€” set only at identcpu.c:169, after the copy) immediately follows cpu_brand, so on this specific build the OOB walk stops after 1 byte: no bytes are disclosed and the 50-byte write stays inside cpu_model[128]. The defect is nonetheless real and layout-dependent: any non-zero neighbor at boot time flows into world-readable sysctl hw.model; β‰₯80 contiguous non-NUL bytes overflow cpu_model.

Why it matters

CPUID brand leaves are attacker-controlled in every VM (hypervisor/operator) and firmware-controlled on bare metal. The kernel must not run unbounded string operations over them. The guard-page harness shows both consumption sites (:161 loop, :164 strcpy) read past the buffer.

Fix validation

fix.diff forces cpu_brand[47] = '\0' after the CPUID fill loop. Validated by rebuilding the guest kernel with only this change and re-running the identical 48-char-brand boot: baseline kernel β†’ hw.model = 48 bytes (OOB); patched kernel β†’ hw.model = 47 bytes (in-bounds truncation), guest boots normally. See fix_build.log, fix_run.log.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (only cpu_brand[NELEM(cpu_brand)-1]='\0' added at identcpu.c:131, -Werror-clean, MD5 differs from stock) re-tested with the identical 48-char CPUID brand: hw.model now 47 chars + NUL read in-bounds (dmesg 'CPU:' line shows 47 A's vs baseline 48), guest boots and runs normally. The out-of-bounds read is gone.

fix_build.log (full untrimmed rebuild: identcpu.c compiled -Werror, 'linking kernel.debug', RC=0); fix_run.log (patched-kernel sysctl/dmesg output); guest MD5s: stock 4625046e8061b34e1758d291bc6cad18 vs patched 41f844478778b38436f4370db9859299
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Oct 3 05:06:43 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (only change: findings/poc/DF-5344/fix.diff)

Confirmed kernel references

Detail

Exploit chain

hypervisor/firmware supplies 48 non-NUL CPUID brand bytes -> printcpuinfo() strcpy reads past cpu_brand[47] into adjacent kernel .bss -> copied bytes exposed world-readable via sysctl hw.model; layout-dependent write overflow of cpu_model[128] if >=80 contiguous non-NUL bytes follow. No uid0 path: victim object is a fixed static string.

Evidence (decisive lines)

boot-brand48.log:32 (CPU: 48 A's on stock kernel); run.2.log (unprivileged sysctl hw.model = 48 bytes); run.log (guard-page harness: modes 0,1 EXIT=139 at brand[48], control mode 2 EXIT=0); env.txt (nm layout cpu_brand -> hw_clockrate -> cpu_model); fix_run.log (patched kernel: 47 A's); fix_build.log (full -Werror-clean rebuild); VERDICT.md

PoC changes

No seed existed; authored from scratch: (1) live trigger via QEMU -cpu model-id of exactly 48 chars (QEMU 7.2 accepts it and copies it into the brand leaves without termination), (2) guard-page harness brand_guard.c replicating identcpu.c:118-164 verbatim since no ASAN runtime exists on guest or host.

Verified recommended fix

Force NUL termination after filling cpu_brand from CPUID 0x80000002..4: cpu_brand[NELEM(cpu_brand) - 1] = '\0'; (see fix.diff, validated)

Verdict

Confirmed out-of-bounds read past the 48-byte static cpu_brand[] in printcpuinfo(): the CPUID brand leaves (0x80000002..4, hypervisor/firmware controlled) are memcpy'd into cpu_brand with no NUL termination (identcpu.c:118-125), and the space-skip loop (:161) plus strcpy(cpu_model, brand) (:164) then walk past the array hunting for a terminator. Live proof on the QEMU guest: booting the stock kernel with -cpu qemu64,model-id=<48 chars> (accepted by QEMU 7.2, unterminated) yields hw.model of exactly 48 bytes read by an unprivileged uid=1001 user - only possible if strcpy read its terminator past cpu_brand[47]. A guard-page replica of the exact algorithm faults precisely at brand[48] for both consumption paths; the 47-char control survives. On the shipped kernel build the adjacent .bss object (hw_clockrate, zero at that instant) terminates the walk after 1 byte, so no bytes are disclosed and the write stays inside cpu_model[128]; any non-zero boot-time neighbor flows into world-readable sysctl hw.model, and >=80 contiguous non-NUL bytes would overflow cpu_model[128]. Fix validated on a rebuilt kernel: forced cpu_brand[47]=0 truncates the brand in-bounds (hw.model 47 bytes vs baseline 48) with no OOB.