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)
PoC verification
Evidence pack
findings/poc/DF-3468 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_MPLSdispatch inether_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 withoptions 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
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)
- Compile-gate pinned empirically. Stock X86_64_GENERIC kernel +
kldload'd
mpls.ko(built from/usr/src/sys/netproto/mpls): 200 frames injected,netstat -mperfectly flat β frames die inether_demux_oncpu'sdefault:case because the MPLS case label is#ifdef MPLS-compiled-out. Conclusion recorded: the bug needsoptions MPLSkernels; a KLD alone does not expose it. - MPLS kernel built (
X86_64_GENERIC+options MPLS= MPLSAUDIT config), installed, booted.nm /boot/kernel/kernelshowsmpls_input. - 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; theBIOCSFEEDBACKcopy ism_dup'd from the stripped mbuf and fed to(*ifp->if_input)()(bpf.c:605-606), soether_inputsaw a header-less mbuf. Fix: write the real ether header twice; after the strip, the feedback copy begins with the second copy. - 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. - Fix validated.
fix.diffapplied to guest/usr/src; incrementalmake nativekernelrebuild; reboot into patched kernel; exact same PoC: 500 + 500 frames β clusters 256 β 256 β 256. Guest healthy throughout. Seerun.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
fixedApplied 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)
Confirmed kernel references
- sys/netproto/mpls/mpls_input.c:200-203
- sys/netproto/mpls/mpls_input.c:159-166
- sys/netproto/mpls/mpls_input.c:51-55
- sys/net/if_ethersubr.c:1146-1150
- sys/net/if_ethersubr.c:1619-1622
- sys/conf/files:1865-1868
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.
No comments yet.