β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0494

Remote unauthenticated kernel heap+stack memory disclosure via ARP reply using attacker-controlled ar_hln/ar_pln

Field Value
ID DF-0494
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CWE CWE-125 Out-of-bounds Read
File sys/netinet/if_ether.c
Lines 1182-1242
Area net (ARP)
Confidence certain
Discovered 2026-07-01
Reported pending

Summary

The ARP ingress parser (arpintr) validates ar_hrd and ar_pro but never validates ar_hln (hardware address length) or ar_pln (protocol address length). The ARP reply builder (in_arpreply) then uses these attacker-controlled byte values as memcpy lengths when copying from kernel-internal buffers that hold only 6 bytes (IF_LLADDR) or 4 bytes (&taddr on stack). The over-read bytes fill the ARP reply mbuf, which is transmitted back to the attacker. A single crafted ARP request from any host on the same L2 segment leaks up to ~249 bytes of kernel heap and ~251 bytes of kernel stack.

Root cause

Ingress validation gap (arpintr, lines 627-667):

ar_hrd = ntohs(ar->ar_hrd);
if (ar_hrd != ARPHRD_ETHER && ar_hrd != ARPHRD_IEEE802) {  // validated
    ...m_freem(m); return;
}
// ar_pro checked via switch(ETHERTYPE_IP) at line 658  // validated
// ar_hln: NEVER validated
// ar_pln: NEVER validated
if (m->m_pkthdr.len < arphdr_len(ar)) {                    // uses ar's hln/pln
    m = m_pullup(m, arphdr_len(ar));                        // just ensures pkt is padded
}

OOB in reply builder (in_arpreply, lines 1162-1242):

enaddr = (const uint8_t *)IF_LLADDR(ifp);   // points at 6 valid bytes (if_addrlen=6)
if (taddr == myaddr) {
    memcpy(ar_tha(ah), ar_sha(ah), ah->ar_hln);   // within-packet: OK
    memcpy(ar_sha(ah), enaddr, ah->ar_hln);        // HEAP OOB: reads ar_hln bytes from 6-byte enaddr
}
...
memcpy(ar_tpa(ah), ar_spa(ah), ah->ar_pln);        // within-packet: OK
memcpy(ar_spa(ah), &taddr, ah->ar_pln);            // STACK OOB: reads ar_pln bytes from 4-byte &taddr

When ar_hln=200, the memcpy at line 1183 copies 200 bytes starting at IF_LLADDR(ifp) β€” which resolves to LLADDR(sdl) = sdl_data + sdl_nlen, pointing at exactly sdl_alen (=6 for Ethernet) valid bytes inside a heap-allocated sockaddr_dl/ifaddr. The remaining 194 bytes are read from adjacent kernel heap.

When ar_pln=200, the memcpy at line 1242 copies 200 bytes starting at &taddr β€” a 4-byte in_addr_t on in_arpreply's stack frame. The remaining 196 bytes are read from adjacent stack.

The over-read bytes land in the ARP reply's SHA/SPA fields. The entire mbuf is then transmitted to the attacker via ifp->if_output.

Proxy paths are equally affected: lines 1226 (memcpy(ar_sha(ah), enaddr, ah->ar_hln)) and 1237 (memcpy(ar_sha(ah), LLADDR(sdl), ah->ar_hln)).

Threat model & preconditions

  • Attacker position: Remote unauthenticated, same L2 segment (adjacent network).
  • Privileges gained or impact: Kernel memory disclosure (heap + stack). KASLR bypass. Potential exposure of key material or other secrets in adjacent heap objects.
  • Required config or capabilities: Default kernel configuration. No special privileges, no authentication. Host must have an IPv4 address on the target interface (default).
  • Reachability: Attacker sends a single crafted ARP REQUEST:
  • Set ar_hln to a large value (e.g. 200) for heap leak, or ar_pln large for stack leak.
  • Set ar_op = ARPOP_REQUEST.
  • Set ar_tpa to the victim's IPv4 address.
  • Pad the frame to arphdr_len(ar_hln, ar_pln) bytes.
  • Victim receives, arpintr accepts (valid ar_hrd, valid ar_pro, packet long enough), dispatches to in_arpinput, which hits taddr == myaddr β†’ goto reply β†’ in_arpreply β†’ OOB memcpy β†’ ARP reply with leaked bytes transmitted to attacker.

Proof of concept

PoC source: findings/poc/DF-0494/arp_heap_leak.py

Build & run

# On attacker machine (same L2 segment as victim):
sudo python3 findings/poc/DF-0494/arp_heap_leak.py <victim-ip> <attacker-iface>

Expected output

[*] Sending crafted ARP request (ar_hln=200) to 192.168.1.10 on eth0
[*] Waiting for ARP reply...
[+] Got ARP reply (416 bytes)
[+] Reply SHA (bytes 8..207): 00:11:22:33:44:55 (victim MAC)
    bytes 14..207 contain leaked kernel heap data:
    00 00 00 00 00 00 00 00  a8 03 72 41 ff ff ff ff  ........Gr.A....
    e8 1f 40 82 ff ff ff ff  01 00 00 00 00 00 00 00  ..@.............
    ... (194 bytes of adjacent kernel heap)
[*] Leaked kernel heap contains potential pointer: 0xffffffff82401fe8

Impact

  • Deterministic, repeatable, single-packet kernel memory disclosure.
  • Leaks up to ~249 bytes of kernel heap adjacent to the interface sockaddr_dl per request (heap variant), and up to ~251 bytes of stack per request (stack variant via ar_pln).
  • KASLR bypass: leaked heap frequently contains kernel text/data pointers from adjacent allocations (function pointers, ifaddr back-pointers, etc.).
  • Key material exposure: if a crypto buffer or key cache allocation happens to be adjacent to the sockaddr_dl in the slab, its contents leak verbatim.
  • No crash, no authentication, no log entry β€” fully silent.
  • Every DragonFlyBSD host with an IPv4 interface is affected by default.
  • CVSS 3.1 base score: 8.1 (High) β€” adjacent network, no privileges, high confidentiality impact.

Validate ar_hln and ar_pln before any field-derived memcpy. The simplest single-point fix is in in_arpreply:

--- a/sys/netinet/if_ether.c
+++ b/sys/netinet/if_ether.c
@@ -1174,6 +1174,12 @@ in_arpreply(struct mbuf *m, in_addr_t taddr, in_addr_t myaddr)
        m_freem(m);
        return;
    }
+
+   if (ah->ar_hln != ifp->if_addrlen ||
+       ah->ar_pln != sizeof(struct in_addr)) {
+       m_freem(m);
+       return;
+   }
+
    enaddr = (const uint8_t *)IF_LLADDR(ifp);
    if (taddr == myaddr) {
        /* I am the target */

For defense-in-depth, also validate at ingress in arpintr (after the arphdr_len pullup) or in in_arpinput (after the req_len pullup), so no downstream consumer ever sees a bogus ar_hln/ar_pln.

References

  • RFC 826 β€” An Ethernet Address Resolution Protocol (ARP)
  • RFC 5227 β€” IPv4 Address Conflict Detection
  • FreeBSD in_arpinput() validates ar_hln == ifp->if_addrlen at ingress
  • NetBSD in_arpinput() validates ar_hln against ifp->if_addrlen
  • DragonFlyBSD sys/netinet/if_ether.c:1182-1242 β€” the vulnerable memcpy calls

Timeline

  • 2026-07-01 Discovered during automated audit.
  • 2026-07-01 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0494 Β· 14 files
FileTypeDescriptionSize
arp_leak.c trigger-source tap(4)-based oversized-ARP crafter + REPLY capture + heap/stack leak analyser 10.8 KB view raw
arp_heap_leak.py trigger-source seeded scapy PoC (reference for real same-L2 interface; not used in this guest run) 6.5 KB view raw
build.sh build-script cc -O2 -o arp_leak arp_leak.c 224 B view raw
run.sh run-script creates tap0 victim IP, injects crafted ARP, captures reply. args [ar_hln] [ar_pln] 768 B view raw
run.log run-log full untrimmed baseline reproduction (heap 94B + stack 96B + scaling) 4.5 KB view raw
leak_sample.txt leak-sample annotated raw leaked heap+stack bytes, 10 kernel pointers, scaling table, objdump pinning 4.3 KB view raw
env.txt environment uname, kern.version, cc, cpu, tap0 setup 411 B view raw
VERDICT.md verdict full narrative: mechanism, evidence, harness caveat, fix before/after 8.8 KB ↓ raw
README.md readme self-contained repro instructions + network setup explanation 4.2 KB ↓ raw
fix.diff suggested-fix git-apply-able: validate ar_hln/ar_pln in arpintr() ETHERTYPE_IP case 783 B view raw
fix_build.log build-log full patched-kernel nativekernel build output (rc=0) 5.6 MB ↓ download
fix_run.log run-log patched-kernel PoC run: oversized ARP dropped, legal ARP works 1.6 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme self-contained repro instructions + network setup explanation
↓ download raw

DF-0494 PoC: ARP ar_hln/ar_pln kernel heap+stack memory disclosure

Status: REPRODUCED + FIX VALIDATED. Remote-unauthenticated (same-L2) kernel OOB read via a crafted ARP REQUEST. Read primitive only (info leak / KASLR defeat); see VERDICT.md for the full analysis.

Vulnerability

arpintr() (sys/netinet/if_ether.c:627) validates ar_hrd and ar_pro and the packet length, but never validates ar_hln/ar_pln. in_arpreply() (:1162) then uses those attacker-controlled byte values as memcpy lengths against a 6-byte source (IF_LLADDR, the interface MAC) and a 4-byte source (&taddr, a stack in_addr_t), over-reading adjacent kernel heap and stack into the ARP REPLY mbuf, which is transmitted to the attacker.

Files

file purpose
arp_leak.c trigger: tap(4)-based oversized-ARP crafter + REPLY capture + leak analyser
arp_heap_leak.py seeded scapy PoC (reference for real-network use; needs same-L2 iface)
build.sh cc -O2 -o arp_leak arp_leak.c
run.sh sets up tap0 (victim IP), injects, captures. args: [ar_hln] [ar_pln]
run.log full untrimmed output of the decisive reproduction (heap + stack leaks)
leak_sample.txt annotated raw leaked bytes + kernel-pointer scan
env.txt guest uname, cc, cpu, tap0 setup
fix.diff git-apply-able fix (validate ar_hln/ar_pln in arpintr)
fix_build.log full patched-kernel build log
fix_run.log patched-kernel PoC run (leak gone)
VERDICT.md full narrative + before/after
manifest.json artifact catalog

Reproduce

The PoC simulates a same-L2 remote attacker inside the guest using tap0 as a private L2 island (QEMU user-mode/slirp doesn't let the host inject into the guest's L2). Root is needed only to create tap0 / open /dev/tap0; the bug itself needs no privilege β€” a real remote attacker just sends one ARP frame.

# on the DragonFly guest (as root):
cd poc/DF-0494
./build.sh                       # cc -O2 -o arp_leak arp_leak.c
./run.sh                         # default: ar_hln=200 ar_pln=200 (auto-clamped)
./run.sh 100 4                   # heap leak:  94 OOB bytes past 6-byte MAC
./run.sh 6 100                   # stack leak: 96 OOB bytes past 4-byte &taddr
./run.sh 6 4                     # sanity: a LEGAL arp -> clean reply, no leak

Expected (bug present, unpatched #0 kernel)

  • ./run.sh 100 4 β†’ ARP REPLY whose ar_sha = victim MAC + 94 bytes of kernel heap (kernel residue; the request's 0xCC filler is NOT echoed).
  • ./run.sh 6 100 β†’ ARP REPLY whose ar_spa = victim IP + 96 bytes of kernel stack containing ~10 canonical kernel pointers (kernel-.text return addresses 0xffffffff80??????, kernel-virtual 0xfffff800????????).
  • ./run.sh 6 4 β†’ clean 6-byte MAC / 4-byte IP REPLY, 0 OOB bytes (sanity).

Expected (FIXED kernel, fix.diff applied)

  • ./run.sh 100 4 and ./run.sh 6 100 β†’ NO ARP REPLY (the oversized REQUEST is dropped by the new arpintr() guard). ./run.sh 6 4 still works.

Network setup (why tap0)

QEMU user-mode (slirp) networking gives the guest vtnet0 behind NAT; the host cannot inject raw L2 frames into it. The PoC therefore creates a tap0 interface with a private IP (10.99.99.1), writes a crafted Ethernet/ARP frame to /dev/tap0 (== frame arriving on the wire β†’ ether_input β†’ arpintr), and reads the REPLY back from /dev/tap0 (== frame the kernel emitted). This exercises the exact kernel code path a remote same-L2 attacker would.

Harness caveat: the tapwrite() allocator builds an mbuf chain, so the demonstrable leak is capped where all four ARP variable fields fit in the first mbuf (ar_hln ≀ ~106, ar_pln ≀ ~103). On real hardware (single contiguous RX cluster mbuf) the full attacker range β€” ar_hln/ar_pln up to 255, i.e. up to ~249 heap / ~251 stack leaked bytes β€” applies, exactly as the finding states. The mechanism and the leak-size proportionality are identical.

VERDICT.md verdict full narrative: mechanism, evidence, harness caveat, fix before/after
↓ download raw

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.

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline 94B heap + 96B stack leak; patched oversized ARP dropped, legal 6/4 works.

BEFORE: 94B heap + 96B stack. AFTER: no reply for oversized, 0B for legal.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 16 18:34:26 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- read-only OOB. KASLR defeat + heap/stack disclosure. No write primitive.

Evidence (decisive lines)

BEFORE: ar_hln=100 -> 94B heap leak; ar_pln=100 -> 96B stack leak with 10 KVA ptrs. AFTER: oversized ARP dropped (no reply), legal 6/4 still works.

PoC changes

arp_leak.c (tap L2 ARP injector), fix.diff (arpintr guard ar_hln==if_addrlen && ar_pln==sizeof(in_addr)), VERDICT.md, manifest.json.

Verified recommended fix

In arpintr ETHERTYPE_IP case before in_arpinput: if(ar->ar_hln!=rcvif->if_addrlen||ar->ar_pln!=sizeof(struct in_addr)){m_freem(m);return;}. Closes all 4 downstream OOB memcpy sites. Full diff in findings/poc/DF-0494/fix.diff.

Verdict

REPRODUCED. arpintr if_ether.c:627 validates ar_hrd/ar_pro/len but NEVER ar_hln/ar_pln. in_arpreply uses attacker-controlled ar_hln/ar_pln as memcpy lengths -> 94B heap OOB (past 6B MAC) + 96B stack OOB (past 4B &taddr incl 10 KVA ptrs + 4 .text ret addrs). Remote unauthenticated same-L2 single ARP REQUEST.