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

Missing zero-length packet guard causes type confusion in bpf_filter OOB read panic

Summary

ng_bpf_rcvdata calls bpf_filter(prog data totlen totlen) without checking totlen==0 unlike FreeBSD upstream guard. When zero-length mbuf arrives (m_pkthdr.len==0) buflen=0 triggers bpf_filter _KERNEL mbuf-traversal fallback (bpf_filter.c:212) casts contiguous data pointer to struct mbuf* dereferences m_len/m_next/m_data fields. data is stack buffer buf[256] or mbuf data area NOT mbuf type confusion arbitrary pointer deref panic or kmem read info leak routing side-channel. Local user ngd_attach no privilege check any user PF_NETGRAPH SOCK_DGRAM. Requires ng_bpf node in topology with packet-load BPF instruction.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2595 Β· 10 files
FileTypeDescriptionSize
poc.c trigger-source builds ng_bpf graph w/ packet-load program, sends zero-length items, queries stats to prove path, grooms mbuf pool 10.1 KB view raw
build.sh build-script cc -O2 -o poc poc.c -lnetgraph 131 B view raw
run.sh run-script loads ng_socket+ng_bpf and runs poc as root 352 B view raw
build.log build-log final successful build (BUILD_EXIT=0) 13 B view raw
run.log run-log baseline: recvFrames delta=4, type-confusion path entered, no panic 1.2 KB view raw
fix_run.log run-log patched: recvFrames delta=0, zero-length dropped by guard 1.2 KB view raw
fix_build.log build-log ng_bpf.ko rebuild + disasm of guard (MAKE_RC=0) 828 B view raw
env.txt environment uname, cc, patched ng_bpf.ko sha256, kldstat 442 B view raw
fix.diff suggested-fix reject totlen==0 at top of ng_bpf_rcvdata (FreeBSD-style guard) 753 B view raw
VERDICT.md verdict full narrative: path reachable & confirmed, latent on default guest, fix validated 7.8 KB ↓ raw
VERDICT.md verdict full narrative: path reachable & confirmed, latent on default guest, fix validated
↓ download raw

DF-2595 β€” ng_bpf missing zero-length-packet guard β†’ bpf_filter type confusion

Verdict

REPRODUCED (code path confirmed reachable & exercised) but LATENT on the default guest: no observable panic or leak. The defective code path β€” a zero-length mbuf reaching bpf_filter() with buflen==0, which in the kernel makes bpf_filter() cast the flat data pointer to (struct mbuf *) and walk it as an mbuf chain β€” is unambiguously confirmed reached (line-by-line trace + recvFrames delta = 4 for 4 zero-length sends). But on the default 6.5-DEVELOPMENT #0 guest the stale mbuf data area read as the "fake struct mbuf" is benign (zeroed β†’ fake m_next == NULL β†’ m_xword() bails with merr=1 β†’ bpf_filter returns 0), so there is no deterministic crash or kernel-memory disclosure. This is a confirmed-reachable latent type confusion / hardening gap: FreeBSD's ng_bpf has the guard, DragonFly's does not. FIX VALIDATED (rebuilt ng_bpf.ko, guard confirmed in disasm, clean before/after).

The bug (confirmed line-by-line)

ng_bpf_rcvdata() in sys/netgraph/bpf/ng_bpf.c:373 computes totlen = m->m_pkthdr.len and, with no guard for totlen == 0, hands totlen as both wirelen and buflen straight to bpf_filter() (ng_bpf.c:403):

int totlen = m->m_pkthdr.len;            /* line 376 */
...
if (m->m_next != NULL) { ...; data = buf; m_copydata(m, 0, totlen, data); }
else data = mtod(m, u_char *);           /* line 400 */
...
len = bpf_filter(hip->prog->bpf_prog, data, totlen, totlen);  /* line 403 */

In the kernel, bpf_filter() (sys/net/bpf_filter.c:174) treats buflen == 0 as the signal "the buffer p is actually an mbuf chain; traverse it". For every packet-load instruction the bounds check fails when buflen==0, and the guard if (buflen != 0) return 0; is false, so execution falls into the mbuf traversal that casts the flat buffer to a struct mbuf *:

case BPF_LD|BPF_W|BPF_ABS:                 /* bpf_filter.c:206 */
    k = pc->k;
    if (k > buflen || sizeof(int32_t) > buflen - k) {   /* true for any k when buflen==0 */
#ifdef _KERNEL
        int merr;
        if (buflen != 0)        /* <-- guard FALSE because buflen==0 */
            return 0;
        A = m_xword((struct mbuf *)p, k, &merr);   /* TYPE CONFUSION (line 214) */
        if (merr != 0)
            return 0;
        continue;

m_xword() (bpf_filter.c:85) then dereferences m->m_len, m->m_next, mtod(m) on the fake mbuf. The identical pattern applies to BPF_LD|BPF_H|BPF_ABS (m_xhalf, c:238), BPF_LD|BPF_B|BPF_ABS (MINDEX+mtod, c:255), and the BPF_IND variants. This mbuf-traversal fallback is intentional for live bpf(4) filtering (where p really is an mbuf and buflen==0 is the "use the chain" sentinel); ng_bpf mis-uses the API by always passing a flat buffer and forgetting to reject totlen==0. FreeBSD's ng_bpf_rcvdata has the totlen==0 guard; DragonFly's does not.

Trigger requires a packet-load program

The default hook program is { BPF_STMT(BPF_RET+BPF_K, 0) } β€” a bare return, no load instruction β€” so the default never reaches the confused path. The PoC installs a load+ret program first (BPF_LD|BPF_W|BPF_ABS, k=0 then BPF_RET|BPF_K).

Reachability / privilege

ng_bpf_rcvdata is reached via a netgraph data socket (PF_NETGRAPH SOCK_DGRAM). Building the graph (NGM_MKPEER ng_bpf, NGM_BPF_SET_PROGRAM) goes through the netgraph control socket, whose attach ngc_attach() (sys/netgraph/socket/ng_socket.c:172) gates on caps_priv_check(SYSCAP_RESTRICTEDROOT) = root. The data socket (ngd_attach, ng_socket.c:310) needs no privilege, but it can only SEND on a hook a root-built graph already wired up. So the bug trigger is root-reachable (a root firewall admin configuring an ng_bpf filter); an unprivileged user can only inject data on a graph a root already configured. (The finding prompt's "any user PF_NETGRAPH SOCK_DGRAM" is true for the data socket alone, not for graph setup.)

Reproduction (unpatched #0 kernel)

# /tmp/poc2595
[+] created socket node 'df2595' csock=3 dsock=4
[+] mkpeer ng_bpf 'df2595:out' -> bpf 'in'
[+] installed BPF program on bpf 'in': LD_W_ABS k=0; RET 0
[*] stats BEFORE: recvFrames=0 recvOctets=0
[*] sending 4 ZERO-LENGTH data items -> ng_bpf 'in' (totlen=0)...
[*] stats AFTER : recvFrames=4 recvOctets=0  (delta=4)
[+] CONFIRMED: zero-length data reached ng_bpf_rcvdata -> bpf_filter(buflen=0)
    -> type-confusion branch entered
[*] grooming mbuf pool: flooding 4000 pointer-shaped packets then re-sending zero-length...
[*] still alive β€” kernel survived grooming+zero-length flood

recvFrames += 4 for 4 zero-length sends proves ng_bpf_rcvdata was entered with m_pkthdr.len == 0, and the code trace proves that path reaches bpf_filter(prog, data, 0, 0) β†’ the buflen==0 mbuf-traversal branch. No panic occurred: the mbuf's data area (read as the fake struct mbuf) is zeroed/benign on the default guest, so m_xword's m = m->m_next yields NULL β†’ it returns merr=1 β†’ bpf_filter returns 0. A targeted mbuf-pool grooming flood (4000 pointer-shaped packets interleaved with zero-length sends) did not land a stale mbuf whose residue dereferences to a fault β€” the MH_ALIGN(m, 0) data pointer points near the end of m_pktdat, which the grooming packets' bytes don't reach, so the fake fields stay zeroed.

To make this deterministically panic/leak one would need to defeat that alignment-dependent grooming window β€” a real but non-trivial exploit-dev task whose payoff is a kernel pointer deref from a root-only trigger. The defect is real and worth fixing as defense-in-depth regardless.

The fix (defense-in-depth, matches FreeBSD upstream)

Reject zero-length packets at the top of ng_bpf_rcvdata, before any data-pointer work and before bpf_filter is ever called:

if (totlen == 0) {
    NG_FREE_DATA(m, meta);
    return (0);
}

Standalone git apply-able diff: fix.diff.

Fix validation (rebuilt ng_bpf.ko, reloaded, re-ran same PoC)

  • Before (unpatched #0 kernel + #0 module, sha b69ff3fd…): recvFrames delta = 4 β€” zero-length packets reach ng_bpf_rcvdata, get counted, and bpf_filter(buflen=0) is called β†’ the type-confusion branch is entered.
  • After (#0 kernel + rebuilt single-fix ng_bpf.ko, sha d6407950…): recvFrames delta = 0 β€” zero-length packets are dropped by the guard before the stats update and before bpf_filter is called. Disasm confirms: mov 0xb8(%rsi),%r12d (load totlen) β†’ test %r12d,%r12d β†’ je <ng_bpf_rcvdata+0x180> (early NG_FREE_DATA+return 0).
  • No regression: non-zero packets are still filtered (recvFrames += 2 for two 32-byte packets on the patched module).
  • Module rebuilt via cd /usr/src/sys/netgraph/bpf && make KERNBUILDDIR=… (module-only; MAKE_RC=0); kldunload ng_bpf (rc=0, graph torn down) β†’ kldload ng_bpf (rc=0). No reboot needed (ng_bpf is safely unloadable, unlike ipfw3).

fix_status = fixed (the dangerous code path is now deterministically short-circuited; clean before/after on the recvFrames signal; no regression).

Files

  • poc.c β€” builds ng_bpf graph with a packet-load program, sends zero-length items, queries NGM_BPF_GET_STATS to prove the path, and grooms the mbuf pool.
  • build.sh / run.sh β€” exact build & run (run as root; loads ng_socket + ng_bpf).
  • run.log β€” baseline run (recvFrames delta=4, type-confusion path entered, no panic).
  • fix_run.log β€” patched-module run (recvFrames delta=0, path short-circuited).
  • fix_build.log β€” module rebuild + disasm (MAKE_RC=0, guard confirmed).
  • env.txt β€” guest uname / cc / patched sha / kldstat.
  • fix.diff β€” the standalone git-apply-able zero-length guard.
  • manifest.json β€” artifact catalog.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: on unpatched ng_bpf.ko (b69ff3fd...) PoC shows recvFrames delta=4 (zero-length packets reach ng_bpf_rcvdata and bpf_filter called with buflen=0, entering type-confusion branch); on single-fix rebuilt ng_bpf.ko (d6407950...) SAME PoC shows recvFrames delta=0 (zero-length packets dropped by guard before stats update and before bpf_filter ever called). Disasm confirms guard: mov 0xb8(%rsi),%r12d (load totlen) -> test %r12d,%r12d -> je . No regression: non-zero packets still filtered (recvFrames += 2 for two 32-byte packets on patched module).

baseline (unpatched ng_bpf.ko): stats BEFORE recvFrames=0; sent 4 zero-length items; stats AFTER recvFrames=4 recvOctets=0 (delta=4) -> type-confusion path entered (benign on default guest). patched (ng_bpf.ko d6407950...): stats AFTER recvFrames=0 recvOctets=0 (delta=0) -> zero-length dropped by guard before bpf_filter. regression check: 2x 32-byte pkts -> AFTER recvFrames=2 (normal filtering intact).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged; single-fix applied to ng_bpf.ko MODULE only β€” rebuilt via cd /usr/src/sys/netgraph/bpf && make KERNBUILDDIR=.../X86_64_GENERIC, sha256 d6407950c2e81e92dec12c6bf7231dbfa204eb1d49118f61aa802afa5c4b50bf, kldunload+kldload reloaded cleanly)

Confirmed kernel references

Detail

Exploit chain

none β€” latent type-confusion / hardening gap, not reliable memory-corruption primitive on default guest. Confused branch IS entered (proven via recvFrames), but dereferences benign zeroed mbuf data so no write primitive, no deterministic panic, no kernel-memory disclosure to convert. Graph setup (NGM_MKPEER ng_bpf, NGM_BPF_SET_PROGRAM) needs netgraph control socket whose attach (ng_socket.c:172) gates on caps_priv_check(SYSCAP_RESTRICTEDROOT) = root; data socket (ng_socket.c:310) unprivileged but can only send on hook a root-built graph already wired, so trigger root-reachable (root firewall admin). Making it deterministically panic would require defeating MH_ALIGN-dependent grooming window for root-only trigger β€” non-trivial with low payoff.

Evidence (decisive lines)

unpatched ng_bpf.ko (b69ff3fd...): created socket node 'df2595'; mkpeer ng_bpf; installed BPF program LD_W_ABS k=0; RET 0; stats BEFORE recvFrames=0; sent 4 zero-length data items; stats AFTER recvFrames=4 recvOctets=0 (delta=4); CONFIRMED zero-length data reached ng_bpf_rcvdata -> bpf_filter(buflen=0) -> type-confusion branch entered; mbuf-pool grooming flood (4000 ptr-shaped pkts + zero-length) -> kernel survived (benign zeroed data area, no panic). patched ng_bpf.ko (d6407950...): same PoC -> stats AFTER recvFrames=0 (delta=0) -> zero-length dropped by guard before bpf_filter. regression: 2x 32-byte pkts -> recvFrames=2 (normal filtering still works).

PoC changes

Authored new poc.c (dir empty): uses libnetgraph to create named socket node, NGM_MKPEER an ng_bpf node, NGM_BPF_SET_PROGRAM a load+ret BPF program, then (1) queries NGM_BPF_GET_STATS to PROVE zero-length data reaches ng_bpf_rcvdata (recvFrames delta), and (2) grooms mbuf pool with pointer-shaped packets interleaved with zero-length sends to try to surface latent panic. Fixed two compile errors during iteration: include BEFORE (struct bpf_insn incomplete) and widen probe array. build.sh, run.sh, fix.diff (FreeBSD-style totlen==0 guard).

Verified recommended fix

In ng_bpf_rcvdata (sys/netgraph/bpf/ng_bpf.c, right after 'int totlen = m->m_pkthdr.len;') reject zero-length packets before any data-pointer work or bpf_filter call: 'if (totlen == 0) { NG_FREE_DATA(m, meta); return (0); }'. Matches FreeBSD upstream ng_bpf and removes only condition (buflen==0) under which bpf_filter()'s kernel mbuf-traversal fallback mis-interprets flat data pointer as struct mbuf. Defense-in-depth for confirmed-reachable latent type confusion. Full git-apply-able diff in findings/poc/DF-2595/fix.diff.

Verdict

REPRODUCED as confirmed-reachable but LATENT type confusion: no observable panic or leak on default guest. ng_bpf_rcvdata (sys/netgraph/bpf/ng_bpf.c:376,403) computes totlen=m->m_pkthdr.len and, with NO guard for totlen==0, calls bpf_filter(prog, data, totlen, totlen). In kernel, bpf_filter (sys/net/bpf_filter.c:206-214) treats buflen==0 as 'p is an mbuf chain' sentinel: for any packet-load instruction the bounds check fails, guard 'if (buflen != 0) return 0' is FALSE, and it casts flat data pointer to (struct mbuf *) and dereferences m_len/m_next/mtod on it (m_xword, bpf_filter.c:85) β€” type confusion. CONFIRMED path reached: NGM_BPF_GET_STATS shows recvFrames += 4 for 4 zero-length sends on unpatched module (mbuf enters ng_bpf_rcvdata with m_pkthdr.len==0 and reaches bpf_filter buflen=0). Confusion does NOT crash on default guest because stale mbuf data area read as fake struct mbuf is benign (zeroed -> fake m_next==NULL -> m_xword bails merr=1 -> bpf_filter returns 0). Targeted mbuf-pool grooming flood (4000 pointer-shaped packets interleaved with zero-length sends) did not land stale mbuf whose residue dereferences to fault, because MH_ALIGN(m,0) points data ptr near END of m_pktdat where grooming bytes don't land. FreeBSD's ng_bpf has totlen==0 guard; DragonFly's does not β€” real defense-in-depth defect. Default hook program (BPF_RET|BPF_K only, no load) never reaches confused path; PoC installs a load+ret program first.