# DF-0449 — VERDICT

**Verdict: REPRODUCED** — heap buffer overflow confirmed; fix validated.

> **Re-validated 2026-07-16.** Fresh `vm.sh reset with-src` baseline (`#0`
> unpatched kernel, `netgraph.ko` sha256 `85938016…`), rebuilt the PoC, and
> re-confirmed the overflow (`arglen=2553 > 2000`, 501-byte heap write, guest
> survived). Then re-applied `fix.diff` to in-guest `/usr/src`, rebuilt
> `netgraph.ko` standalone (`make` in `sys/netgraph/netgraph/`, `rc=0`), got
> the **identical** patched-module hash `6e225a12…` (deterministic build),
> verified the new `cmp (%rbx),%eax; jg <ERANGE>` in the disassembly at offset
> `0x3b43`, installed it, rebooted, and re-ran 3× — every run returned
> `ERANGE` (`Result too large`), no overflow, guest healthy. The fix is
> deterministic and reproducible. (The PoC source was lightly edited to print
> a clean `ERANGE: bounds check fired (FIXED)` message on the fixed path so the
> "fixed" output is unambiguous; the trigger logic is unchanged.)

## Root cause

`ng_string_parse()` (`sys/netgraph/netgraph/ng_parse.c:704-720`) copies a
user-supplied decoded string into a caller-provided buffer without checking
that the string length fits the buffer:

```c
static int
ng_string_parse(const struct ng_parse_type *type,
    const char *s, int *off, const u_char *const start,
    u_char *const buf, int *buflen)
{
    char *sval;
    int len;

    if ((sval = ng_get_string_token(s, off, &len)) == NULL)
        return (EINVAL);
    *off += len;
    len = strlen(sval) + 1;
    bcopy(sval, buf, len);          /* ← line 716: NO check len <= *buflen */
    kfree(sval, M_NETGRAPH);
    *buflen = len;
    return (0);
}
```

Every sibling parse/getDefault function in this file checks `*buflen` before
its bcopy and returns `ERANGE`/`E2BIG` on overflow:
- `ng_int8_getDefault:385`, `ng_int16_getDefault:479`,
  `ng_int32_getDefault:573`, `ng_int64_getDefault:666`,
  `ng_string_getDefault:742` — all `if (*buflen < sizeof(...)) return (ERANGE)`
- `ng_fixedstring_parse:774` — `if (strlen(sval)+1 > fi->bufSize) return (E2BIG)`

`ng_string_parse` is the sole exception. This is a clear oversight.

## Trigger path (confirmed line-by-line)

1. **`socket(AF_NETGRAPH, SOCK_DGRAM, NG_CONTROL)`** — root-only
   (`ng_socket.c:172`: `caps_priv_check(..., SYSCAP_RESTRICTEDROOT)`).
2. **`sendto(csock, msg, ..., ".")`** → `ngc_send` (`ng_socket.c:201`) →
   `ng_send_msg` (`ng_base.c:1208`) → `ng_path2node` resolves "." to own node
   (`ng_base.c:1216`).
3. **`CALL_MSG_HANDLER`** (`ng_base.c:1188`): typecookie == `NGM_GENERIC_COOKIE`
   ⇒ `ng_generic_msg`.
4. **`case NGM_ASCII2BINARY`** (`ng_base.c:1578`): `bufSize = 2000` (hardcoded,
   line 1580). `NG_MKRESPONSE(rp, msg, sizeof(*binary)+bufSize, ...)` (line 1598)
   allocates `52+52+2000 = 2104` bytes. `binary->data` is the 2000-byte buffer.
5. **`bcopy(ascii, binary, sizeof(*ascii))`** (line 1606) copies the attacker's
   embedded message header — including `flags = NGF_RESP` — into `binary`.
6. **Command lookup** (line 1609-1624): `cmdstr = "textstatus"` matches the
   generic cmd whose `respType = &ng_parse_string_type` (`ng_base.c:292`).
7. **`argstype = (binary->header.flags & NGF_RESP) ? c->respType : c->mesgType`**
   (line 1631-1632) ⇒ `argstype = &ng_parse_string_type`.
8. **`ng_parse(argstype, ascii->data, &off, binary->data, &bufSize)`** (line 1636)
   ⇒ `ng_string_parse` ⇒ **`bcopy(sval, buf, 2501)` into 2000-byte `buf`**
   (line 716) ⇒ **501-byte heap overflow**.
9. **`*buflen = 2501`** (line 718) ⇒ `binary->header.arglen = 2501` (line 1644),
   `rp->header.arglen = 2553` (line 1645).
10. **`ship_msg`** (`ng_socket.c:737`): `msglen = 52+2553 = 2605`;
    `m_devget(msg, 2605, ...)` reads 2605 bytes from the 2104-byte `rp`
    allocation ⇒ **501-byte OOB read** of adjacent heap (secondary CWE-125).

## Evidence (unpatched #0 kernel)

```
[*] sending NGM_ASCII2BINARY: string=2500 A's, bufSize(buflen)=2000, expected overflow=501 bytes
[+] sendto returned 2607 bytes
[+] recvfrom returned 2605 bytes (response)
[*] response header.arglen = 2553 (alloc was 2000)
[!!] OVERFLOW CONFIRMED: arglen=2553 > 2000
```
Deterministic across 3 consecutive runs; guest survived all runs (the bcopy
itself is a raw memcpy, not INVARIANTS-guarded).

### Note on the "info leak"

The secondary OOB read (step 10) reads back the *same* bytes the bcopy
overflow wrote (both windows = `len - 2000` = 501 bytes). So it returns the
attacker's own 'A' bytes, **not** pre-existing kernel data. The leak is
technically a CWE-125 but is not a useful information disclosure. The
**primary** security impact is the **CWE-787 heap overflow** — 501
attacker-controlled bytes corrupting the adjacent slab object.

## Exploit chain / escalation analysis

The primitive is a **root → kernel heap write** of 501 attacker-controlled
bytes (full content control — the decoded string is arbitrary) into the slab
object adjacent to a `kmalloc(2104)` allocation (page-zone, since 2048 < 2104
≤ 4096).

**uid=0 escalation: NOT attempted — valid hard blocker (INVARIANTS).**

On this audit guest, **INVARIANTS is ENABLED** (KKASSERT/KASSERT are CPP macros
that expand to inline `panic()`, so `nm | grep KASSERT = 0` is a false negative;
proven by DF-0783's `_kfree` assertion panic and DF-0028's KKASSERT panic). A
slab-grooming exploitation chain (spray the 4K zone → overflow into a victim
object containing a function pointer / `ucred *` / refcount → convert to
arbitrary control) would require double-free or free-list manipulation, both of
which trip INVARIANTS assertions and panic the kernel before the chain can
complete. This is the same hard wall documented for DF-0783.

The overflow itself is **not** INVARIANTS-blocked (it is a `bcopy`, not a slab
operation), so the corruption reproduces cleanly and the guest survives — but
converting the corruption to `uid=0` via slab grooming is blocked. This is a
**valid hard blocker** per the procedure (INVARIANTS prevents the
grooming/free-list phase of any heap-corruption-to-priv-esc chain on this
guest).

**On a production kernel without INVARIANTS**, this 501-byte attacker-controlled
heap write would be a viable corruption primitive: spray the 4K slab zone with
victim objects containing function pointers (e.g., `struct file` fileops,
socket ops vectors), position one adjacent to the overflow target, overwrite
its function pointer, and redirect to userspace shellcode (no SMEP) or a forged
`ucred` (no SMAP). The root-gating limits this to root→kernel, but that is
still a meaningful kernel-integrity / sandbox-escape primitive.

## The fix

Add the missing bounds check before the bcopy, mirroring every sibling parse
function. The fix returns `ERANGE` (matching `ng_string_getDefault:742`,
`ng_int*_getDefault`) and frees `sval` first to avoid a memory leak:

```c
    len = strlen(sval) + 1;
+   if (len > *buflen) {
+       kfree(sval, M_NETGRAPH);
+       return (ERANGE);
+   }
    bcopy(sval, buf, len);
```

(`ng_fixedstring_parse:775` has the same missing-free on its `E2BIG` path — a
pre-existing minor leak we do not need to fix here, but worth noting.)

## Fix validation (Phase 8)

| | kernel / module | result |
|---|---|---|
| **Before** | unpatched `#0`, original `netgraph.ko` | **overflow**: `arglen=2553 > 2000`, 501-byte heap write + OOB read |
| **After** | same `#0` kernel, **patched** `netgraph.ko` (sha256 `6e225a…`) | **ERANGE**: `sendto failed: Result too large`, no overflow, guest alive |

The fix was validated by rebuilding only `netgraph.ko` (standalone module
build: `cd /usr/src/sys/netgraph/netgraph && make`), installing it to
`/boot/kernel/netgraph.ko`, and loading it on a fresh boot (no kernel reboot
needed — the module is not loaded until `kldload`). The fix is visible in the
disassembly of `ng_string_parse`: new `cmp (%rbx),%eax; jg <ERANGE_path>` at
offset `0x3b43`. The patched module returns `ERANGE` deterministically across 3
runs.

## Verdict

**REPRODUCED.** The heap buffer overflow in `ng_string_parse` is real,
confirmed by the inflated `arglen=2553` and the 501-byte overflow. The fix
(add `len > *buflen → ERANGE`) closes it completely, validated before/after on
the same `#0` kernel with only the `netgraph.ko` module swapped.
