# DF-0410 — Heap OOB read+write in `ng_encode_string` (netgraph7) — REAL primitive, DEAD CODE on default kernel, root-gated

## Verdict: REPRODUCED (primitive proven at harness + object level) — latent: opt-in netgraph7 + root-only trigger; NO runtime impact on the default GENERIC kernel; NO uid0 escalation (root→kernel only)

The bug is **real at the source level and confirmed in real kernel-compiled
code**, but it lives in **netgraph7** (DragonFly's opt-in parallel netgraph
stack), which is **not compiled into the default `X86_64_GENERIC` kernel**, and
its only trigger path is **root-gated** (`SYSCAP_RESTRICTEDROOT` on the netgraph
control socket). So on the audit guest — and on any default DragonFly install —
the vulnerable function does not exist in the running kernel, and even on an
opt-in netgraph7 system an attacker must already be root to reach it.

## Root cause (confirmed line-by-line in `sys/netgraph7/netgraph/ng_parse.c`)

`ng_sizedstring_unparse` (the `unparse` method of `ng_parse_sizedstring_type`,
reached via an `NGM_BINARY2ASCII` control message on a sizedstring-typed field
— e.g. `ng_pppoe`'s `data` field, `sys/netgraph7/pppoe/ng_pppoe.h:132`):

```c
919: ng_sizedstring_unparse(const struct ng_parse_type *type,
921:     const u_char *data, int *off, char *cbuf, int cbuflen)
922: {
923:   const char *const raw = (const char *)data + *off + 2;   /* bytes AFTER the 2-byte len prefix */
924:   const int slen = *((const u_int16_t *)(data + *off));    /* ATTACKER-CONTROLLED 16-bit length */
925:   char *const s = ng_encode_string(raw, slen);             /* passed straight through */
```

`ng_encode_string` (line 1825):

```c
1825: ng_encode_string(const char *raw, int slen)
1832:   cbuf = kmalloc(strlen(raw) * 4 + 3, ...);   /* alloc bounded by strlen(raw) — stops at first NUL */
1837:   for (i = 0; i < slen; i++, raw++) {          /* loop bounded by slen — independent of strlen */
```

**The mismatch:** the allocation is `strlen(raw)*4+3` but the loop runs `slen`
times. The attacker controls the 16-bit `slen` independently of the actual byte
content of `raw` (which can contain embedded NULs). If `slen > strlen(raw)`:

- **OOB READ** (info leak): the loop reads `slen` bytes starting at `raw`,
  walking past `raw`'s first NUL terminator into whatever follows in the
  containing slab allocation. Those bytes are encoded into `cbuf` and returned
  to userspace via the `NGM_BINARY2ASCII` reply → kernel heap info leak.
- **OOB WRITE** (heap overflow): the loop writes up to `slen*4+3` bytes into a
  buffer sized only `strlen(raw)*4+3`. Worst case: `raw[0]=='\0'` ⇒
  `strlen(raw)=0` ⇒ `alloc = 0*4+3 = 3 bytes`; `slen=65535` ⇒ up to
  `65535*4+3 = 262143` bytes written ⇒ ~262 KB heap overflow from a 3-byte
  allocation (smashes every following object in the kmalloc-4 bucket and well
  into neighbouring pages).

(The default v1 netgraph `ng_encode_string` at `sys/netgraph/netgraph/ng_parse.c:1632`
takes only `raw` — no `slen` — and bounds BOTH alloc and loop on `strlen(raw)`.
It is **not** vulnerable. The bug is specific to the netgraph7 variant.)

## Why it is not exploitable on this guest (two independent hard blockers)

### 1. Dead code on the default kernel (netgraph7 is opt-in)

`sys/conf/files` gates the file on `optional netgraph7` (`sys/conf/files:1720`),
and `sys/Makefile.modules` builds the v1 `sys/netgraph/` tree *unless*
`WANT_NETGRAPH7` is defined:

```make
.if defined(WANT_NETGRAPH7)
SUBDIR+=netgraph7
.else
SUBDIR+=netgraph
.endif
```

`WANT_NETGRAPH7` is **not** in `X86_64_GENERIC`, **not** in
`/usr/src/sys/config/`, and **not** in `/etc/make.conf` (verified on the guest).
`share/man/man5/make.conf.5:501` documents it as opt-in ("Set to build a newer,
experimental netgraph userland. It has to be accompanied by NETGRAPH7* options
in the kernel."). Confirmed on the running `6.5-DEVELOPMENT #0` guest:

- `nm /boot/kernel/kernel | grep -c ng_encode_string` ⇒ **0** (the function is
  entirely absent from the running kernel).
- `/boot/kernel/` contains only v1 modules (`netgraph.ko`, `ng_*.ko`); no
  netgraph7 modules are present.
- The v1 `ng_socket.ko` control socket uses the v1 (non-vulnerable) `ng_parse.c`.

This matches the dead-code pattern documented for DF-0601/DF-0602/DF-0759.

### 2. Root-only trigger (`SYSCAP_RESTRICTEDROOT`)

Even on an opt-in netgraph7 system, the only way to deliver an
`NGM_BINARY2ASCII` message to the `ng_sizedstring_unparse` path is through the
netgraph7 control socket, whose `ngc_attach` requires root:

```c
182:   if (caps_priv_check(ai->p_ucred, SYSCAP_RESTRICTEDROOT | __SYSCAP_NULLCRED))
183:   { error = EPERM; }      /* unprivileged users cannot even create the socket */
```

(`sys/netgraph7/socket/ng_socket.c:182`; the v1 socket has the same gate at
`sys/netgraph/socket/ng_socket.c:172`.) So the primitive is **root→kernel**.
Per the bright-line rule, root→kernel is game-over by definition: there is no
privilege boundary to cross, and a root attacker who has gone to the trouble of
building a `WANT_NETGRAPH7` kernel does not need `ng_encode_string` to escalate.

**Net: no uid0 escalation path exists** — the bug is (a) absent from the default
kernel and (b) reachable only from root. It is a **defense-in-depth / latent
hardening** defect: fix it so a future netgraph7 promotion does not ship a
trivially-triggerable heap-corruption primitive.

## How the primitive was proven (harness + real-kernel object disassembly)

Because the code is not in the running kernel, the primitive was proven by
transcribing `ng_encode_string` **verbatim** into a userspace harness
(`df0410_harness.c`) and driving it with the exact inputs
`ng_sizedstring_unparse` would supply: a 2-byte `u_int16_t` length prefix
followed by raw bytes, with `slen` set larger than `strlen(raw)`.

### Harness output (vulnerable build)

```
sized-string payload: strlen(raw)=0, attacker slen=20
VULNERABLE alloc (strlen*4+3) = 3 bytes
FIXED      alloc (slen*4+3)   = 83 bytes
loop iterations (slen)        = 20
ng_encode_string returned 25 bytes of encoded output:
  "\x001234567890123456789"
OOB READ: 23 bytes were encoded from BEYOND raw's NUL terminator (strlen(raw)=0 but 23 data bytes appear in output).
OOB WRITE: loop wrote 26 bytes into a 3-byte allocation => 23-byte HEAP OVERFLOW.
[no fix] Heap overflow CONFIRMED: alloc=3 < written=26.
```

The output `"\x001234567890123456789"` is the proof: `raw[0]` is NUL
(`strlen(raw)=0`) yet 23 data bytes (`\x00` + the 19 sentinel digits that
followed in the allocation) were encoded — i.e. read from **beyond** the NUL
terminator (the info leak) and written into a 3-byte buffer (the overflow).
With `slen=65535` the same input yields a ~262 KB overflow.

### Harness output (fixed build, `-DAPPLY_FIX`)

Same encoded output, but:

```
[APPLY_FIX] allocation now slen*4+3=83 >= 26 written => overflow GONE.
```

### Real-kernel object validation

To prove the fix is correct in genuine kernel context (not just the harness),
the **actual** `sys/netgraph7/netgraph/` module was built from in-guest
`/usr/src` both with and without the fix, with `-Werror`:

- **BASELINE_BUILD=OK** and **FIX_BUILD=OK** (both compile cleanly).
- `strlen` call-site count in `ng_parse.o`: **VULNERABLE=6 → FIXED=5** (exactly
  the one removed `strlen(raw)` call).
- VULNERABLE `ng_encode_string` prologue: `callq strlen` at `0x2056`, then
  `lea 0x3(,%rax,4),%rdi` (strlen result ×4 +3) as the kmalloc size.
- FIXED `ng_encode_string` prologue: **no `callq strlen`** before kmalloc;
  `lea 0x3(,%rsi,4),%edi` uses `slen` (`rsi`=2nd arg) directly as the size.

This is machine-level confirmation the fix replaces the strlen-bounded
allocation with an slen-bounded one.

## Fix

One-line change at `sys/netgraph7/netgraph/ng_parse.c:1832`: bound the
allocation on the loop count (`slen`) instead of `strlen(raw)`. `slen` is `int`
sourced from a `u_int16_t` (range 0–65535), so `slen*4+3` max = 262143 — a
valid `kmalloc` size with no integer overflow. The loop and read are then
in-bounds for all `slen`.

```diff
-	cbuf = kmalloc(strlen(raw) * 4 + 3, M_NETGRAPH_PARSE,
+	cbuf = kmalloc(slen * 4 + 3, M_NETGRAPH_PARSE,
 		       M_WAITOK | M_NULLOK);
```

See `fix.diff` (applies with `git apply -p1` / `patch -p1`; validated).

There is no `findings/DF-0410-*.md` recommended-fix proposal in this run (no
markdown seeded); this `fix.diff` is the authoritative verified fix.

## Fix validation

`fix_status: not_testable` — the running default kernel does not contain
`ng_encode_string` (it is in opt-in netgraph7), so a before/after runtime test
on the guest is not meaningful (the file is not in the kernel build, so patching
the source and rebuilding the GENERIC kernel changes nothing the PoC can reach).
What was validated, beyond `git apply --check`:

- the diff applies cleanly to in-guest `/usr/src` (`patch -p1`, hunk succeeded);
- the real netgraph7 module (the actual compilation unit containing the bug)
  builds with the fix under `-Werror`;
- disassembly of the fixed object confirms the strlen-based allocation is
  eliminated (6→5 strlen refs; `lea` now keyed on `slen` not the strlen return).

This is the strongest validation possible for a dead-code finding short of
building a custom `WANT_NETGRAPH7` kernel (which would itself be a non-default
build and is not warranted for a root-gated latent bug).

## Files

- `df0410_harness.c` — verbatim transcription of vulnerable `ng_encode_string`
  + harness proving OOB read+write; `-DAPPLY_FIX` toggles the corrected alloc.
- `build.sh` / `run.sh` — exact build/run commands.
- `harness_vuln.log`, `harness_fixed.log`, `run.log` — full harness output.
- `module_build.log` — real-kernel module build (baseline + fixed) +
  disassembly comparison.
- `env.txt` — guest environment (uname, cc, netgraph module inventory,
  WANT_NETGRAPH7 absence, `nm` count of ng_encode_string = 0).
- `fix.diff` — the git-apply-able one-line fix.
- `manifest.json` — artifact catalog.
