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)
PoC verification
Evidence pack
findings/poc/DF-2595 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 reachng_bpf_rcvdata, get counted, andbpf_filter(buflen=0)is called β the type-confusion branch is entered. - After (#0 kernel + rebuilt single-fix
ng_bpf.ko, shad6407950β¦):recvFrames delta = 0β zero-length packets are dropped by the guard before the stats update and beforebpf_filteris called. Disasm confirms:mov 0xb8(%rsi),%r12d(load totlen) βtest %r12d,%r12dβje <ng_bpf_rcvdata+0x180>(earlyNG_FREE_DATA+return 0). - No regression: non-zero packets are still filtered
(
recvFrames += 2for 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, queriesNGM_BPF_GET_STATSto 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
fixedVALIDATED: 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
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).
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
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.
No comments yet.