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

mpls_forward() permanently leaks the packet mbuf+cluster on every AF_MPLS route miss β€” unauthenticated on-link memory-exhaustion DoS on options-MPLS kernels

Summary

mpls_forward() returns at :202 without m_freem(m) when rtalloc() finds no AF_MPLS route for the packet's label; mpls_input() transferred ownership at :159-161. Since the AF_MPLS routing table is empty by default and mplsforwarding is hardwired to 1, every label-16..1048575 EtherType-0x8847 frame from an on-link attacker leaks one mbuf+cluster (~2KB) permanently; ~33k frames exhaust nmbclusters (33296 measured on the audit guest) -> network-wide ENOBUFS DoS. VERIFIED live on an options-MPLS kernel built in-guest: bpf+BIOCSFEEDBACK injection of label=1000/S=1 frames - clusters 256->356 (+100/100), then ->856 (+500/500), still 856 at +28s; unknown-ethertype control +0 (note: the ether header must be written twice because bpf_movein strips 14 bytes and the feedback m_dup starts stripped). PLATFORM FACT pinned: NOT reachable via kldload mpls.ko on a stock kernel - the ETHERTYPE_MPLS dispatch cases are #ifdef MPLS and compiled out; measured flat on stock+kld. Non-default config -> Medium. Unauthenticated on-link one-way attacker; pure exhaustion primitive (~46MB of traffic), no corruption/disclosure. Fix: m_freem(m) before the return. Also noted: DF-3321's fix.diff contained ONLY the M_HASH/M_LENCHECKED hunks for mpls - DF-0751's goto-again loop fix is NOT included (still unfixed in tree).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3468 Β· 13 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 4.7 KB ↓ raw
mpls_leak.c β€” 4.2 KB view raw
build.sh β€” 279 B view raw
run.sh β€” 212 B view raw
build.log β€” 19 B view raw
kernel_build.log β€” 5.6 MB ↓ download
run.log β€” 744 B view raw
run.patched.log β€” 796 B view raw
env.txt β€” 711 B view raw
fix.diff β€” 522 B view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 4.4 KB view raw

DF-3468 β€” mpls_forward() route-miss mbuf/cluster leak β€” PoC & evidence pack

TL;DR

Every MPLS packet whose label has no matching AF_MPLS route β€” the default state of any MPLS-enabled kernel, since none exist until an admin adds them β€” is permanently leaked by mpls_forward() (sys/netproto/mpls/mpls_input.c:200-203): the route-miss return; drops the function without m_freem(m), although the caller (mpls_input() at :159-161) transferred ownership with mpls_forward(m); return;. An unauthenticated on-link attacker needs only ~33k small MPLS frames to exhaust the cluster pool (nmbclusters=33296, ~2 KB each) β†’ ENOBUFS network-wide DoS on options MPLS kernels.

Verdict: REPRODUCED (+1 leaked cluster per frame, permanent). Fix VALIDATED (zero growth after patch).

Reachability (empirically pinned down)

  • MPLS is optional mpls (sys/conf/files:1865-1868); NOT in X86_64_GENERIC.
  • kldload mpls.ko on a stock kernel is NOT sufficient: the case ETHERTYPE_MPLS dispatch in ether_demux_oncpu/ether_characterize (sys/net/if_ethersubr.c:1146-1150,1619-1622) is #ifdef MPLS-compiled-out of the kernel; a loaded module cannot receive the frames (measured: 200 frames, counts flat). Attack surface = kernels built with options MPLS (or a distribution kernel built with it).

Files

file purpose
mpls_leak.c trigger: injects N MPLS frames (label=1000, S=1 so the known DF-0751 label-0/2 loop is NOT hit) via bpf write + BIOCSFEEDBACK on vtnet0; doubles the ether header because DF's bpf_movein(DLT_EN10MB) strips the leading 14 bytes into the output sockaddr and the feedback m_dup starts at the stripped mbuf
build.sh guest-side build steps (trigger / MPLS kernel / patched kernel)
run.sh guest-side measurement: baseline β†’ N frames β†’ +3s β†’ +28s β†’ control frames with unknown ethertype
run.log decisive baseline run (500 frames β†’ +500 clusters, permanent; control β†’ +0)
run.patched.log patched-kernel run (500+500 frames β†’ +0 clusters)
kernel_build.log full make nativekernel KERNCONF=MPLSAUDIT output
build.log trigger rebuild on guest
env.txt guest uname (MPLSAUDIT kernel), cc 8.3, kldstat, nmbclusters
fix.diff git apply-able one-hunk fix: m_freem(m) before the route-miss return

Reproduce (inside guest, root)

# 1. kernel with MPLS (stock kernel cannot deliver ETHERTYPE_MPLS)
cp /usr/src/sys/config/X86_64_GENERIC /usr/src/sys/config/MPLSAUDIT
echo "options   MPLS" >> /usr/src/sys/config/MPLSAUDIT
cd /usr/src && make -j6 nativekernel KERNCONF=MPLSAUDIT && make installkernel KERNCONF=MPLSAUDIT && reboot

# 2. trigger
cc -O2 -o /root/mpls_leak /root/mpls_leak.c

# 3. measure  (expect: clusters 256 -> 756 after 500 frames, still 756 at +28s;
#             control 500 unknown-ethertype frames -> no change)
netstat -m | head -2
/root/mpls_leak vtnet0 500
sleep 3  && netstat -m | head -2
sleep 25 && netstat -m | head -2
/root/mpls_leak vtnet0 500 88b7 && sleep 3 && netstat -m | head -2

# 4. fix validation
cd /usr/src && patch -p1 < /root/DF-3468.fix.diff
make -j6 nativekernel KERNCONF=MPLSAUDIT && make installkernel KERNCONF=MPLSAUDIT && reboot
# re-run step 3: counts stay flat
VERDICT.md
↓ download raw

DF-3468 β€” VERDICT

Status: REPRODUCED β€” impact leak (kernel memory exhaustion DoS) β€” FIX VALIDATED

Root cause (line-accurate)

sys/netproto/mpls/mpls_input.c, mpls_forward():

190:    smpls = (struct sockaddr_mpls *) &cache_rt->ro_dst;
191:    if (cache_rt->ro_rt == NULL || smpls->smpls_label != label) {
192:        if (cache_rt->ro_rt != NULL) {
193:            RTFREE(cache_rt->ro_rt);
194:            cache_rt->ro_rt = NULL;
195:        }
196:        smpls->smpls_family = AF_MPLS;
197:        smpls->smpls_len = sizeof(struct sockaddr_mpls);
198:        smpls->smpls_label = htonl(label);
199:        rtalloc(cache_rt);
200:        if (cache_rt->ro_rt == NULL) {
201:            /* route not found */
202:            return;         *** BUG: no m_freem(m) ***
203:        }
204:    }

Ownership flow: mpls_input() :159-161 β€” mpls_forward(m); return; β€” hands the mbuf over exclusively. Every other exit of mpls_forward() consumes the mbuf (if_output on success, m_freem(m) in the bad: label at :217-218). The route-miss return at :202 is the one exception: the mbuf is neither freed nor handed to anyone β†’ permanent leak of the mbuf + its cluster.

Trigger condition (all attacker-controlled): - packet arrives as EtherType 0x8847/0x8847-mcast on any interface of an options MPLS kernel (if_ethersubr.c:1146-1150,1619-1622); - first label > 15 (mpls_input.c:154 reserved-range check passes); - mplsforwarding β€” always 1; the sysctl is commented out (mpls_input.c:51-55); - no AF_MPLS route for the label β€” the default state, since the AF_MPLS routing table is empty until an administrator populates it (mpls_proto.c:mpls_inithead attaches an empty radix tree).

Attacker: unauthenticated on-link peer (MPLS frames are link-local; not routed). No privileges, no response needed, one-way traffic.

Verification narrative (what was run, what was seen)

  1. Compile-gate pinned empirically. Stock X86_64_GENERIC kernel + kldload'd mpls.ko (built from /usr/src/sys/netproto/mpls): 200 frames injected, netstat -m perfectly flat β€” frames die in ether_demux_oncpu's default: case because the MPLS case label is #ifdef MPLS-compiled-out. Conclusion recorded: the bug needs options MPLS kernels; a KLD alone does not expose it.
  2. MPLS kernel built (X86_64_GENERIC + options MPLS = MPLSAUDIT config), installed, booted. nm /boot/kernel/kernel shows mpls_input.
  3. Injection obstacle solved. First trigger run was flat β€” root cause: DF's bpf_movein(DLT_EN10MB) (sys/net/bpf.c) strips the leading 14 bytes of the written frame into the output sockaddr; the BIOCSFEEDBACK copy is m_dup'd from the stripped mbuf and fed to (*ifp->if_input)() (bpf.c:605-606), so ether_input saw a header-less mbuf. Fix: write the real ether header twice; after the strip, the feedback copy begins with the second copy.
  4. Leak reproduced. 100-frame warmup: clusters 256 β†’ 356 (+100). Then 500 frames: 356 β†’ 856 (+500), still 856 at +28 s (no reclaim). Control: 500 frames with unknown EtherType 0x88B7 (drop path): +0. Exact per-frame accounting, permanent retention, clean differential. See run.log.
  5. Fix validated. fix.diff applied to guest /usr/src; incremental make nativekernel rebuild; reboot into patched kernel; exact same PoC: 500 + 500 frames β†’ clusters 256 β†’ 256 β†’ 256. Guest healthy throughout. See run.patched.log.

Impact ceiling

Remote-unauth (on-link, AV:A) kernel memory exhaustion: each ~1.4 KB frame permanently removes one mbuf+cluster (2 KB). nmbclusters = 33296 β†’ ~33k frames (~46 MB of traffic, well under one second on any LAN) exhausts the cluster pool β†’ ENOBUFS for all networking, dummynet/panic risk on allocation-failure paths, reboot required to reclaim. Gated on non-default kernel config (options MPLS) per the empirical gate above β€” hence Medium, not High, under this project's rubric (remote DoS on default config).

exploit_chain: none β€” resource-exhaustion primitive only; no corruption, no info disclosure, no escalation.

Fix

m_freem(m) before the route-miss return β€” see fix.diff (validated). Consider also a per-route-miss counter/stat for operator visibility.

Guest hygiene

Guest was reset to the clean-source snapshot (vm.sh reset with-src) after validation: stock X86_64_GENERIC #0 kernel, pristine /usr/src (no MPLSAUDIT config, no patch residue).

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

Applied fix.diff (m_freem(m) on route miss) to guest /usr/src, incremental nativekernel rebuild, reboot, re-ran the exact PoC: 500+500 frames, cluster count 256->256->256 (zero leak), guest healthy. Baseline on the same kernel before patch: 256->856 permanent. Bad behavior gone.

run.patched.log (full patched-kernel run); fix.diff (git-apply-able); second build completion stamp in kernel_build.log tail (10:27:31 UTC)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 13 10:23:43 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/MPLSAUDIT x86_64

Confirmed kernel references

Detail

Exploit chain

none β€” resource-exhaustion primitive only (permanent mbuf+cluster leak per packet); no memory corruption, no disclosure, no escalation

Evidence (decisive lines)

run.log (baseline: clusters 256->856 after 500 frames, still 856 at +28s, control 0x88B7 frames +0); run.patched.log (patched kernel: 256->256 after 1000 frames); env.txt (MPLSAUDIT kernel uname, nmbclusters=33296); kernel_build.log (full nativekernel build)

PoC changes

authored from scratch (no seed): (1) trigger injects via bpf+BIOCSFEEDBACK but must write the ether header TWICE β€” DF bpf_movein(DLT_EN10MB) strips the leading 14 bytes into the output sockaddr and the feedback m_dup starts at the stripped mbuf, so a single header never reaches ether_input intact; (2) label=1000 S=1 to avoid wedging on the known DF-0751 label-0/2 loop; (3) stock kernel + kldload mpls.ko was tested first and ruled out (compile-gated), requiring an options-MPLS nativekernel build

Verified recommended fix

add m_freem(m) before the route-not-found return in mpls_forward() (sys/netproto/mpls/mpls_input.c:202); validated in fix.diff

Verdict

mpls_forward() leaks the mbuf on every AF_MPLS route miss (mpls_input.c:200-203 returns without m_freem(m) after mpls_input() transferred ownership at :159-161). Reproduced on an options-MPLS kernel: netstat -m cluster count climbs exactly +1 per injected MPLS frame (256->356 on 100 frames, 356->856 on 500) and never recovers (+28s recheck); unknown-ethertype control frames leak nothing. ~33k frames exhaust nmbclusters=33296 -> permanent network-wide ENOBUFS DoS until reboot. Empirically pinned reachability: kldload'd mpls.ko on the stock kernel does NOT receive ETHERTYPE_MPLS frames (if_ethersubr.c:1146/1619 case labels are #ifdef MPLS; 200-frame control on stock kernel: flat), so the exposure is options-MPLS kernels. Unauthenticated on-link attacker, one-way traffic, label>15 (mplsforwarding is hardwired 1). Fix validated: m_freem(m) before the return; patched kernel shows zero growth over 1000 frames.