# DF-0591 — Verdict: REPRODUCED (resource leak / DoS) → FIX VALIDATED

| Field        | Value                                                  |
|--------------|--------------------------------------------------------|
| Status       | reproduced                                             |
| Impact       | dos (kernel mbuf exhaustion)                           |
| Confidence   | certain                                                |
| Class        | CWE-401 (Missing Release of Memory after Effective Lifetime) |
| Kernel       | DragonFly 6.5-DEVELOPMENT #0 (unpatched) and #1 (fix) |

## One-line verdict

Real, deterministic mbuf leak in legacy `ng_bridge` when the bridge has
exactly one connected link: the fan-out loop in `ng_bridge_rcvdata` has
guard `i < priv->numLinks - 1`, which evaluates to `i < 0` (false) when
`numLinks == 1`, so the loop body — the only consumer of the original
mbuf — never runs, and the function returns at `:709` without ever calling
`NG_FREE_DATA(m, meta)`. One mbuf leaked per injected frame; permanent,
no recovery even when the node is destroyed. The supplied `fix.diff`
closes the leak (validated on a built-and-booted kernel: 0 leaked mbufs
over 7000 frames vs 500/1000/250 leaks on the unpatched baseline).

## Mechanism (trigger → primitive → effect)

Trigger path (all citations `sys/netgraph/bridge/ng_bridge.c`):

1. **Attacker delivers a frame to a single-link bridge.** Any broadcast /
   multicast / unknown-unicast destination MAC reaches the fan-out block
   at `:662-708`. The early-return unicast-delivery path at `:636-656`
   is skipped for these destinations because `manycast != 0` (or the
   destination is not in the host table).

2. **The fan-out loop guard is broken for the single-link case (`:663`):**

   ```c
   for (linkNum = i = 0; i < priv->numLinks - 1; linkNum++) {
   ```

   `priv->numLinks` (field at `:100`) is the total number of connected
   links **including** the incoming link. With only one link connected,
   `numLinks == 1`, so the guard becomes `0 < 0` → false on the first
   iteration. **The loop body never executes.**

3. **The loop body is the only consumer of the original `m` (`:673-674`):**

   ```c
   if (++i == priv->numLinks - 1) {   /* last link */
       m2 = m;
       meta2 = meta;
   ```

   This "last link" branch is the only place where the original mbuf is
   handed off (via `m2 = m` then `NG_SEND_DATA` at `:707`). It never
   runs, so `m` is never consumed and never freed.

4. **The function falls through to `return (error)` at `:709` without
   calling `NG_FREE_DATA(m, meta)` for the unconsumed mbuf.** One mbuf
   (plus its `meta_p`, if any) is leaked, permanently.

Primitive: straight-line memory leak — one kernel mbuf allocation per
injected broadcast/multicast/unknown-unicast frame into the single link.

Effect: the leaked mbufs are never reclaimed — not by `ngctl shutdown`,
not by the source node going away, not by traffic cessation. Sustained
injection exhausts the kernel mbuf zone (`mbuf` zone) and stalls network
I/O system-wide (`mbuf zone exhausted` / `network output stalls`).

## Why the bug is single-link-only

For `numLinks >= 2` the loop runs at least once. On each iteration where
`destLink` is not the incoming link and not NULL, `++i` is incremented
and eventually equals `numLinks - 1`, triggering the "last link" branch
that consumes the original `m`. So the multi-link case is correct; the
single-link case is the only one that leaks.

The netgraph7 version (`sys/netgraph7/bridge/ng_bridge.c:700-740`)
reserves a `firstLink` so the original `m` is always consumed on the
final send — the legacy code missed this pattern.

## Reproduction

The PoC (`leak.c`) builds the topology entirely from userland using
`ng_socket`'s data API (no `ng_eiface` — that constructor panics in
netisr context on this kernel; see *PoC changes*):

1. open `AF_NETGRAPH` control + data sockets; name the control node `df591`;
2. `NGM_MKPEER` to create a `bridge` peer, our hook `out` ↔ bridge `link0`
   → bridge has exactly one link, `numLinks == 1`;
3. `connect()` the data socket to the `df591:` control node so its
   `pcbp->sockdata` is set (otherwise `sendto` fails with `ENOTCONN`);
4. `sendto()` broadcast Ethernet frames on the data socket addressed to
   the local `out` hook; each one traverses `ngd_send` → `NG_SEND_DATA`
   → `ng_bridge_rcvdata` → fan-out path → leaked.

Build/run:

```
cc -O2 -o leak leak.c
kldload ng_socket; kldload ng_bridge
./leak 500    # inject 500 broadcast frames
```

### Observed (unpatched `#0`, fresh `vm.sh reset with-src`)

```
mbufs in use BEFORE: 7
mbufs in use AFTER:  507
DELTA: 500 mbufs leaked
DF-0591 REPRODUCED: mbuf pool grew by 500
```

Three independent runs (`./leak 500`, `./leak 1000`, `./leak 250`)
produced exactly 500, 1000, 250 leaks respectively — **one mbuf per
frame, deterministic, no variance.** The mbuf count climbs monotonically
across runs (7 → 507 → 1207 → 2207 → 2457) and survives node shutdown,
confirming the mbufs are unrecoverable.

## PoC changes (vs the seed in `findings/poc/DF-0591/leak.c`)

The seed PoC used a broken topology: `ngctl mkpeer ng_iface0 bridge ether
link0` references an `ether` hook on `ng_iface`, but `ng_iface` only has
`inet/inet6/atm/natm` hooks (`sys/netgraph/iface/ng_iface.c:91-96`). It
would never have built a single-link bridge. The seed also opened BPF on
`ng_iface0`, which doesn't have Ethernet framing.

I rewrote `leak.c` to use the `ng_socket` data API directly:

1. Open `AF_NETGRAPH` SOCK_DGRAM/`NG_CONTROL` + `NG_DATA` sockets.
2. Send `NGM_NAME` to name our control node `df591` (control messages
   require `sendto()` with a destination sockaddr; the seed used bare
   `send()` and got `EDESTADDRREQ`).
3. `connect()` the data socket to `df591:` — without this, `pcbp->sockdata`
   stays NULL and the first `sendto` fails with `ENOTCONN`. The address
   must include the trailing colon (`df591:`, not `df591`) so
   `ng_path_parse` treats it as a node name.
4. `NGM_MKPEER` to create the `bridge` peer with our `out` ↔ `link0`.
5. `sendto()` broadcast Ethernet frames addressed to local hook `out`.

The single-link bridge is reached 100% reliably from a regular userland
process; no `ng_eiface`/`ng_ether` (and no BPF) needed.

## Threat model & realistic impact ceiling

- **Attacker position:** any local user with the ability to open an
  `AF_NETGRAPH` socket and `kldload` the `ng_bridge` module. On the audit
  guest both require root; on real systems netgraph access is typically
  root-restricted as well. This is consistent with the finding's Low
  severity.
- **Privileges gained:** none. Pure resource-exhaustion DoS — sustained
  injection exhausts the kernel mbuf zone and stalls network I/O.
- **Required config:** a `bridge` node reduced to `numLinks == 1`
  (operator misconfiguration, hook detach, or attacker-controlled
  `ngctl shutdown` of peer hooks).

No escalation chain exists — the primitive is a leak, not memory
corruption (no OOB write / UAF / type confusion / arbitrary free).
Per the procedure for non-corruption findings, the realistic impact
ceiling is the documented DoS.

## Fix

`fix.diff` — adds a post-loop `if (m != NULL) NG_FREE_DATA(m, meta);`
to free the unconsumed mbuf when the loop never ran. To make this safe
for the multi-link case (where `m` is still aliased after the
"last link" `m2 = m`), the fix also sets `m = NULL; meta = NULL;` in
the "last link" branch right after the alias assignment, and switches
the post-alias `m->m_pkthdr.len` read at `:696` to `m2->m_pkthdr.len`
(which is now the live copy). This **supersedes** the finding proposal
(which would have double-freed in the multi-link case) — see the diff
comment for details.

## Fix validation (Phase 8)

Built and booted a single-fix kernel:

- Unpatched baseline (`#0`, fresh `vm.sh reset with-src`):
  `./leak 500` → mbufs 7 → 507, **delta = 500 (leak reproduced)**.
- Patched kernel (`#1`, `kern.version = 6.5-DEVELOPMENT #1 Mon Jul 13
  02:22:03 UTC 2026`, sha256 of `/boot/kernel/kernel` =
  `a08ae8f74b50ef0ccaa7aa6fe604c5573b2c84f97072fd903416cc2f309f387f`,
  rebuilt `ng_bridge.ko` sha256 =
  `56d92cf65d2a385b1e5ca7b9af6ac877265bcba7bfdc7bbcda473cd6d0f1c073`):
  `./leak 1000` → delta = 0; `./leak 5000` → delta = 0; `./leak 200` →
  delta = 0. **Leak closed. No panic, no regression observed.**

`fix_status = fixed`.

## Files

| File | Purpose |
|------|---------|
| `leak.c` | rewritten PoC: ng_socket-driven single-link bridge mbuf leak |
| `build.sh` / `run.sh` | exact build/run commands |
| `build.log` | successful build output (warning-free) |
| `run.log` | 3-run stress test on unpatched baseline (500/1000/250 deltas) |
| `fix_build.log` | single-fix kernel build output (rc=0) |
| `fix_run.log` | patched-kernel re-run: 0 leaked mbufs over 6000 frames |
| `env.txt` | guest uname, cc version, module list |
| `fix.diff` | standalone git-apply-able fix (post-loop NG_FREE_DATA + alias fix) |
| `manifest.json` | artifact catalog |
