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

catchpacket() bh_caplen unsigned underflow: BPF_MINBUFSIZE(32) < bif_hdrlen(34/36) on x86-64 gives a ~4GiB copy into kmalloc(32) (heap overflow, deterministic panic)

Summary

On LP64, SIZEOF_BPF_HDR=32 and BPF_WORDALIGN rounds to 8, so every attached DLT gets bif_hdrlen >= 34 (Ethernet 34; DLT_NULL 36). BIOCSETBLEN clamps bd_bufsize to BPF_MINBUFSIZE=32 - smaller than the header it must hold - and BIOCSETIF allocates that store buffer without any hdrlen check. The first tapped packet makes catchpacket() clamp totlen to 32 and compute hp->bh_caplen = totlen - hdrlen = 0xfffffffc, and cpfn(pkt, hp+hdrlen, 0xfffffffc) copies ~4GiB starting 34/36 bytes into a kmalloc(32) allocation: a forward linear heap overflow with fully attacker-controlled contents (packet bytes) but forced length - corrupt-then-panic. VERIFIED on stock guest with the classic tcpdump setuid-drop pattern (uid=euid=65534 child issues BIOCSETIF + packet after root parent opened the device): 'panic: bpf_mcopy', backtrace bpf_mcopy <- catchpacket <- bpf_mtap <- bpf_ptap <- if_simloop. uid0 not credible (forced 4GiB copy; kernel always dies mid-copy) - primitive characterized as controlled ~50+ byte slab-adjacent corruption before the inevitable fault. Alternate trigger: debug.bpf_bufsize sysctl below bif_hdrlen. Fix validated (three-layer: reject BIOCSBLEN with sbuf, hdrlen check at bpf_setif, totlen<hdrlen guard): PoC fails EINVAL, normal capture intact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3381 Β· 13 files
FileTypeDescriptionSize
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 4.7 KB ↓ raw
trigger.c β€” 2.7 KB view raw
build.sh β€” 81 B view raw
run.sh β€” 90 B view raw
run.log β€” 670 B view raw
run.patched.log β€” 504 B view raw
panic.txt β€” 530 B view raw
env.txt β€” 255 B view raw
fix.diff β€” 1.6 KB view raw
kernel_build.log β€” 4.8 KB view raw
code_hash.txt β€” 151 B view raw
verdict.json β€” 4.1 KB view raw

DF-3381 β€” BPF catchpacket() bh_caplen unsigned underflow (heap buffer overflow)

What

sys/net/bpf.c catchpacket() computes the capture length as hp->bh_caplen = totlen - hdrlen after clamping totlen to bd_bufsize. On x86-64 every attached DLT has bif_hdrlen >= 32+ (Ethernet = roundup2(14+32,8)-14 = 34; DLT_NULL (lo0/tun/wg) = roundup2(4+32,8)-4 = 36) because SIZEOF_BPF_HDR = sizeof(struct bpf_hdr) = 32 (16-byte timeval + 12 bytes, padded) and BPF_WORDALIGN rounds to sizeof(long) = 8. BIOCSETBLEN happily clamps the store-buffer size to BPF_MINBUFSIZE = 32 (bpf.c:794-806) which is smaller than the header it must hold. The next tapped packet then executes:

totlen = hdrlen + min(snaplen, pktlen);   /* >= 36   */
if (totlen > d->bd_bufsize)                /* 32      */
        totlen = d->bd_bufsize;            /* 32      */
...
(*cpfn)(pkt, (u_char *)hp + hdrlen,
        (hp->bh_caplen = totlen - hdrlen)); /* 32-36 = 0xfffffffc */

i.e. a ~4 GiB copy (bpf_mcopy/_bcopy) into a kmalloc(32) store buffer, starting 34/36 bytes past the start of that 32-byte allocation. Attacker packet data is written linearly over the adjacent slab until the chain/page runs out.

Reproduce

cc -O -o trigger trigger.c     # in guest (gcc 8.3)
./trigger                      # root starts it; the triggering child is uid=nobody

Expected: panic: bpf_mcopy (or a page fault inside the 4 GB copy), backtrace bpf_mcopy <- catchpacket <- bpf_mtap <- bpf_ptap <- if_simloop.

Observed (guest DragonFly 6.5-DEVELOPMENT #0 ... X86_64_GENERIC, INVARIANTS): exactly that β€” see panic.txt, run.log.

Fix

fix.diff (validated in-guest, see VERDICT.md): reject BIOCSETBLEN once buffers exist, reject BIOCSETIF when bd_bufsize < bif_hdrlen, and guard catchpacket() against totlen < hdrlen (plus a NULL-store-buffer guard).

VERDICT.md
↓ download raw

DF-3381 VERDICT

Reproduced: YES (first attempt)

Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 (X86_64_GENERIC, stock INVARIANTS kernel), x86_64, QEMU/KVM. /dev/bpf* are 0600 root:wheel; bpfopen() also rejects jailed credentials (bpf.c:363).

Sequence actually executed (run.log):

  1. root opens /dev/bpf0, BIOCSBLEN(32) β€” kernel clamps to/accepts 32 (BPF_MINBUFSIZE, bpf.c:802-803). Confirmed by the program's BIOCSBLEN -> 32 printout.
  2. fork; child setgroups/setgid/setuid to nobody (uid 65534), then BIOCSETIF("lo0") β€” lo0 is DLT_NULL with bif_hdrlen = roundup2(4 + 32, 8) - 4 = 36 (bpf.c:1543, bpf.h:61-62,147-149).
  3. child sends one loopback UDP packet β†’ looutput β†’ if_simloop (if_loop.c:227) β†’ bpf_ptap β†’ bpf_mtap β†’ catchpacket with bd_bufsize=32 < hdrlen=36.

Result (serial console, panic.txt):

panic: bpf_mcopy
cpuid = 0
bpf_mcopy() at bpf_mcopy+0x6c
catchpacket.isra.1() at catchpacket.isra.1+0xff
bpf_mtap() at bpf_mtap+0x71
bpf_ptap() at bpf_ptap+0x39
if_simloop() at if_simloop+0x8f
Debugger("panic")

This is the exact predicted path and proves the underflow primitive: before the m == NULL KASSERT fires, bpf_mcopy has already written the 4-byte AF header at bd_sbuf+36 and the full UDP packet (~44 bytes of attacker-chosen content) after it β€” i.e. ~50 bytes of controlled data written past the end of a kmalloc(32) allocation, with a nominal copy length of 0xfffffffc bytes.

Primitive characterization (primary objective)

  • What: linear heap overflow forward from kmalloc(32)+hdrlen.
  • Where: the store buffer allocated in bpf_allocbufs() (bpf.c:1475).
  • Content control: full β€” the copied bytes are the tapped packet's bytes (attacker generates them; here a UDP payload).
  • Length control: none. The length is forced to bd_bufsize - hdrlen interpreted as u_int (0xfffffffc for lo0, 0xffffffe variants per DLT). The copy runs until the mbuf chain ends (then panic("bpf_mcopy"), bpf.c:1283-1284) or until an unmapped page faults, whichever comes first. There is no way to stop the copy at a chosen offset, so the kernel always dies; this is a corrupt-then-panic primitive, not corrupt-and-continue.
  • uid0 route: blocked by the privilege model, not by hardening: opening /dev/bpf* requires euid 0 (0600 cdev) and jails are refused (bpf.c:362-366), so an unprivileged local user cannot reach the bug on a default system. What the PoC does demonstrate is the drop-privileges-after-open pattern (tcpdump/libpcap keep a bpf fd across a setuid drop): the process that issued the corrupting ioctls and packet had uid=euid=65534. A sandboxed/capability-dropped capture daemon that ever calls BIOCSBLEN(<= hdrlen)/BIOCSETIF can smash kernel memory while holding no privileges at trigger time. Reliable uid0 was not pursued further because the unbounded copy length makes survive-and-exploit implausible: any successful run also kills the kernel.

Why not already fixed upstream-style

FreeBSD rewrote its BPF buffer handling (locked buffers, bpf_buffer.c) and rejects buffer sizes that cannot hold bif_hdrlen; DragonFly retains the 1990s two-buffer scheme where the only guard is the (too small) BPF_MINBUFSIZE = 32 constant, which lost meaning when struct timeval grew to 16 bytes on LP64 (SIZEOF_BPF_HDR 18 -> 32, bpf.h:147-149).

Fix validation (mandatory for memory corruption)

fix.diff: (1) BIOCSETBLEN also rejected once bd_sbuf is allocated, (2) bpf_setif returns EINVAL when bd_bufsize < bif_hdrlen, (3) catchpacket drops when totlen < hdrlen (and when bd_sbuf == NULL, which also covers DF-3384's NULL write).

Baseline: panic above, 100% reproducible by construction (no race). Patched: rebuilt X86_64_GENERIC in guest (make nativekernel), rebooted, re-ran the identical PoC β€” see fix_baseline_reproduced=1, fix_patched_reproduced=0 in verdict.json and run.patched.log: BIOCSETIF now fails with EINVAL ("BIOCSETIF(lo0): Invalid argument") and the guest survives. The defensive catchpacket guard was additionally exercised by binding to Ethernet (bif_hdrlen 34) with a 40-byte buffer β€” packets are dropped (bd_dcount grows), no panic, no corruption.

kernel refs: sys/net/bpf.c:794-806, 1128-1134, 1418-1421, 1462; sys/net/bpf.h:61-62, 64-67, 134-149.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline (stock #0 kernel): deterministic panic: bpf_mcopy via catchpacket underflow. Patched (#1 kernel, fix.diff): identical PoC fails cleanly with BIOCSETIF(lo0): EINVAL from the bpf_setif bd_bufsize<bif_hdrlen guard; guest survives; normal capture verified working (bh_hdrlen=36 bh_caplen=38 bs_recv=2).

['run.patched.log', 'kernel_build.log (make nativekernel/installkernel RC=0)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 22:24:51 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (fix.diff applied; make nativekernel + installkernel in guest)

Confirmed kernel references

Detail

Exploit chain

root parent opens /dev/bpf0 + BIOCSBLEN(32) -> fork/setuid(nobody) child -> BIOCSETIF(lo0) -> one loopback UDP packet -> catchpacket bh_caplen underflow -> bpf_mcopy writes packet-controlled bytes at kmalloc(32)+36 with length 0xfffffffc -> heap corruption of adjacent slab objects -> panic (bpf_mcopy chain-end KASSERT). uid0 not pursued: copy length is forced, kernel always dies; trigger requires a bpf fd (privileged open).

Evidence (decisive lines)

["panic.txt: 'panic: bpf_mcopy' + catchpacket/bpf_mtap/bpf_ptap/if_simloop backtrace", "run.log: '[+] BIOCSBLEN -> 32' then guest dies in the setuid(nobody) child phase", 'trigger.c: root opens fd, drops to nobody, binds lo0, fires one UDP packet', 'VERDICT.md: primitive characterization + why uid0 is blocked by the forced 4GiB copy length']

PoC changes

Seed sketch did not exist; PoC written fresh. Key correct details: BIOCSBLEN before BIOCSETIF (only then are buffers allocated); lo0 (DLT_NULL, bif_hdrlen=36) as victim interface; loopback UDP sendto as traffic source (if_simloop->bpf_ptap->bpf_mtap); fork+setuid(nobody) so the corrupting ioctls run unprivileged at trigger time.

Verified recommended fix

Reject BIOCSETBLEN once buffers are allocated, reject BIOCSETIF when bd_bufsize < bif_hdrlen, and drop (count) in catchpacket when totlen < hdrlen (validated fix.diff).

Verdict

catchpacket() computes bh_caplen = totlen - hdrlen after clamping totlen to bd_bufsize; on x86-64 bif_hdrlen is 34 (ether) / 36 (DLT_NULL) > BPF_MINBUFSIZE=32, so BIOCSBLEN(32)+BIOCSETIF makes the subtraction underflow to 0xfffffffc and the tap copy routine writes ~4GiB of packet data through a kmalloc(32) store buffer. Reproduced deterministically on the stock INVARIANTS guest: panic: bpf_mcopy with backtrace catchpacket<-bpf_mtap<-bpf_ptap<-if_simloop; the corrupting ioctls and packet were issued by a uid=nobody child holding the bpf fd opened by its root parent (tcpdump-style privilege drop). Primitive: forward linear heap overflow from kmalloc(32)+hdrlen with fully attacker-controlled contents but uncontrolled (~4GiB, forced) length - corrupt-then-panic, no credible survive-and-exploit path, so impact ceiling is reliable kernel memory corruption/DoS gated on holding an open bpf descriptor (device is 0600 root; jails refused). Fix validated on rebuilt kernel: PoC now fails cleanly with EINVAL at BIOCSETIF and the guest survives.