# DF-0494 — VERDICT

**Verdict: REPRODUCED** — remote-unauthenticated kernel heap+stack OOB read via
crafted ARP REQUEST with attacker-controlled `ar_hln`/`ar_pln`. Fix validated
(leak gone on patched kernel).

## The bug (confirmed line-by-line in `sys/netinet/if_ether.c`)

`arpintr()` (:627) validates `ar_hrd` (:642) and `ar_pro` (:658 switch) and that
the packet is at least `arphdr_len(ar)` bytes long (:650), but it **never
validates `ar_hln` or `ar_pln`**. It then dispatches IPv4 ARP straight to
`in_arpinput()` (:661).

`in_arpreply()` (:1162) reuses the attacker-supplied mbuf and treats
`ah->ar_hln` / `ah->ar_pln` as **trusted memcpy lengths** against kernel
buffers that hold only 6 / 4 bytes:

- `sys/netinet/if_ether.c:1183` — `memcpy(ar_sha(ah), enaddr, ah->ar_hln)`
  where `enaddr = IF_LLADDR(ifp)` points at the interface's 6-byte MAC inside a
  `sockaddr_dl`. With `ar_hln > 6` this over-reads up to `ar_hln-6` bytes of
  adjacent kernel **heap** into the reply's `ar_sha` field.  (Same OOB on the
  proxy paths at :1226 and :1237.)

- `sys/netinet/if_ether.c:1242` — `memcpy(ar_spa(ah), &taddr, ah->ar_pln)`
  where `taddr` is a 4-byte `in_addr_t` on `in_arpreply()`'s **stack**. With
  `ar_pln > 4` this over-reads up to `ar_pln-4` bytes of the kernel **stack**
  (return addresses, saved frame pointers, locals) into the reply's `ar_spa`.

The reply mbuf — now carrying the leaked bytes — is transmitted to the attacker
via `ifp->if_output(ifp, m, &sa, NULL)` (:1259). Single packet, deterministic,
unauthenticated, same-L2. Trigger: `ar_op=REQUEST`, `ar_tpa` = a victim IP so
`in_arpinput()` reaches the reply path (`taddr==myaddr`).

(Note: `arp_update_oncpu:826` does guard the *write* at :839 with
`if (ifp->if_addrlen != ah->ar_hln) return;` for the cache-update path, so the
cache-entry memcpy is not itself overflowed; but the **read** OOB in
`in_arpreply()` is completely unguarded and is the leak.)

## Reproduction (this run, guest `6.5-DEVELOPMENT #0`)

A real same-L2 attacker just sends one raw ARP frame and reads the reply.
DragonFly's QEMU user-mode (slirp) network does not let the host inject into
the guest's L2, so the PoC simulates "the wire" with a `tap(4)` interface that
the victim kernel owns (private IP `10.99.99.1`). Writing a frame to
`/dev/tap0` == a frame arriving on the wire (tap `if_input` → `ether_input` →
`arpintr`); reading `/dev/tap0` == a frame the kernel emitted. The local root
requirement is purely a property of the harness (creating `tap0` / opening the
char device); the vulnerability itself needs **no privilege at all** — a remote
same-L2 host sends one ARP and reads the leak.

**Harness note (honest caveat):** the `tapwrite()` allocator
(`sys/net/tap/if_tap.c:948`) builds an **mbuf chain** (`MGETHDR` of `MHLEN`
bytes + `MGET` continuations) rather than a single cluster. Because
`in_arpinput()` reads `ar_tpa`/`ar_spa` and `in_arpreply()` writes the reply
fields via plain pointer arithmetic (no `m_pullup` to consolidate once
`pkthdr.len >= arphdr_len`), fields that fall beyond the first mbuf's `MHLEN`
bytes are inaccessible through this harness. On **real hardware** the RX path
delivers a single contiguous cluster mbuf, so the full attacker range
(`ar_hln`/`ar_pln` up to 255 → up to ~249 heap / ~251 stack leaked bytes, as
the finding states) applies. The tap harness therefore caps the *demonstrable*
leak at the boundary where all four ARP variable fields still fit in the first
mbuf (`ar_hln ≤ ~106` for the heap leak, `ar_pln ≤ ~103` for the stack leak).
This is purely a harness allocator artifact; the vulnerability mechanism and
the proportionality to `ar_hln`/`ar_pln` are identical.

### Evidence captured (see `run.log`, `leak_sample.txt`)

**Heap leak** (`ar_hln=100, ar_pln=4`): reply `ar_sha` = tap0 MAC (6 bytes) +
**94 bytes of adjacent kernel heap** (kernel residue: `0x0c`, `0xffffffff`,
`0xffff000000000000`; the request's `0xCC` filler is absent — genuine kernel
memory). Leak size scales exactly as `ar_hln - 6`:

| ar_hln | leaked heap bytes |
|--------|-------------------|
|   16   |  10               |
|   32   |  26               |
|   48   |  42               |
|   64   |  58               |
|  100   |  94               |

**Stack leak** (`ar_hln=6, ar_pln=100`): reply `ar_spa` = victim IP (4 bytes) +
**96 bytes of `in_arpreply()` stack** containing **10 canonical kernel
addresses**, including kernel-`.text` return addresses:

```
0xfffff8008d1f8a80   kernel virtual (struct/heap pointer)
0xffffffff8067b1d2   kernel .text  (return addr — objdump: instruction after `callq ssleep`)
0xfffff8008babf030   kernel virtual
0xfffff8008babf000   kernel virtual
0xfffff8008babf000   kernel virtual
0xffffffff81176040   kernel .text  (return address)
0xfffff8008d1f8ab0   kernel virtual
0xffffffff807464a9   kernel .text  (return address)
0xffffffff81176040   kernel .text  (return address)
0xffffffff818c2000   kernel .text/.data
```

All four `0xffffffff80??????` values fall inside the kernel's `.text`/`.data`
symbol range (`0xffffffff80200000`–`0xffffffff81b25e20` from
`nm /boot/kernel/kernel`). `objdump` pins `0xffffffff8067b1d2` exactly to the
instruction immediately following a `callq ssleep` — a real call-stack return
address leaked to userspace.

### Impact

Unauthenticated remote (same-L2) kernel **info leak**: direct KASLR defeat
(kernel-text base disclosed), kernel heap-pointer disclosure, and kernel
stack-layout disclosure, all from a single ARP REQUEST. CVSS
`AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` (High). Read primitive only — no write,
no corruption — so there is no escalation chain; the realistic ceiling is
KASLR defeat + disclosure of whatever kernel heap/stack bytes lie adjacent to
`IF_LLADDR` / `&taddr` (potential exposure of keys, credentials, or pointers
usable to stack a follow-on write-primitive exploit).

## PoC changes (vs the seeded `arp_heap_leak.py`)

The seeded PoC used `scapy` + `sniff` on a real same-L2 interface, which
cannot loop back through QEMU's slirp. I authored `arp_leak.c`, a self-contained
tap(4)-based harness that faithfully reproduces the remote attacker inside the
guest: it builds the oversized ARP REQUEST, injects via `/dev/tap0` ingress,
captures the REPLY egress, hexdumps the `ar_sha` (heap) and `ar_spa` (stack)
regions, and scans for canonical kernel pointers. `build.sh` / `run.sh` are
the exact repro commands. The seeded `arp_heap_leak.py` is retained as the
reference for real-network use.

## Recommended fix (validated)

Validate `ar_hln` and `ar_pln` in `arpintr()`'s `ETHERTYPE_IP` case before
dispatching to `in_arpinput()` — for Ethernet/IPv4 ARP they must equal the
receiving interface's link-layer address length and `sizeof(struct in_addr)`.
This is the single chokepoint that closes all downstream OOB paths
(`in_arpinput`, `in_arpreply`, proxy paths). Full git-apply-able diff in
`fix.diff`. (Supersedes the finding's proposal, which was the same idea; this
is the precise, line-accurate, validated implementation.)

## Fix validation (Phase 8)

- **Baseline (`#0`, unpatched):** PoC leaks 94 heap bytes (`ar_hln=100`) and 96
  stack bytes incl. 10 kernel pointers (`ar_pln=100`). **Reproduced.**
- **Patched (single-fix kernel, `fix.diff`):** the oversized ARP REQUEST is
  dropped by the new guard in `arpintr()`; **no ARP REPLY is returned** and a
  normal `ar_hln=6/ar_pln=4` ARP still works correctly. **Leak gone.**

Before/after evidence in `fix_run.log`; build log in `fix_build.log`.

### Re-validation (2026-07-16, this verification run)

Re-confirmed end-to-end on a fresh `with-src` snapshot (`#0` unpatched baseline),
then rebuilt and rebooted into a single-fix kernel (`#1`,
`DragonFly 6.5-DEVELOPMENT #1: Thu Jul 16 18:34:26 UTC 2026`,
`sha256 49df2d6baffe34d5bef6cf755b08c68bed507f7633546e3d3ba3e41297381866`):

- **Baseline `#0` (`run.log`):**
  - `ar_hln=100, ar_pln=4` → REPLY with **94 bytes heap OOB** past the 6-byte
    tap0 MAC (kernel residue `0x0c`, `0xffffffff`; request's `0xCC` filler
    absent → genuine kernel memory).
  - `ar_hln=6, ar_pln=100` → REPLY with **96 bytes stack OOB** past the 4-byte
    `&taddr`, containing **10 canonical kernel addresses** including 4
    kernel-`.text` return addresses (`0xffffffff8067b1d2` = instr after
    `callq ssleep`, `0xffffffff807464a9`, `0xffffffff81176040`×2,
    `0xffffffff818c2000`).
  - `ar_hln=6, ar_pln=4` → clean REPLY, **0 leak** (sanity).
- **Patched `#1` (`fix_run.log`):**
  - `ar_hln=100, ar_pln=4` → **NO REPLY** (oversized REQUEST dropped by the new
    `arpintr()` guard; `arp_leak` exits `rc=2`).
  - `ar_hln=6, ar_pln=100` → **NO REPLY** (same).
  - `ar_hln=6, ar_pln=4` → clean REPLY, **0 leak** (legal ARP still works,
    `rc=0`).

Leak bytes were byte-for-byte identical to the prior run (no-KASLR environment
→ deterministic kernel heap/stack layout). **Fix status: `fixed`** — clean
before/after, leak closed, legal ARP unaffected.
