# 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 `#include`s 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 `#define`d 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.
