Stale mbuf pointer after mpls_output may reallocate β use-after-free/double-free when route has PUSH op
Summary
mpls_input.c:208 error=mpls_output(m,cache_rt->ro_rt) β m passed BY VALUE. mpls_output mpls_push does M_PREPEND (mpls_output.c:157) which may reallocate+free original mbuf updating only mpls_output LOCAL m. Caller mpls_forward still holds original (freed) pointer. :211 ifp->if_output(ifp,m,...) uses stale pointer = UAF. :218 bad: m_freem(m) = double-free. Trigger: MPLS-forwarding host with AF_MPLS route whose rt_shim includes PUSH op + on-link attacker sends MPLS frame matching route sized to force M_PREPEND realloc. Preconditions (MPLS+PUSH route+headroom-dependent realloc) make less universally reachable than DF-0751 hence likely not certain. Fix: mpls_output takes struct mbuf**mp updates caller pointer.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0753 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| mpls_stale_harness.c | trigger-source | deterministic proof: transcribes mpls_output/push/swap/pop/forward + m_prepend/m_pullup verbatim; poisoned allocator detects double-free/UAF | 22.3 KB | view raw |
| mpls_stale_harness_fixed.c | trigger-source | fixed-code-path variant: struct mbuf **mp propagation; all scenarios PASS | 13.0 KB | view raw |
| mpls_trigger.c | trigger-source | live trigger: bpf BIOCSFEEDBACK inject of MPLS frame on vtnet0 | 3.0 KB | view raw |
| Makefile | build | KLD module Makefile for mpls.ko | 112 B | β download |
| build.sh | build-script | builds harness + trigger | 682 B | view raw |
| run.sh | run-script | runs unpatched vs fixed harness contrast | 1.2 KB | view raw |
| build.log | build-log | harness build output | 343 B | view raw |
| run.log | run-log | decisive unpatched harness run (scenarios A2/B double-free) | 3.8 KB | view raw |
| harness_unpatched.log | run-log | unpatched harness: DOUBLE-FREE + UAF CONFIRMED | 3.8 KB | view raw |
| harness_fixed.log | run-log | fixed harness: ALL PASS | 1.6 KB | view raw |
| module_build.log | build-log | unpatched mpls.ko build | 108 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: mpls_output(struct mbuf **mp) propagate head through *mp | 5.2 KB | view raw |
| fix_build.log | build-log | patched mpls.ko build (cc 8.3 -Werror, BUILD=0) | 3.6 KB | view raw |
| fix_run.log | run-log | fix validation: before/after contrast, patched module loads + runs clean | 1.4 KB | view raw |
| env.txt | environment | uname, cc version, kldstat | 614 B | view raw |
| VERDICT.md | verdict | full narrative: bug mechanism, path:line cites, exploitation assessment, fix validation | 10.4 KB | β raw |
| README.md | readme | human-readable summary + reproduce instructions | 2.9 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0753 β Stale mbuf pointer after mpls_output may reallocate β PoC & evidence
TL;DR
mpls_output(struct mbuf *m, ...) takes m by value. When mpls_push
(via M_PREPEND/m_prepend) or mpls_swap/mpls_pop (via m_pullup)
reallocate the head mbuf, the rebind only updates mpls_output's local copy.
The caller mpls_forward (mpls_input.c:208) still holds the old pointer:
- PUSH: old m is alive but no longer the chain head β if_output (:211)
receives a stale/wrong mbuf; on if_output error, m_freem(m) (:218)
double-frees.
- SWAP/POP: m_pullup frees the old m β caller's m is a dangling
pointer β true UAF + double-free.
Verdict: REPRODUCED (deterministic harness). Fix VALIDATED.
Impact: panic / memory corruption (UAF + double-free of mbuf in mbuf_zone).
On GENERIC (INVARIANTS ON) the slab allocator catches the double-free β panic
(DoS). Escalation to uid=0 is blocked by two valid hard blockers: INVARIANTS
catches the double-free before grooming lands, and mbufs are in a dedicated
slab zone (no cross-object victim for privilege escalation).
Files
| file | purpose |
|---|---|
mpls_stale_harness.c |
Primary deterministic proof. Transcribes mpls_forward/mpls_output/mpls_push/mpls_swap/mpls_pop/m_prepend/m_pullup verbatim with a poisoned allocator. Proves DOUBLE-FREE + UAF in scenarios A2/B. |
mpls_stale_harness_fixed.c |
Fix proof. Same code with struct mbuf **mp fix applied. All scenarios PASS. |
mpls_trigger.c |
Live trigger (bpf BIOCSFEEDBACK on vtnet0). Confirms 14-byte headroom prevents realloc on standard frames. |
Makefile |
KLD module Makefile for mpls.ko. |
fix.diff |
Standalone git apply-able fix: mpls_output(struct mbuf **mp, ...). |
VERDICT.md |
Full narrative with path:line cites, mechanism, before/after. |
build.log / run.log |
Harness build + decisive run (full untrimmed). |
harness_unpatched.log / harness_fixed.log |
Before/after harness contrast. |
module_build.log / fix_build.log / fix_run.log |
Module build + fix validation. |
env.txt |
Guest uname, cc version, kldstat. |
manifest.json |
Machine-readable artifact catalog. |
Reproduce
./build.sh # cc -O2 -Wall -o mpls_stale_harness mpls_stale_harness.c
./run.sh # runs unpatched + fixed harness, shows contrast
Expected: UNPATCHED shows *** DOUBLE-FREE CONFIRMED *** in scenarios A2/B;
FIXED shows ALL scenarios PASS.
Live trigger (requires root, needs mpls.ko)
cd /usr/src/sys/netproto/mpls && make && cp mpls.ko /boot/kernel/
kldload mpls.ko
./mpls_trigger vtnet0 # injects 1 MPLS frame; no panic (14-byte headroom)
Fix validation
cd /usr/src && git apply fix.diff
cd sys/netproto/mpls && make && cp mpls.ko /boot/kernel/
# cold boot (kldunload mpls crashes β separate domain-teardown bug)
kldload mpls.ko && ./mpls_trigger vtnet0 # no panic, no regression
DF-0753 β Stale mbuf pointer after mpls_output may reallocate (UAF / double-free)
Verdict: REPRODUCED (deterministic harness) β FIX VALIDATED
Impact: panic / memory corruption (UAF + double-free of an mbuf in the
mbuf slab zone). The bug is a genuine code defect: mpls_output() takes
struct mbuf *m by value, so when mpls_push/mpls_swap/mpls_pop
rebind the local m (via M_PREPEND/m_pullup), the caller mpls_forward
never sees the new head and proceeds to use a stale pointer β leading to
use-after-free at mpls_input.c:211 and double-free at mpls_input.c:218.
The bug is confirmed by a deterministic userspace harness that transcribes the
exact kernel code. Live triggering on a standard ethernet frame is blocked by
the 14-byte leading space from ether_input (β₯ max 12 bytes of PUSH), which
is documented below as the reason the harness is the primary proof.
Exploit chain ceiling: The primitive is a double-free/UAF of an mbuf
allocated from the dedicated mbuf_zone slab. On GENERIC (INVARIANTS ON),
the slab allocator's chunk_mark_free check catches the double-free and panics
before any grooming lands β this is a valid hard blocker for escalation on
the default kernel (see "Exploitation assessment" below). The realistic impact
is DoS (panic) on GENERIC, with theoretical heap corruption on INVARIANTS-OFF.
The bug β line-by-line trace (all citations confirmed in sys/)
Root cause: mpls_output takes m by value
sys/netproto/mpls/mpls_output.c:49-50:
int
mpls_output(struct mbuf *m, struct rtentry *rt) // m BY VALUE
Inside the loop (:74-126), three operations may reallocate the head mbuf:
PUSH (:77-91 β mpls_push :152-169):
M_PREPEND(*m, sizeof(struct mpls), M_NOWAIT); // mpls_output.c:157
M_PREPEND (sys/sys/mbuf.h:469-483) checks M_LEADINGSPACE. If insufficient,
it calls m_prepend() (sys/kern/uipc_mbuf.c:1500-1520) which allocates a
new head mbuf mn, runs M_MOVE_PKTHDR(mn, m) to move the pkthdr, chains
the old m as mn->m_next, and returns mn. The local *m (in mpls_push,
which takes struct mbuf **) is correctly updated β but only mpls_output's
local copy. mpls_forward's m is unchanged β stale.
SWAP (:92-103 β mpls_swap :171-194):
if (m->m_len < sizeof(struct mpls) &&
(m = m_pullup(m, sizeof(struct mpls))) == NULL) // :178 β local rebind
return (ENOBUFS);
mpls_swap takes struct mbuf *m by value. m_pullup
(sys/kern/uipc_mbuf.c:2103-2158) can free the old mbuf and return a new
one (:2122-2133: allocate new, M_MOVE_PKTHDR, copy data, m_free consumed
mbufs). The rebind m = m_pullup(...) only updates mpls_swap's local. Even
mpls_output's local is not updated (it called mpls_swap(m, ...) by value).
The caller's m is a dangling pointer to freed memory β true UAF.
POP (:104-121 β mpls_pop :196-212): same by-value issue as SWAP.
The victim caller: mpls_forward
sys/netproto/mpls/mpls_input.c:173-218:
static void
mpls_forward(struct mbuf *m)
{
...
error = mpls_output(m, cache_rt->ro_rt); // :208 β m by value
if (error)
goto bad;
error = (*ifp->if_output)(ifp, m, dst, cache_rt->ro_rt); // :211 β STALE m
if (error)
goto bad;
...
return;
bad:
m_freem(m); // :218 β DOUBLE-FREE
}
- :211 β
if_outputreceives the stalem. If PUSH reallocated, the oldmis alive (chained asmn->m_next) but is no longer the chain head β the freshly-pushed MPLS label lives only in the leakedmn. The forwarded packet is wrong (missing the label). If SWAP/POP reallocated viam_pullup,mpoints to freed memory β UAF. - :218 β
m_freem(m). If PUSH reallocated andif_outputconsumedm, this double-frees. If SWAP/POP'sm_pullupfreedm, this double-frees.
The same bug also manifests in mpls_output_process and ip_output
mpls_output.c:135-150βmpls_output_process(m, rt)also takesmby value and callsmpls_output(m, rt). On error it doesm_freem(m)(:145).sys/netinet/ip_output.c:695, 739β callers ofmpls_output_process. At:698/742,ifp->if_output(ifp, m, ...)uses the potentially-stalem.
This is the same root cause as DF-0754 (the mpls_output by-value bug).
Reproduction β deterministic harness (primary proof)
mpls_stale_harness.c transcribes mpls_forward/mpls_output/mpls_push/
mpls_swap/mpls_pop/m_prepend/m_pullup verbatim from the kernel, with
userspace mbuf stand-ins and a poisoned allocator (freed memory marked
0xdeadc0de, matching DragonFly's INVARIANTS WEIRD_ADDR).
Results (unpatched harness):
| Scenario | Condition | double_free | uaf | Verdict |
|---|---|---|---|---|
| A | PUSH, leading_space=2, if_output OK | 0 | 0 | stale head sent (logic bug) |
| A2 | PUSH, leading_space=2, if_output ERR | 1 | 1 | DOUBLE-FREE + UAF |
| B | SWAP, m_len=2 (fragmented) β m_pullup | 2 | 1 | DOUBLE-FREE + UAF |
| C | PUSH, leading_space=14 (normal ether) | 0 | 0 | no realloc (control) |
| D | 3 PUSHes (max rt_shim), leading_space=14 | 0 | 0 | no realloc (control) |
Scenarios A2 and B deterministically prove the stale-pointer β double-free/UAF.
Scenarios C and D prove the bug is headroom-dependent: standard ethernet
frames (14-byte headroom from ether_input) never trigger m_prepend because
max PUSH = 3 Γ 4 = 12 < 14.
Fixed harness (mpls_stale_harness_fixed.c):
Applies the fix: mpls_output/mpls_swap/mpls_pop take struct mbuf **mp
and write *mp = m after every rebind. mpls_forward passes &m.
| Scenario | double_free | uaf | Verdict |
|---|---|---|---|
| A | 0 | 0 | PASS |
| A2 | 0 | 0 | PASS |
| B | 0 | 0 | PASS |
ALL scenarios pass β the fix eliminates the stale pointer.
Live trigger attempt
Built mpls.ko from /usr/src/sys/netproto/mpls/ (Makefile reused from
DF-0751). kldload mpls.ko succeeded. Injected a crafted MPLS frame
(EtherType 0x8847, label=100, S=1, TTL=64) via bpf BIOCSFEEDBACK on vtnet0.
Result: No panic, guest stays up. This corroborates the harness control
scenarios (C/D): the 14-byte ethernet headroom prevents m_prepend from firing
on standard frames. The AF_MPLS routing table did not fully attach when MPLS
was loaded as a KLD (route add -mpls ... β "Address family not supported"),
so the live path could not reach mpls_output with a PUSH rt_shim. The
harness is therefore the primary deterministic proof, per the DF-0265/0594/
0616/0393/0751 precedent (code-level harness for precondition-sensitive bugs).
Exploitation assessment (why escalation to uid=0 is blocked)
The primitive is a double-free / UAF of an mbuf. Escalation to uid=0 is
blocked by two valid hard blockers:
-
INVARIANTS ON (GENERIC) catches the double-free before grooming lands.
sys/kern/kern_slaballoc.chas 17 INVARIANTS-gated slab checks (chunk_mark_allocated/chunk_mark_free,WEIRD_ADDR0xdeadc0de poisoning). A double-free of an mbuf triggerschunk_mark_freewhich detects the already- freed state and panics. The corruption never silently lands β it manifests as a panic (DoS), not controllable heap corruption. Switching tonoinvwould allow silent corruption, but that is a non-default kernel result per the bright-line rule. -
mbufs are allocated from a dedicated slab zone (
mbuf_zone), not the generalkmallocpool. Cross-zone exploitation (placing astruct ucredor function-pointer-bearing object adjacent to the freed mbuf) is not possible without a separate zone-confusion bug. The double-free reclaims withinmbuf_zoneonly β the victim object is always another mbuf, which does not contain a function pointer,ucred *, oruidfield that would allow privilege escalation.
These are genuine hard blockers (read the Phase 6 valid-blocker list: "KASSERT
trips before I corrupt" is a blocker on GENERIC; dedicated slab zones prevent
cross-object grooming). The realistic impact on the default GENERIC kernel
is panic / DoS. On noinv (INVARIANTS OFF, non-default), the double-free
could be exploited for heap corruption within mbuf_zone, but the victim
object is another mbuf β privilege escalation would require an additional
chain component.
No escalation chain was developed because both valid hard blockers apply.
A panic (DoS) is the demonstrated impact on GENERIC.
The fix (validated β fix.diff)
Change mpls_output to take struct mbuf **mp and propagate the new head
through *mp at every rebind. Apply the same change to mpls_swap and
mpls_pop. Update all callers.
Files changed:
sys/netproto/mpls/mpls_var.h:59-60β update prototypessys/netproto/mpls/mpls_output.c:44-128βmpls_output(struct mbuf **mp, ...), addstruct mbuf *m = *mp;at top,*mp = m;before returnsys/netproto/mpls/mpls_output.c:46-47βmpls_swap/mpls_poptakestruct mbuf **sys/netproto/mpls/mpls_output.c:171-212β write*mp = mafterm_pulluprebindssys/netproto/mpls/mpls_output.c:134-150βmpls_output_process(struct mbuf **mp, ...)sys/netproto/mpls/mpls_input.c:208βmpls_output(&m, ...)(caller passes&m)sys/netinet/ip_output.c:695,739βmpls_output_process(&m, ...)
This closes DF-0753 and DF-0754 (same root cause).
Fix validation (Phase 8 β single-fix module built + loaded):
unpatched mpls.ko (sha256 87133dβ¦) |
patched mpls.ko (sha256 72fb38β¦) |
|
|---|---|---|
| module build | cc 8.3, -Werror, BUILD=0 |
cc 8.3, -Werror, BUILD=0 |
| kldload | LOAD=0 | LOAD=0 |
| trigger 3Γ | no panic (14-byte headroom prevents realloc) | no panic, no regression |
| harness A2 | DOUBLE-FREE CONFIRMED | PASS (zero double-free) |
| harness B | DOUBLE-FREE + UAF CONFIRMED | PASS (zero double-free) |
The fix compiles cleanly, loads without regression, and the fixed harness proves the stale-pointer/double-free is structurally eliminated.
Note: kldunload mpls crashes the guest (page fault in pffindtype β the
MPLS KLD module's domain-teardown path doesn't properly deregister the protocol
domain). This is a separate infrastructure bug in the module unload path, not
related to the DF-0753 fix. Fix validation was done by cold-booting with the
patched module rather than hot-swapping.
Fix verification
fixedVALIDATED. UNPATCHED harness: scenarios A2/B print DOUBLE-FREE CONFIRMED + UAF CONFIRMED. PATCHED harness (mpls_output takes struct mbuf *mp, propagates mp=m): ALL 3 scenarios PASS with zero double-free and zero UAF. Patched mpls.ko (cc 8.3 -Werror, BUILD=0) loaded on cold boot (kldunload mpls crashes via pffindtype - separate KLD domain-teardown bug), live trigger ran 3x with no panic and no regression. The stale-pointer/double-free/UAF primitives present in unpatched code are structurally eliminated by the fix. Note: live trigger on standard frames shows no panic in BOTH unpatched and patched (14-byte ether headroom >= max 12-byte PUSH prevents m_prepend); the harness is the deterministic before/after proof.
fix.diff (202 lines, git apply --check=0 on clean /usr/src). fix_build.log: 'FIX_MODULE_BUILD=0' sha256 72fb3810.... harness_unpatched.log scenario A2: 'double_free=1 uaf=1 *** DOUBLE-FREE CONFIRMED ***'. harness_fixed.log scenario A2: 'double_free=0 uaf=0 PASS: no double-free'. harness_fixed.log final: 'RESULT: ALL scenarios PASS - fix eliminates stale pointer.'
Confirmed kernel references
- sys/netproto/mpls/mpls_input.c:208
- sys/netproto/mpls/mpls_input.c:211
- sys/netproto/mpls/mpls_input.c:218
- sys/netproto/mpls/mpls_output.c:50
- sys/netproto/mpls/mpls_output.c:78
- sys/netproto/mpls/mpls_output.c:100
- sys/netproto/mpls/mpls_output.c:112
- sys/netproto/mpls/mpls_output.c:143
- sys/netproto/mpls/mpls_output.c:157
- sys/netproto/mpls/mpls_output.c:178
- sys/netproto/mpls/mpls_output.c:202
- sys/kern/uipc_mbuf.c:1500
- sys/kern/uipc_mbuf.c:2103
- sys/sys/mbuf.h:469
- sys/netinet/ip_output.c:695
- sys/netinet/ip_output.c:739
- sys/netproto/mpls/mpls.h:96
Detail
Exploit chain
blocked by two VALID hard blockers (Phase 6): (1) INVARIANTS ON on GENERIC - sys/kern/kern_slaballoc.c has 17 INVARIANTS-gated slab checks (chunk_mark_free, WEIRD_ADDR=0xdeadc0de poisoning) that catch the mbuf double-free and panic before any heap grooming lands; the corruption never silently reaches a victim object. (2) mbufs are allocated from the DEDICATED mbuf_zone slab, not the general kmalloc pool - cross-zone placement of a struct ucred / function-pointer ops vector / uid-bearing object adjacent to the freed mbuf is impossible without a separate zone-confusion bug; the double-free can only reclaim within mbuf_zone where every victim is another mbuf (no privilege-escalation field). Both are genuine blockers from the Phase-6 valid list ('KASSERT trips before I corrupt' on GENERIC; dedicated slab zone prevents cross-object grooming). On noinv (INVARIANTS OFF, non-default) the double-free would be silent but still confined to mbuf_zone. No escalation chain developed - realistic impact is panic/DoS on the default GENERIC kernel. No exploit.c written because no chain is feasible within the mbuf_zone on an INVARIANTS kernel.
Evidence (decisive lines)
UNPATCHED harness (findings/poc/DF-0753/harness_unpatched.log): Scenario A2: 'allocs=2 frees=1 double_free=1 uaf=1' -> '*** DOUBLE-FREE CONFIRMED (DF-0753 mpls_input.c:218) ***' + '*** USE-AFTER-FREE CONFIRMED (stale m in mpls_forward) ***'. Scenario B: 'allocs=3 frees=2 double_free=2 uaf=1' -> same CONFIRMED. FIXED harness (harness_fixed.log): all 3 scenarios 'PASS: no double-free (fix eliminates stale pointer)' -> 'RESULT: ALL scenarios PASS'. Patched mpls.ko build: 'FIX_MODULE_BUILD=0' sha256 72fb3810... (cc 8.3 -Werror). Live trigger 3x on patched module: no panic, guest up.
PoC changes
Created findings/poc/DF-0753/ from scratch. mpls_stale_harness.c: deterministic proof transcribing mpls_forward/mpls_output/mpls_push/mpls_swap/mpls_pop + m_prepend/m_pullup verbatim with poisoned allocator (0xdeadc0de). mpls_stale_harness_fixed.c: same code with struct mbuf mp fix, all scenarios PASS. mpls_trigger.c: live bpf BIOCSFEEDBACK trigger. Makefile: KLD module build (reused from DF-0751). fix.diff: git-apply-able fix changing mpls_output/mpls_swap/mpls_pop to struct mbuf mp. VERDICT.md/README.md/manifest.json + full logs.
Verified recommended fix
Change mpls_output(struct mbuf m, ...) to mpls_output(struct mbuf mp, ...) at sys/netproto/mpls/mpls_output.c:50; add mp=m write-back before every return. Change mpls_swap/mpls_pop to take struct mbuf mp and write *mp=m after m_pullup rebinds (:178, :202). Update mpls_output_process to struct mbuf mp. Update callers: mpls_forward passes &m (mpls_input.c:208), ip_output passes &m (ip_output.c:695,739). Closes DF-0753 AND DF-0754 (same root cause). Full diff in findings/poc/DF-0753/fix.diff (git apply --check passes). Matches finding proposal (mpls_output->struct mbuf **mp).
Verdict
REPRODUCED via deterministic harness. The bug is real: mpls_output(struct mbuf m, struct rtentry rt) at sys/netproto/mpls/mpls_output.c:50 takes m BY VALUE. mpls_push (:157 M_PREPEND->m_prepend at sys/kern/uipc_mbuf.c:1500) and mpls_swap/mpls_pop (:178/:202 m_pullup at sys/kern/uipc_mbuf.c:2103) rebind the LOCAL m on realloc, but the caller mpls_forward (mpls_input.c:208) never sees the new head. Line :211 if_output(ifp, stale_m, ...) = UAF; line :218 m_freem(stale_m) = DOUBLE-FREE. The harness transcribes these functions verbatim with a poisoned allocator (0xdeadc0de): scenario A2 (PUSH no-headroom + if_output error) prints 'DOUBLE-FREE CONFIRMED' + 'USE-AFTER-FREE CONFIRMED'; scenario B (SWAP fragmented->m_pullup) prints both. Control scenarios C/D prove the bug is headroom-dependent: standard ethernet frames have 14-byte leading space from ether_input >= max 12-byte PUSH (3 rt_shim * MPLS_SHIM_LEN=4, MPLS_MAXLOPS=3 at mpls.h:96), so m_prepend never fires on normal frames - the live trigger (bpf BIOCSFEEDBACK) confirms no panic, matching the control. Live PUSH-route triggering was also blocked because AF_MPLS routing did not fully attach when MPLS was KLD-loaded ('route add -mpls' -> 'Address family not supported'). The harness is the primary deterministic proof (DF-0265/0594/0616/0751 precedent for precondition-sensitive bugs).
No comments yet.