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)
PoC verification
Evidence pack
findings/poc/DF-5344 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 pastcpu_brand[47]hunting for a terminator and copies everything it finds intocpu_model[128], which is exported world-readable viasysctl 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 bootbuild.log/run.log/run.2.logβ harness build + runs + sysctl/dmesg/idenv.txtβ guest environmentfix.diffβ one-line fix (force termination)
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
- 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). - 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 ifstrcpy(cpu_model, brand)(identcpu.c:164) read its terminating byte pastcpu_brand[47]β an out-of-bounds read of kernel static memory, executed at boot. - Fault-localized harness:
brand_guard.c(exact algorithm replica, buffer flush against a PROT_NONE page) faults precisely atbrand[48]for both thestrcpypath (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). - Layout analysis (
nm /boot/kernel/kernel):hw_clockrate(zero at that instant β set only at identcpu.c:169, after the copy) immediately followscpu_brand, so on this specific build the OOB walk stops after 1 byte: no bytes are disclosed and the 50-byte write stays insidecpu_model[128]. The defect is nonetheless real and layout-dependent: any non-zero neighbor at boot time flows into world-readablesysctl hw.model; β₯80 contiguous non-NUL bytes overflowcpu_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
fixedSingle-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
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.
No comments yet.