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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2230 Β· 17 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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 = 0 is inside #ifndef _KERNEL.
  • sys/libprop/prop_number.c:540 β€” in kernel, strtoll is #defined to strtoq (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_signed ALWAYS returns true, having stored the saturated value.
  • sys/libprop/prop_number.c:583-587 β€” the decimal branch is if (_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 AND pnv_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_value union/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_unsigned flag 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Oct 17 09:40:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.