Kernel plist integer parsing silently saturates decimal values exceeding INT64_MAX (unsigned fallback is dead code)
Summary
When kernel internalizes attacker-supplied XML plist <integer> with decimal value in range (INT64_MAX UINT64_MAX] value silently corrupted to INT64_MAX. ERANGE overflow check in _prop_number_internalize_signed guarded by #ifndef _KERNEL (kernel has no errno) so strtoq silent saturation (strtoq.c:122-123 returns QUAD_MAX on overflow no externally visible indication) goes undetected function always returns true. Short-circuit && at line 584 ensures _prop_number_internalize_unsigned never called making entire try-signed fall-back-to-unsigned design non-functional in kernel. Userland build unaffected checks errno==ERANGE. Decimal value > 9223372036854775807 stored as signed INT64_MAX instead of correct unsigned value. pnv_is_unsigned flag set incorrectly (false instead of true). Hex path (0x prefix) correctly routes to strtouq handles full uint64_t range so only DECIMAL unsigned values > INT64_MAX affected. Any local user submitting plist to kernel ioctl (prop_dictionary_copyin_ioctl) can trigger. Kernel silently stores wrong value and wrong signedness flag. Data-integrity violation on attacker-controlled input. Kernel/userland desynchronization in proplib-using protocols. [VERIFIED pass2: reproduced on stock kernel via real prop_array_internalize - decimal in (INT64_MAX UINT64_MAX] stored signed-saturated INT64_MAX with is_unsigned=0 while userland build of same source stores correct unsigned value or rejects with ERANGE; capital-0X hex found as additional instance; fix-validated - kernel strtouq re-parse saturation check revives the unsigned fallback; NOT privesc - fixed-size union no memory unsafety]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2230 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| kernel_side.c | β | 3.7 KB | view raw | |
| Makefile | β | 259 B | β download | |
| userland_full.c | β | 2.3 KB | view raw | |
| userland_unit.c | β | 3.8 KB | view raw | |
| build.sh | β | 112 B | view raw | |
| run.sh | β | 103 B | view raw | |
| build.log | β | 1.1 KB | view raw | |
| run.log | β | 7.1 KB | view raw | |
| fix.diff | β | 964 B | view raw | |
| fix_build.log | β | 5.7 MB | β download | |
| fix_run.log | β | 7.0 KB | view raw | |
| run.fixed.log | β | 7.0 KB | view raw | |
| env.txt | β | 505 B | view raw | |
| VERDICT.md | β | 10.1 KB | β raw | |
| README.md | β | 3.6 KB | β raw | |
| manifest.json | β | 2.0 KB | view raw | |
| verdict.json | β | 5.8 KB | view raw |
DF-2230 β Verification evidence pack
Finding: Kernel plist integer parsing silently saturates decimal values
exceeding INT64_MAX (unsigned fallback is dead code)
File: sys/libprop/prop_number.c:519-584 β severity Medium, bucket base:libs
Verdict: reproduced on the stock kernel (wrong value + wrong signedness
stored; kernel/userland desync); fix validated (fixed kernel matches userland).
What is in here
| file | role |
|---|---|
kernel_side.c + Makefile |
KLD that drives the REAL in-kernel parser path (prop_array_internalize -> _prop_number_internalize -> _prop_number_internalize_signed -> strtoq) with a case matrix of <integer> payloads and reports stored value + pnv_is_unsigned via the real accessors. No code copied from prop_number.c. |
userland_full.c |
same matrix through userland prop_array_internalize (stock -lprop, built from the same sys/libprop source) β the desync counterpart. |
userland_unit.c |
#include of the audited prop_number.c compiled in USERLAND mode (stock libprop flags) so the static parsers are callable directly; shows errno=ERANGE (34) and rv=0 exactly where the kernel accepts. |
build.sh |
guest-side build; sha256-gates guest /usr/src/sys/libprop/prop_number.c == audited tree (89da58daβ¦). |
run.sh |
guest-side baseline run (KLD load + both userland binaries). |
build.log |
full untrimmed baseline build output. |
run.log |
full untrimmed BASELINE run (stock kernel #0, Jul 2 2026) β the bug. |
fix.diff |
git-applyable one-hunk fix for _prop_number_internalize_signed. |
fix_build.log |
full untrimmed fixed-kernel build (make nativekernel + installkernel). |
fix_run.log / run.fixed.log |
identical matrix on the FIXED kernel (#1, Oct 17 2026). |
env.txt |
guest uname, compiler, kldstat. |
VERDICT.md |
full narrative + case-matrix tables + privesc assessment. |
manifest.json / verdict.json |
machine-readable catalog / persisted verdict. |
How to reproduce (on the dfbsd QEMU guest, as root)
# copy this directory to the guest as /root/df2230, then: sh /root/df2230/build.sh # sha256 gate + build df2230.ko + 2 userland binaries sh /root/df2230/run.sh # baseline matrix (stock kernel)
For fix validation (what was done here):
cd /usr/src && patch -p1 < /root/df2230/fix.diff make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel KERNCONF=X86_64_GENERIC reboot # into fixed kernel sh /root/df2230/run.sh # fixed matrix
Expected decisive output (baseline, stock kernel)
Kernel column shows the bug β decimal inputs in (INT64_MAX, UINT64_MAX]
stored as signed INT64_MAX with is_unsigned=0:
DF2230|K|in=9223372036854775808|β¦|is_unsigned=0|signed=9223372036854775807 (0x7fffffffffffffff)|β¦ DF2230|K|in=10000000000000000000|β¦|is_unsigned=0|signed=9223372036854775807 (0x7fffffffffffffff)|β¦ DF2230|K|in=18446744073709551615|β¦|is_unsigned=0|signed=9223372036854775807 (0x7fffffffffffffff)|β¦ DF2230|K|in=0xffffffffffffffff|β¦|is_unsigned=1|β¦unsigned=18446744073709551615β¦ # hex correct
while the userland build of the SAME source gives:
DF2230|U|in=9223372036854775808|β¦|is_unsigned=1|β¦unsigned=9223372036854775808 (0x8000000000000000)|β¦ DF2230|UU|in=9223372036854775808|signed_rv=0|errno=34|β¦ # ERANGE check is live in userland
On the FIXED kernel, every case in (INT64_MAX, UINT64_MAX] returns the correct
unsigned value and -9223372036854775809 is rejected β byte-identical to the
userland column; boundary/negative/small/hex cases unchanged from baseline.
DF-2230 β VERDICT
Status: reproduced (baseline, stock kernel) β fix validated (fixed kernel matches userland). Impact: none in the memory-safety sense β this is a wrong-value / wrong-signedness logic bug plus a kernel-userland desync.
1. Rig
libprop is compiled into the stock DragonFly kernel (sys/conf/files:2243-2255,
libprop/prop_number.c standard), and prop_array_internalize plus all
prop_number_* accessors are exported kernel symbols. The kernel-side rig is
therefore a KLD (kernel_side.c) that calls the REAL in-kernel parser path:
prop_array_internalize(xml) [real, exported] -> _prop_array_internalize() [real] -> _prop_number_internalize() [real β the function under audit] -> _prop_number_internalize_signed() [real β strtoq, ERANGE check compiled out]
No proplib code was copied into the rig. Code identity is proven by build.sh:
sha256 of the guest /usr/src/sys/libprop/prop_number.c must equal the audited
tree hash 89da58dad14f7d7b60964666bdda9d60c16755505d9cc499fbc4e3f4573c9117
(gate passed; see build.log line 1).
The userland counterpart runs the same XML through prop_array_internalize
from stock -lprop (built from the same sys/libprop source via
.PATH: ${.CURDIR}/../../sys/libprop in lib/libprop/Makefile), and a unit
binary that #includes the audited prop_number.c compiled in userland mode
(-D_LIBPROP -D_REENTRANT -I/usr/src/sys) so the static parsers and their
errno behavior are directly observable.
Environment: DragonFly 6.5-DEVELOPMENT stock kernel
#0: Thu Jul 2 06:02:54 UTC 2026, gcc 8.3, guest up/clean before and after
(vm.sh reset with-src performed at the end).
2. Root cause, line-accurate
sys/libprop/prop_number.c:537-539βerrno = 0is inside#ifndef _KERNEL.sys/libprop/prop_number.c:540β in kernel,strtollis#defined tostrtoq(line 38).sys/libkern/strtoq.c(end of loop) saturates on overflow:if (any < 0) { acc = neg ? QUAD_MIN : QUAD_MAX; }β positive overflow returns INT64_MAX with NO error indication.sys/libprop/prop_number.c:541-545β the ERANGE rejection is inside#ifndef _KERNEL(XXX can't check for ERANGE in the kernel), so in the kernel build_prop_number_internalize_signedALWAYS returns true, having stored the saturated value.sys/libprop/prop_number.c:583-587β the decimal branch isif (_prop_number_internalize_signed(...) == false && _prop_number_internalize_unsigned(...) == false). Because the left operand can never be false in kernel, the short-circuit&&makes the unsigned fallback DEAD CODE in the kernel build. Result: for a decimal<integer>in (INT64_MAX, UINT64_MAX] the kernel stores a SIGNED number with value INT64_MAX β wrong on both axes (value ANDpnv_is_unsigned).
Userland builds compile the errno check in, so the identical XML is either parsed to the correct unsigned value (fallback runs) or rejected outright β the desync.
3. Baseline results (run.log, stock kernel #0)
| input | kernel (real path) | userland (-lprop) | unit, userland build |
|---|---|---|---|
| 0 / 42 / -1 | signed, correct | signed, correct | rv=1 errno=0 |
| -9223372036854775808 (INT64_MIN) | signed INT64_MIN | signed INT64_MIN | rv=1 errno=0 |
| -9223372036854775809 | signed INT64_MIN (saturated) | array rejected (array=0) | rv=0 errno=34 |
| 9223372036854775807 (INT64_MAX) | signed INT64_MAX | signed INT64_MAX | rv=1 errno=0 |
| 9223372036854775808 | signed INT64_MAX, is_unsigned=0 | unsigned 9223372036854775808, is_unsigned=1 | rv=0 errno=34 |
| 10000000000000000000 | signed INT64_MAX, is_unsigned=0 | unsigned 10^19, is_unsigned=1 | rv=0 errno=34 |
| 18446744073709551615 | signed INT64_MAX, is_unsigned=0 | unsigned UINT64_MAX, is_unsigned=1 | rv=0 errno=34 |
| 0Xffffffffffffffff (capital X routes via signed path) | signed INT64_MAX, is_unsigned=0 | unsigned UINT64_MAX, is_unsigned=1 | rv=0 errno=34 |
| 0x7fffffffffffffff / 0x8000000000000000 / 0xffffffffffffffff | unsigned, correct | unsigned, correct | (hex, unsigned branch) |
| 99999999999999999999 (> UINT64_MAX) | signed INT64_MAX | array rejected | rv=0 errno=34 |
Every clause of the finding is confirmed:
(a) kernel stores value == INT64_MAX with pnv_is_unsigned == 0 for the
> INT64_MAX decimals (wrong on both axes) β confirmed for
9223372036854775808, 10^19, 18446744073709551615 (and 0X⦠hex-via-signed);
(b) the SAME XML in the userland build of the same source either stores the
correct unsigned value (ERANGE -> fallback) or rejects the document
(> UINT64_MAX, or below INT64_MIN) β the desync, in both sub-cases;
(c) lowercase hex 0x⦠>= INT64_MAX parses correctly to the full unsigned
value in BOTH builds β the defect is decimal(+-routed)-only, exactly as
claimed. ("Hex path unaffected" holds for the lowercase-0x dispatch;
capital-0X is routed into the signed path by the poic_cp[1]=='x'
check at prop_number.c:580 and IS affected β an additional instance of
the same dead-fallback bug, noted for the fix.)
The unit matrix (DF2230|UU| lines) shows errno==34 (ERANGE) with signed_rv=0 precisely on the overflow inputs, proving the errno rejection is live in the userland compile of this file and compiled-out in the kernel compile of the same bytes.
4. Privilege-escalation assessment (explicit close-out)
This is NOT a memory-safety bug and no uid=0 chain exists:
- The saturated value is an in-range int64_t stored in the fixed-size
struct _prop_number_valueunion/bitfield (prop_number.c:52-61). No out-of-bounds write, no overflow of any buffer, no type-size confusion (the union is 8 bytes either way; the bitfield write is 1 bit). - The wrong
pnv_is_unsignedflag only affects later comparisons (_prop_number_compare_values,prop_number_equals_*) and the externalize format (decimal vs hex) β logic-level effects. - No kernel memory is disclosed (no leak), nothing panics (run.log is a clean RUN-OK), and the value cannot index or size a copy anywhere inside libprop itself.
Impact ceiling: in-kernel proplib CONSUMERS (plist-driven ioctls / sysctls) that validate "value <= INT64_MAX"-style invariants can be handed INT64_MAX instead of a rejected document, or see kernel/userland disagree on the same plist (externalize round-trip changes value and format). That is a correctness/logic desync (Medium), not memory corruption, not DoS, not an info leak. Privesc: none via this primitive on its own.
5. Fix (fix.diff) and validation
One hunk in _prop_number_internalize_signed: add an #else branch for the
kernel that detects strtoq saturation WITHOUT errno by re-parsing the same
text with strtouq():
- result == INT64_MAX and strtouq(text) > INT64_MAX -> saturated positive;
- result == INT64_MIN and text[0]=='-' and strtouq(text+1) > 2^63 -> saturated negative;
returning false in those cases lets the existing decimal unsigned fallback (prop_number.c:584-585) actually run β matching the userland semantics. A genuine INT64_MAX / INT64_MIN re-parses identically and is kept.
Validation (fix_run.log / run.fixed.log, kernel #1 Sat Oct 17 09:40:41 UTC
2026, built with the stock make nativekernel KERNCONF=X86_64_GENERIC,
full build in fix_build.log):
| input | baseline kernel | fixed kernel | userland | verdict |
|---|---|---|---|---|
| 0/42/-1 | signed ok | signed ok (identical) | signed ok | unchanged |
| -9223372036854775808 | signed INT64_MIN | signed INT64_MIN (identical) | signed INT64_MIN | unchanged |
| -9223372036854775809 | signed INT64_MIN (bug) | array rejected | array rejected | fixed |
| 9223372036854775807 | signed INT64_MAX | signed INT64_MAX (identical) | signed INT64_MAX | unchanged |
| 9223372036854775808 | signed INT64_MAX (bug) | unsigned 0x8000000000000000 | unsigned 0x8000000000000000 | fixed |
| 10000000000000000000 | signed INT64_MAX (bug) | unsigned 10^19 | unsigned 10^19 | fixed |
| 18446744073709551615 | signed INT64_MAX (bug) | unsigned UINT64_MAX | unsigned UINT64_MAX | fixed |
| 0Xffffffffffffffff | signed INT64_MAX (bug) | unsigned UINT64_MAX | unsigned UINT64_MAX | fixed |
| 0x⦠(3 hex cases) | unsigned ok | unsigned ok (identical) | unsigned ok | unchanged |
The fixed kernel column is now IDENTICAL to the userland column for every input up to UINT64_MAX, and all boundary/negative/small/hex rows are byte-identical to the baseline run (no regression).
Residual (documented, out of the finding domain)
Decimal > UINT64_MAX (e.g. 99999999999999999999) still saturates in the
kernel β now to UNSIGNED UINT64_MAX via the fallback (baseline: signed
INT64_MAX) β while userland rejects it (strtoull ERANGE, prop_number.c:520
is also #ifndef _KERNEL). The same > UINT64_MAX divergence exists today
for oversized lowercase hex (e.g. 0x1ffffffffffffffff) in both builds,
before and after this fix. Closing that would require errno-free
saturation detection in _prop_number_internalize_unsigned as well
(strtouq cannot distinguish a genuine UINT64_MAX from a saturated one
without comparing the digit string); noted as a follow-up hardening item,
not part of DF-2230 as filed.
6. Artifacts
run.logβ baseline (bug) matrix, full untrimmed.run.fixed.log/fix_run.logβ fixed-kernel matrix, full untrimmed.build.logβ rig build, sha256 gate at line 1.fix_build.logβ full fixed-kernel build (37,763 lines, untrimmed).env.txtβ guest uname/compiler/kldstat.fix.diffβ the one-hunk fix (git-applyable; dry-run verified against a pristine copy before use; never applied to the read-only audit tree).
Guest left CLEAN: vm.sh reset with-src after validation; post-reset
checks confirmed stock kernel #0 Thu Jul 2, pristine source sha256
89da58daβ¦, rig directory gone.
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
fixedfix.diff applied inside the guest /usr/src copy, kernel rebuilt with make nativekernel KERNCONF=X86_64_GENERIC (full log fix_build.log) and rebooted; the exact baseline matrix re-run (fix_run.log) shows every decimal in (INT64_MAX, UINT64_MAX] now stored as the correct unsigned value, -9223372036854775809 now rejected, capital-0X hex now unsigned, and boundary, negative, small, and lowercase-hex cases byte-identical to baseline; the kernel column equals the userland column for all inputs up to UINT64_MAX. No regression, bad behavior gone.
fix_run.log rows DF2230|K| for 9223372036854775808 (is_unsigned=1 unsigned=9223372036854775808), 10000000000000000000 (is_unsigned=1 unsigned=10000000000000000000), 18446744073709551615 and 0Xffffffffffffffff (is_unsigned=1 unsigned=18446744073709551615), and -9223372036854775809 (array=0 rejected); all other DF2230|K| rows identical to run.log baseline; fix_build.log ends with NATIVEKERNEL-INSTALL-OK.
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log (baseline): DF2230|K| rows for 9223372036854775808, 10000000000000000000, 18446744073709551615, 0Xffffffffffffffff all show is_unsigned=0 signed=9223372036854775807 while the DF2230|U| rows for the same inputs show is_unsigned=1 with the exact unsigned values; DF2230|UU| rows show signed_rv=0 errno=34 exactly on those inputs (ERANGE check live in userland, dead in kernel). fix_run.log: same DF2230|K| rows now match userland; -9223372036854775809 row changed from stored INT64_MIN to rejected. build.log line 1 is the sha256 gate (89da58dad14f7d7b60964666bdda9d60c16755505d9cc499fbc4e3f4573c9117). fix_build.log is the full untrimmed fixed-kernel build; env.txt has the guest uname.
PoC changes
No evidence pack existed (created from scratch). Dropped the suggested verbatim-copy KLD approach in favor of a strictly stronger rig: libprop is compiled into the stock kernel and prop_array_internalize plus the prop_number accessors are exported symbols, so the KLD drives the real in-kernel object code directly; source identity is enforced by a sha256 gate in build.sh instead of recompiling copied function bodies. Fixed kernel printf -> kprintf for the DragonFly kernel namespace.
Verified recommended fix
In _prop_number_internalize_signed add an #else (kernel) branch that detects strtoq saturation without errno by re-parsing with strtouq (INT64_MAX + strtouq(text)>INT64_MAX, or INT64_MIN + leading minus + strtouq(text+1)>2^63, return false so the existing decimal unsigned fallback runs) - see fix.diff.
Verdict
Reproduced on the stock DragonFly 6.5-DEVELOPMENT kernel through the real in-kernel parser path (KLD calling prop_array_internalize, sha256-gated to the audited source). For decimal plist integers in (INT64_MAX, UINT64_MAX] the kernel stores a SIGNED number with saturated value INT64_MAX and pnv_is_unsigned=0 - wrong on both axes - because the ERANGE rejection in _prop_number_internalize_signed (prop_number.c:541-545) is compiled out under _KERNEL while kernel strtoq (sys/libkern/strtoq.c) saturates silently, which makes the short-circuit && at prop_number.c:584 keep the unsigned fallback permanently dead. The userland build of the identical source file either stores the correct unsigned value (ERANGE -> fallback) or rejects the document; errno=34/signed_rv=0 shown per case by the unit rig. Hex 0x values >= INT64_MAX are correct in both builds, proving the defect is decimal-only. Not a memory-safety bug: the saturated value stays inside the fixed 8-byte union and 1-bit flag; no OOB, UAF, leak, or panic (clean RUN-OK). Impact ceiling is wrong-value logic and kernel/userland plist desync (Medium as filed); no uid=0 chain exists via this primitive. fix.diff (errno-free saturation detection by strtouq re-parse, letting the existing unsigned fallback run) rebuilt and re-tested on the guest: fixed kernel matches userland for every input up to UINT64_MAX and is byte-identical to baseline for boundary, negative, small, and hex cases.
No comments yet.