Unbounded while(*cp!="\"") scan in XML attribute-value parser causes heap OOB read / kernel panic
Summary
_prop_object_internalize_find_tag scans XML attribute value with while(*cp!="\"") cp++ and only checks for NUL terminator AFTER loop has already overrun buffer. Kernel-side copyin path NUL-terminates attacker XML at buf[pref_len] so plist tag whose attribute value opens quote but never closes (e.g. <plist version="X) drives scan past kmalloc buffer into adjacent kernel heap. Scan continues until it hits 0x22 byte in freed/adjacent memory or walks into unmapped page and panics kernel. Loop at 484-485 has no terminator check in body. Every other scanner uses _PROP_ISSPACE (includes _PROP_EOF) stops at NUL but this one uses bare *cp!="\"" test NUL (0x00) is not 0x22 so loop sails past buffer NUL sentinel. _PROP_EOF check at 486 is dead defense by the time control reaches it *cp is whatever byte runaway scan stopped on. Earlier EOF check at 480-481 only proves *cp!=NUL immediately after opening quote does not bound subsequent scan. Reachable: prop_kern.c:398 kmalloc pref_len+1 copyin at 399 NUL-terminate at 404 prop_dictionary_internalize -> _prop_generic_internalize -> _prop_object_internalize_find_tag. Attacker: any local principal who can issue proplib ioctl (device-mapper.c:267 NETBSD_DM_IOCTL on /dev/mapper/control tbridge.c:258 kern_udev.c:892). Impact: (a) kernel panic very high probability scan reads linearly past kmalloc almost always reaches unmapped page reliable local kernel DoS; (b) on rare run 0x22 found in adjacent live heap poic_cp left pointing into kernel heap downstream parsing constructs prop objects string/data values copied back to userspace via prop_dictionary_copyout_ioctl leaking kernel heap contents. 64KB copyin limit doesnt help bug is about missing closing quote not input size.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2227 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| oob_read_poc.c | trigger-source | privileged reproducer via /dev/udev UDEVPROP; mmap'd unclosed-quote plist; argv[1]=pref_len (60000=panic, 17=silent) | 4.8 KB | view raw |
| build.sh | build-script | cc -O2 -o oob_read_poc oob_read_poc.c | 187 B | view raw |
| run.sh | run-script | ./oob_read_poc [pref_len]; MUST run as root (/dev/udev is 0600) | 457 B | view raw |
| build.log | build-log | final successful userspace build | 70 B | view raw |
| run.log | run-log | baseline (#0) panic run summary | 871 B | view raw |
| panic.txt | panic-signature | Fatal trap 12 page fault in _prop_object_internalize_find_tag+0x32f cmpb $0x22,(%rax) at page-aligned addr 0xfffff8011864f000 | 1.4 KB | view raw |
| fix_build.log | build-log | single-fix nativekernel build (NK_DONE rc=0) | 6.4 KB | view raw |
| fix_run.log | run-log | patched (#1) 4x clean EIO runs, guest up, 0 fatal traps | 1.1 KB | view raw |
| env.txt | environment | uname, cc, /dev/udev perms (root:wheel 0600), vfs.quota.enabled | 342 B | view raw |
| fix.diff | suggested-fix | per-iteration _PROP_EOF(*cp) check in while loop + stricter post-loop check | 411 B | view raw |
| VERDICT.md | verdict | full narrative: reproduced panic, mechanism, root-only reachability hard blocker, fix validated | 8.7 KB | β raw |
| README.md | readme | human-facing build/run/expected | 2.3 KB | β raw |
DF-2227: unbounded while (*cp != '"') scan in XML attribute-value parser
Bug
_prop_object_internalize_find_tag (sys/libprop/prop_object.c:484-485) scans an
XML attribute value with while (*cp != '"') cp++; β no NUL check in the loop
body. Every other scanner in the same function uses _PROP_ISSPACE (which
includes _PROP_EOF, i.e. stops at NUL); only this loop uses the bare *cp != '"'
test. The kernel copyin path NUL-terminates at exactly buf[pref_len]
(prop_kern.c:404), so an attribute value that opens a " but never closes it
(<plist version="X, pref_len == strlen) drives the scan past the NUL
sentinel (0x00 β 0x22) into adjacent kernel heap.
Trigger (privileged)
Only privileged callers reach prop_dictionary_copyin_ioctl() on the default
GENERIC guest. This PoC uses /dev/udev UDEVPROP (root:wheel 0600 β the only
caller compiled into the base kernel). See VERDICT.md / DF-2231 for the full
reachability analysis (all four callers are privileged).
Build / Run
cc -O2 -o oob_read_poc oob_read_poc.c # or: ./build.sh ./run.sh # as root; default pref_len=60000 -> kernel panic ./run.sh 17 # small pref_len -> silent OOB read -> clean EIO
Expected
- Unpatched kernel (#0) with
pref_len=60000(large β kmem/page alloc): kernel panic β page fault in the buggy loop itself:Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff8011864f000 (page-aligned, 1st byte past buffer) Stopped at _prop_object_internalize_find_tag+0x32f: cmpb $0x22,(%rax) - Patched kernel (#1): clean
errno=5 EIO(unterminated quote now detected as a parse error at the NUL sentinel), no panic, guest stays up. Well-formed XML still parses normally.
Impact
Privileged kernel DoS (panic via page fault) + latent OOB read. NOT an unprivileged escalation: root-only reachability (valid hard blocker) AND a read-only primitive (no corruption to convert β valid hard blocker). The finding's secondary "heap leak via copyout" is not realised β the parse always fails after the OOB scan, so no kernel bytes reach userspace.
Fix
Add the per-iteration NUL check (same idiom the other scanners use):
while (!_PROP_EOF(*cp) && *cp != '"') cp++; β see fix.diff (validated).
DF-2227 β unbounded while (*cp != '"') scan in XML attribute-value parser
Verdict
REPRODUCED β the code claim is CONFIRMED: the attribute-value scan loop at
sys/libprop/prop_object.c:484-485 (while (*cp != '"') cp++;) has no NUL
check in its body, and the kernel copyin path NUL-terminates attacker XML at
exactly buf[pref_len] (prop_kern.c:404). The pre-loop _PROP_EOF check at
line 480 only verifies the first byte after the opening quote β it does not
bound the subsequent scan. A plist whose attribute value opens a " but never
closes it (e.g. <plist version="X with pref_len == strlen) drives the scan
past the NUL sentinel into adjacent kernel memory: '\0' (0x00) β '"'
(0x22), so the loop advances and reads uninitialised/unmapped kernel heap.
Confirmed as a kernel panic (page fault in the buggy instruction itself).
The proposed fix was VALIDATED on a single-fix kernel (panic β clean EIO,
guest stays up; legitimate-XML parse path unchanged).
Impact (honest, on the default GENERIC guest)
Privileged kernel DoS (panic via page fault) + latent OOB read. NOT an
unprivileged escalation β two independent valid hard blockers apply (see
Phase 6):
1. Root-only reachability. All four kernel callers of
prop_dictionary_copyin_ioctl() are privileged on the default guest
(re-verified here, consistent with DF-2231):
- sys/kern/kern_udev.c:892 UDEVPROP on /dev/udev β node is
root:wheel 0600 (maxx gets EPERM). This is the only caller compiled
into the base kernel and is what the PoC exercises (as root).
- sys/kern/vfs_quota.c:346 vquotactl(530) β gated by vfs_quota_enabled
(default 0, CTLFLAG_RD); sysctl vfs.quota.enabled is unset on this guest.
- sys/dev/disk/dm/device-mapper.c:267 NETBSD_DM_IOCTL β dm module not
loaded (would be 0640 root:operator).
- sys/dev/misc/tbridge/tbridge.c:258 TBRIDGE_LOADTEST β module not loaded.
=> No unprivileged trigger exists. rootβkernel is game-over by definition;
this is a privileged DoS / hardening gap, not unprivβroot.
2. Read-only primitive. This is an OOB read (cmpb $0x22,(%rax)), not a
write β there is no corruption primitive to convert into control flow, so no
escalation chain is derivable regardless of reachability.
The finding's secondary "kernel heap leak via copyout" claim is not realised on
this path: after the runaway scan (if it stops on a 0x22 byte in adjacent
heap rather than faulting), the very next checks at prop_object.c:486-492
almost always return false (the byte after the stray 0x22 is not >), so
_prop_object_internalize_find_tag returns false β
prop_dictionary_internalize returns NULL β EIO. No prop objects are
constructed from the OOB bytes, so nothing is copied back to userspace. The
realistic ceiling is privileged kernel DoS.
Mechanism (trigger β primitive β effect)
- Trigger. A privileged caller (
/dev/udevUDEVPROP, root) issues an ioctl whoseap->a_datais a userstruct plistref{ void *pref_plist; size_t pref_len; }(sys/libprop/plistref.h:43-45). - Sink.
udev_dev_ioctl()(kern_udev.c:892) βprop_dictionary_copyin_ioctl()β_prop_object_copyin()(prop_kern.c:398):c buf = kmalloc(pref->pref_len + 1, M_TEMP, M_WAITOK); /* 398 */ error = copyin(pref->pref_plist, buf, pref->pref_len); /* 399 */ ... buf[pref->pref_len] = '\0'; /* 404 β sole terminator */The NUL sentinel lands at exactlybuf[pref_len]β the last allocated byte. - Buggy scan (
prop_object.c_prop_object_internalize_find_tag, parsing the<plist version="...">tag):c cp++; /* 479: cp -> byte right after opening '"' */ if (_PROP_EOF(*cp)) /* 480: ONLY checks the FIRST post-quote byte */ return (false); ctx->poic_tagattrval = cp; /* 483 */ while (*cp != '\"') /* 484: *** NO NUL CHECK IN BODY *** */ cp++; /* 485: 'X'->cp++, '\0'(0x00!=0x22)->cp++ = OOB! */Every other scanner in this function bounds itself with_PROP_ISSPACE(which includes_PROP_EOF, i.e. stops at NUL): lines 379, 423, 462, 469. Only the attribute-value loop at 484 uses the bare*cp != '"'test, so it sails past the NUL sentinel. - Effect.
cpnow points past the allocation. The loop keeps reading adjacent kernel VA one byte at a time until it either finds a0x22byte (parse then fails cleanly β EIO, silent OOB read) or walks into an unmapped page β page fault β kernel panic. - Reproduced reliably as a panic with a largepref_len(60000): thekmalloc(60001)allocation goes through the kmem/page allocator, so the byte immediately afterbuf[pref_len]sits at a page boundary that is typically unmapped β fault on the first OOB read. Panic signature (2/2 runs):Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff8011864f000 (page-aligned, 1st byte past buffer) fault code = supervisor read data, page not present Stopped at _prop_object_internalize_find_tag+0x32f: cmpb $0x22,(%rax)The faulting instructioncmpb $0x22,(%rax)is the body of the loop at line 484 β definitive attribution. - With a smallpref_len(17 βkmalloc-32slab bucket) the scan usually finds a0x22byte in adjacent slab memory first β silent OOB read β EIO.
Exploit chain
Not applicable (read-only primitive + root-only reachability = two valid hard
blockers per Phase 6; no escalation chain is derivable). Impact ceiling is
privileged kernel DoS (panic). No exploit.c/chain.c authored β the trigger
PoC (oob_read_poc.c) is the deliverable and demonstrates the panic.
PoC changes
Rewrote the seeded oob_read_poc.c (which targeted the not-loaded dm
/dev/mapper/control) into a parameterised privileged demonstrator via the
base-kernel /dev/udev UDEVPROP ioctl:
- Uses the real <sys/udev.h> UDEVPROP macro + <libprop/plistref.h>.
- mmaps a user buffer of pref_len bytes: <plist version=" + 'A' padding,
no closing quote, no > β so the prop_kern.c:404 NUL is the sole
terminator and lands right after the unclosed value.
- argv[1] selects pref_len: 60000 (default β kmalloc page-zone β reliable
panic) or 17 (slab bucket β silent OOB read β EIO).
- Added build.sh/run.sh. Original README claim ("kernel panic") is correct
for the large-buffer mode; added the honest silent-OOB variant for the slab mode.
Fix (validated)
Add the per-iteration _PROP_EOF(*cp) check to the loop body β the same idiom
every other scanner in this function already uses β and treat the unterminated
case as a parse error. Full diff in fix.diff:
- while (*cp != '\"')
+ while (!_PROP_EOF(*cp) && *cp != '\"')
cp++;
- if (_PROP_EOF(*cp))
+ if (_PROP_EOF(*cp) || *cp != '\"')
return (false);
git apply --check against sys/: CLEAN (1 file, 2+2 lines).
Fix validation (single-fix kernel, make -j6 nativekernel)
- Baseline
#0(unpatched, Jul 2 2026):/tmp/oob2227 60000β kernel PANIC (_prop_object_internalize_find_tag+0x32f: cmpb $0x22,(%rax), page fault @0xfffff8011864f000, guest DOWN). Reproduced 2/2. - Single-fix
#1(Aug 8 16:51:23 2026, sha256350f0274β¦):/tmp/oob2227 60000β cleanerrno=5 EIO, NO panic, guest UP, 0 newFatal trapin boot.log. 3/3 large + 1/1 small variant all clean. The fix's_PROP_EOF(*cp)check stops the scan at thebuf[pref_len]NUL sentinel; the unterminated quote is then detected as a parse error. - Legit-XML path intact on
#1: a well-formed<plist version="1.0"> <dict></dict></plist>still parses (returnsEINVALfrom the udev handler β parse succeeded, handler ran, no"command"key β i.e. the parser is not broken by the fix; only the malformed unclosed-quote input now fails cleanly).
Files
oob_read_poc.cβ parameterised privileged demonstrator via/dev/udev UDEVPROP.build.sh/run.shβ exact build/run (run.sh [pref_len], default 60000).build.logβ final clean userspace build.run.logβ baseline (#0) panic run summary.panic.txtβ full panic signature fromdfbsd-qemu/boot.log(the crash proof).fix_build.logβ single-fixnativekernelbuild output (NK_DONE rc=0).fix_run.logβ patched (#1) clean-run summary (3Γ large + small, all EIO, guest up).env.txtβ guestuname,cc,/dev/udevperms,vfs.quota.enabled.fix.diffβgit apply-able fix (per-iteration NUL check).manifest.jsonβ machine-readable catalog.
Fix verification
fixedVALIDATED: ./oob_read_poc 60000 PANICS on unpatched #0 baseline (page fault in _prop_object_internalize_find_tag+0x32f cmpb $0x22,(%rax) @ 0xfffff8011864f000, guest DOWN, 2/2) and does NOT panic on single-fix #1 kernel (clean errno=5 EIO on 3/3 large + 1/1 small runs, guest UP, 0 new Fatal trap in boot.log). Legit-XML path intact on #1 (well-formed plist still parses -> EINVAL). git apply --check CLEAN (1 file, 2+2 lines).
baseline #0: Stopped at _prop_object_internalize_find_tag+0x32f: cmpb $0x22,(%rax) (page fault, guest DOWN). patched #1 run1/run2/run3: ioctl rc=-1 errno=5 (EIO) no panic, guest UP. patched #1 (17): errno=5 EIO no panic. after: 0 new Fatal trap in boot.log; legit plist still parses (EINVAL not EIO).
Confirmed kernel references
Detail
Exploit chain
Not applicable (two independent valid hard blockers per Phase 6): (1) READ-ONLY primitive β OOB is cmpb $0x22,(%rax) read compare, no write capability, no corruption to groom/convert into control flow; (2) ROOT-ONLY reachability β all four kernel callers of prop_dictionary_copyin_ioctl() privileged on default GENERIC (/dev/udev UDEVPROP root:wheel 0600 only base-kernel caller; vquotactl gated vfs_quota_enabled=0; dm/tbridge not loaded), root->kernel game-over by definition. No exploit.c/chain.c. Impact ceiling = privileged kernel DoS (panic via page fault).
Evidence (decisive lines)
BASELINE #0 (unpatched), /tmp/oob2227 60000 as root: kmalloc(60001) -> page alloc -> next byte unmapped -> PANIC (ssh timed out, guest DOWN). boot.log: Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff8011864f000 (page-aligned, 1st byte past buffer) / fault code = supervisor read data, page not present / Stopped at _prop_object_internalize_find_tag+0x32f: cmpb $0x22,(%rax). Reproduced 2/2 on #0.
PoC changes
Rewrote seeded oob_read_poc.c (targeted not-loaded dm) into parameterised privileged demonstrator via base-kernel /dev/udev UDEVPROP ioctl: real Add per-iteration NUL check to the while loop at sys/libprop/prop_object.c:484 β same idiom every other scanner in the function uses: while (!_PROP_EOF(cp) && cp != '"') cp++; and tighten post-loop check to if (_PROP_EOF(cp) || cp != '"') return (false); so an unterminated attribute value is a clean parse error instead of an OOB scan. Defense-in-depth good hygiene even though reachability is privileged. Full git-apply-able diff in findings/poc/DF-2227/fix.diff; supersedes finding proposal.Verified recommended fix
Verdict
REPRODUCED. Claim CONFIRMED: the attribute-value scan loop at sys/libprop/prop_object.c:484 (while (*cp != '"') cp++;) has NO NUL check in its body, unlike every other scanner in the same function which use _PROP_ISSPACE (includes _PROP_EOF). The kernel copyin path NUL-terminates at exactly buf[pref_len] (prop_kern.c:404), and the pre-loop _PROP_EOF check at line 480 only verifies the FIRST post-quote byte. Input
No comments yet.