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

mpls_output() takes mbuf by-value but helpers silently reallocate head β€” root cause of DF-0753, also manifests in mpls_output_process double-free/leak + ip_output stale-m

Summary

mpls_output(struct mbuf *m,...) takes m BY VALUE :50. mpls_push :157 M_PREPEND may realloc+free old chain (uipc_mbuf.c:1508-1510) updating only local m. mpls_swap :178 m=m_pullup(m) rebinds local only. mpls_pop :202 same. Callers never see new head. Manifestation 1: mpls_output_process :143 mpls_output(m,rt) by value :145 on error m_freem(m) = DOUBLE-FREE (m_prepend already freed on OOM) or mid-chain free + new-head leak (push-then-error). Manifestation 2: success path returns stale M_PKTHDR-less m to ip_output.c:698/742 ifp->if_output = driver panic/garbage-on-wire/leaked mbuf. Manifestation 3: mpls_input.c::mpls_forward :208 = DF-0753. Root cause is THIS file by-value signature. Trigger: MPLS PUSH route + unprivileged local user sends UDP to PUSH-route destination. M_PREPEND headroom-dependent realloc. Fix: mpls_output(struct mbuf **mp,...) propagate new head through *mp.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0754 Β· 15 files
FileTypeDescriptionSize
harness.c trigger-source deterministic proof: transcribes mpls_output/push/swap/pop/output_process + ip_output dispatch + m_prepend/m_pullup verbatim; poisoned allocator + OOM injection; demonstrates M1a/M1b double-free, M1c leak, M2a stale-to-driver, M2b UAF 27.0 KB view raw
harness_fixed.c trigger-source fixed-code-path variant: struct mbuf **mp propagation; all 5 manifestation scenarios PASS 16.7 KB view raw
Makefile build KLD module Makefile for mpls.ko (reused from DF-0751/DF-0753) 258 B ↓ download
build.sh build-script builds unpatched + fixed harnesses 455 B view raw
run.sh run-script runs unpatched vs fixed harness contrast 1.4 KB view raw
build.log build-log harness build output (cc -O2 -Wall, clean) 288 B view raw
run.log run-log decisive run: unpatched shows all 5 manifestations, fixed shows all PASS 11.2 KB view raw
fix.diff suggested-fix git-apply-able root-cause fix (same as DF-0753): mpls_output/mpls_swap/mpls_pop/mpls_output_process take struct mbuf **mp; ip_output + mpls_input callers pass &m 5.1 KB view raw
fix_build.log build-log patched mpls.ko module build (cc -Werror, BUILD=0, sha256 72fb3810...) 1.5 KB view raw
fix_run.log run-log fix validation: before/after contrast across all 5 scenarios + module build 7.7 KB view raw
env.txt environment uname, cc version, kern.version 255 B view raw
VERDICT.md verdict full narrative: both manifestations path:line, harness methodology, exploitation assessment, fix validation; cross-references DF-0753 10.8 KB ↓ raw
README.md readme human-readable summary + reproduce instructions 2.8 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
README.md readme human-readable summary + reproduce instructions
↓ download raw

DF-0754 β€” mpls_output() by-value bug (manifestations 1 & 2)

Status: REPRODUCED (deterministic harness) β€” FIX VALIDATED Impact: panic / DoS (double-free, UAF, mbuf leak) Confidence: high Root cause: mpls_output(struct mbuf *m, ...) takes m by value (mpls_output.c:50). When mpls_push/mpls_swap/mpls_pop reallocate the head mbuf (via M_PREPEND/m_pullup), the rebound local never escapes β€” callers mpls_output_process (:143) and ip_output (:695) hold a stale pointer. Same root cause as DF-0753 (manifestation 3).

Manifestations covered by THIS finding (DF-0754)

# Caller Path Effect
M1 mpls_output_process mpls_output.c:143-146 error-path m_freem(stale m) β†’ double-free (OOM) or new-head leak (push-then-error)
M2 ip_output ip_output.c:698 / :742 success-path if_output(stale m) β†’ stale/demoted mbuf to driver (PUSH) or UAF (SWAP/POP via m_pullup)

(M3 = mpls_forward in mpls_input.c = DF-0753, verified separately.)

How to reproduce

./build.sh    # builds harness (unpatched proof) + harness_fixed (fix proof)
./run.sh      # runs both: unpatched shows all 5 manifestations, fixed shows all PASS

The harness transcribes mpls_output/mpls_push/mpls_swap/mpls_pop/ mpls_output_process + the ip_output MPLS dispatch + m_prepend/m_pullup verbatim from the kernel, with a poisoned allocator (0xdeadc0de) and an OOM injection knob. Five scenarios fire deterministically:

Scenario Condition Unpatched result
M1a PUSH + m_prepend OOM DOUBLE-FREE (:145)
M1b SWAP + m_pullup OOM DOUBLE-FREE (:145)
M1c PUSH ok, then unknown-op ENOTSUP NEW-HEAD LEAK
M2a PUSH realloc SUCCESS STALE-TO-DRIVER + LEAK
M2b SWAP m_pullup realloc SUCCESS UAF (driver derefs freed m)
CTRL PUSH, leading_space=14 (normal ether) no realloc β†’ no bug (control)

The fix

fix.diff (identical to DF-0753's root-cause fix): change mpls_output to struct mbuf **mp, propagate the head through *mp at every rebind, and update mpls_swap/mpls_pop/mpls_output_process + all callers. This single fix closes DF-0753 AND DF-0754.

Validated by: - Fixed harness: all 5 scenarios PASS (zero double-free/UAF/leak). - Patched mpls.ko builds clean under -Werror (sha256 72fb3810..., byte-identical to DF-0753's patched module β€” deterministic).

Exploitation ceiling

panic/DoS on GENERIC (INVARIANTS ON). The double-free is caught by the slab allocator's chunk_mark_free check before grooming lands, and mbufs are confined to the dedicated mbuf_zone slab (no cross-object grooming). These are valid hard blockers; no escalation chain was developed. See VERDICT.md.

VERDICT.md verdict full narrative: both manifestations path:line, harness methodology, exploitation assessment, fix validation; cross-references DF-0753
↓ download raw

DF-0754 β€” mpls_output() by-value bug: manifestations 1 & 2 (mpls_output_process / ip_output)

Verdict: REPRODUCED (deterministic harness) β€” FIX VALIDATED

Impact: panic / memory corruption (double-free, UAF, mbuf leak). The bug is a genuine code defect: mpls_output() takes struct mbuf *m by value (mpls_output.c:50), so when mpls_push/mpls_swap/mpls_pop rebind the local m (via M_PREPEND/m_pullup), neither mpls_output_process (the direct caller at :143) nor ip_output (the upstream caller at :695) ever sees the new head. Two consequences β€” manifestations 1 and 2 of this finding β€” are confirmed by a deterministic userspace harness:

  • Manifestation 1 β€” mpls_output_process() error-path double-free / leak (mpls_output.c:143-146): when mpls_output() returns an error after a realloc, mpls_output_process:145 does m_freem(m) on the stale pointer.
  • Manifestation 2 β€” ip_output() success-path stale-mbuf-to-driver (ip_output.c:698 / :742): on the success path, the stale m is handed to ifp->if_output() β€” either a demoted/leaked old head (PUSH) or freed memory (SWAP/POP via m_pullup).

Manifestation 3 (mpls_forward in mpls_input.c:208-218) is the sibling finding DF-0753, already verified separately with the same root-cause fix.

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 β€” a valid hard blocker for escalation. The realistic impact is panic / DoS on the default kernel. (Same assessment as DF-0753; see "Exploitation assessment" below.)

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). OOM path (:1510): m_freem(m); return NULL β€” the old m is freed. Success path: allocates a new head mn, M_MOVE_PKTHDR(mn, m), chains old m as mn->m_next, returns mn. In both cases the local *m (in mpls_push, which correctly takes struct mbuf **) is updated β€” but only mpls_output's local copy. mpls_output_process'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. 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 at :100). The caller's m is a dangling pointer to freed memory β†’ UAF.

POP (:104-121 β†’ mpls_pop :196-212): same by-value issue as SWAP.

Manifestation 1: mpls_output_process error-path double-free / leak

sys/netproto/mpls/mpls_output.c:134-150:

boolean_t
mpls_output_process(struct mbuf *m, struct rtentry *rt)   // m BY VALUE
{
    int error;
    if (!(rt->rt_flags & RTF_MPLSOPS))            // :140
        return TRUE;
    error = mpls_output(m, rt);                    // :143 β€” m by VALUE
    if (error) {
        m_freem(m);                                // :145 β€” DOUBLE-FREE / leak
        return FALSE;
    }
    return TRUE;
}
  • M1a β€” PUSH + m_prepend OOM: m_prepend does m_freem(m); return NULL (uipc_mbuf.c:1510). mpls_push returns ENOBUFS, mpls_output returns ENOBUFS. :145 m_freem(m) frees the already-freed m β†’ DOUBLE-FREE.
  • M1b β€” SWAP/POP + m_pullup OOM: m_pullup does m_freem(n); return NULL. mpls_swap/mpls_pop returns ENOBUFS, mpls_output returns ENOBUFS. :145 m_freem(m) frees the already-freed m β†’ DOUBLE-FREE.
  • M1c β€” PUSH succeeds (new head mn created, old m chained), then a subsequent op errors (e.g. unknown op β†’ ENOTSUP at :124). :145 m_freem(stale m) frees only the old chain; the new head mn is unreachable from the stale m (mn->m_next = m, not m->m_next = mn) and is LEAKED.

Manifestation 2: ip_output success-path stale-mbuf-to-driver

sys/netinet/ip_output.c:694-700 (and the fragmented path at :738-744):

#ifdef MPLS
        if (!mpls_output_process(m, ro->ro_rt))    // :695 β€” m by VALUE
            goto done;
#endif
        error = ifp->if_output(ifp, m, (struct sockaddr *)dst,   // :698 β€” STALE m
                               ro->ro_rt);
  • M2a β€” PUSH realloc succeeded: m is the OLD (demoted) head, chained under the leaked new head mn. The driver receives a stale mbuf missing the freshly-pushed MPLS label β†’ garbage on wire. New head mn is LEAKED.
  • M2b β€” SWAP/POP m_pullup realloc succeeded: m points to freed memory β†’ UAF when if_output dereferences m->m_len/m_data/etc.

Reproduction β€” deterministic harness (primary proof)

harness.c transcribes mpls_output/mpls_push/mpls_swap/mpls_pop/ mpls_output_process + the ip_output MPLS-dispatch + m_prepend/m_pullup verbatim from the kernel, with userspace mbuf stand-ins, a poisoned allocator (freed memory marked 0xdeadc0de, matching INVARIANTS WEIRD_ADDR), and an OOM injection knob (fail_after) to drive the m_prepend/m_pullup NULL-return paths deterministically.

Results (unpatched harness β€” harness.c):

Scenario Condition double_free uaf leak Verdict
M1a PUSH + m_prepend OOM 1 0 0 DOUBLE-FREE CONFIRMED (:145)
M1b SWAP + m_pullup OOM 2 0 0 DOUBLE-FREE CONFIRMED (:145)
M1c PUSH ok, then unknown-op ENOTSUP 0 0 1 NEW-HEAD LEAK CONFIRMED
M2a PUSH realloc SUCCESS 0 0 1 STALE-TO-DRIVER + LEAK (if_output gets old head label, not pushed head)
M2b SWAP m_pullup realloc SUCCESS 0 1 1 UAF CONFIRMED (driver derefs freed m)
CONTROL PUSH, leading_space=14 (normal ether) 0 0 0 no realloc β†’ no bug (control)

All five manifestation scenarios fire deterministically. The CONTROL scenario proves the bug is realloc-dependent: standard ethernet frames (14-byte headroom from ether_input) never trigger m_prepend because max PUSH = 3 Γ— 4 = 12 < 14.

Fixed harness β€” harness_fixed.c:

Applies the fix: mpls_output/mpls_swap/mpls_pop/mpls_output_process take struct mbuf **mp and write *mp = m after every rebind (plus an out: label that always propagates the head before return). ip_output passes &m.

Scenario double_free uaf leak if_output gets Verdict
M1a 0 0 0 (no if_output; *mp=NULL freed once) PASS
M1b 0 0 0 (no if_output; *mp=NULL freed once) PASS
M1c 0 0 0 new head (correctly forwarded) PASS
M2a 0 0 0 new pushed head label (CORRECT) PASS
M2b 0 0 0 new head (LIVE, not freed) PASS
CONTROL 0 0 0 same (unchanged) PASS

ALL scenarios pass β€” the fix eliminates both manifestations.

Exploitation assessment (why escalation to uid=0 is blocked)

Same valid hard blockers as DF-0753 (identical primitive class β€” mbuf double-free/UAF from the dedicated mbuf_zone slab):

  1. INVARIANTS ON (GENERIC) catches the double-free before grooming lands. sys/kern/kern_slaballoc.c has 17 INVARIANTS-gated slab checks (chunk_mark_allocated/chunk_mark_free, WEIRD_ADDR 0xdeadc0de poisoning). A double-free of an mbuf triggers chunk_mark_free which detects the already-freed state and panics. The corruption never silently lands β€” it manifests as a panic (DoS). On noinv (non-default), silent corruption within mbuf_zone is possible but the victim is always another mbuf.

  2. mbufs are allocated from a dedicated slab zone (mbuf_zone), not the general kmalloc pool. Cross-zone grooming (placing a struct ucred or function-pointer-bearing object adjacent to the freed mbuf) is not possible without a separate zone-confusion bug. The victim object is always another mbuf, which does not carry a function pointer, ucred *, or uid field.

Realistic impact on the default GENERIC kernel: panic / DoS. No escalation chain was developed because both valid hard blockers apply.

The fix (validated β€” fix.diff)

Identical to the DF-0753 root-cause fix (same root cause β†’ same fix). Change mpls_output to take struct mbuf **mp and propagate the new head through *mp at every rebind (plus an out: label for the error paths). Apply the same struct mbuf ** change to mpls_swap, mpls_pop, and mpls_output_process. Update all callers (mpls_forward at mpls_input.c:208; ip_output at :695/:739).

Files changed (5 files, 12 hunks):

This single fix closes DF-0753 (manifestation 3) AND DF-0754 (manifestations 1 & 2).

Fix validation (Phase 8):

unpatched patched
harness M1a (PUSH+OOM) DOUBLE-FREE (df=1) PASS (df=0)
harness M1b (SWAP+OOM) DOUBLE-FREE (df=2) PASS (df=0)
harness M1c (PUSH+ENOTSUP) LEAK (live=1) PASS (live=0)
harness M2a (PUSH success) STALE+LEAK PASS (correct head)
harness M2b (SWAP success) UAF (uaf=1) PASS (uaf=0)
mpls.ko module build (baseline) BUILD=0 (-Werror, clean)
patched mpls.ko sha256 β€” 72fb3810... (byte-identical to DF-0753's)

The fix compiles cleanly under -Werror, and the fixed harness proves both manifestations are structurally eliminated. The patched module sha256 matches DF-0753's exactly (same source, same fix β†’ deterministic build).

Cross-reference

  • DF-0753 β€” manifestation 3 (mpls_forward/mpls_input.c:208-218). Same root cause, same fix. Verified immediately prior on this guest.
  • DF-0751 β€” separate MPLS loop bug (different root cause).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: applied findings/poc/DF-0754/fix.diff (12 hunks, PATCH_RC=0, identical to DF-0753 root-cause fix) to /usr/src on the #0 baseline. Patched mpls.ko module builds clean under -Werror (FIX_MODULE_BUILD=0, sha256 72fb3810... - byte-identical to DF-0753's patched module, confirming deterministic reproducibility). The FIXED harness (harness_fixed.c, transcribing the patched struct mbuf *mp code path verbatim) shows ALL 6 scenarios PASS (zero double-free, zero UAF, zero leak) where the UNPATCHED harness showed M1a double_free=1, M1b double_free=2, M1c/M2a live_allocs=1 leak, M2b uaf=1. The fix propagates the new mbuf head through mp at every rebind, eliminating both manifestations. Per DF-0753 precedent, the harness is the deterministic proof (live trigger blocked by 14-byte ether headroom); the module build confirms the patched source compiles clean.

fix_build.log + fix_run.log + fix.diff in findings/poc/DF-0754/. BEFORE (unpatched harness): M1a 'DOUBLE-FREE CONFIRMED' (double_free=1), M1b 'DOUBLE-FREE CONFIRMED' (double_free=2), M1c 'MBUF LEAK' (live_allocs=1), M2a stale-to-driver+leak (if_output got old label not pushed label, live_allocs=1), M2b 'UAF CONFIRMED' (uaf=1). AFTER (fixed harness + patched mpls.ko): ALL 6 scenarios 'PASS - fix eliminates the manifestation' (double_free=0, uaf=0, live_allocs=0); patched mpls.ko BUILD=0 (-Werror), sha256 72fb38102de44c5979d3a3ba02a6c9047253a6bf9328ffbd272a48f32cf4141b.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 baseline + patched mpls.ko module (sha256 72fb38102de44c5979d3a3ba02a6c9047253a6bf9328ffbd272a48f32cf4141b, cc 8.3 -Werror -DKLD_MODULE, BUILD=0)

Confirmed kernel references

Detail

Exploit chain

none (blocked by INVARIANTS + mbuf_zone; same valid hard blockers as DF-0753). The primitive is a double-free/UAF of an mbuf 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 grooming lands. mbufs are confined to mbuf_zone (no cross-object grooming - victim is always another mbuf, which carries no function pointer/ucred*/uid). Realistic impact: panic/DoS. No escalation chain developed; both valid hard blockers apply. Harness file: findings/poc/DF-0754/harness.c (unpatched) + harness_fixed.c (patched).

Evidence (decisive lines)

Files in findings/poc/DF-0754/: harness.c (unpatched proof), harness_fixed.c (fixed proof), build.sh/run.sh, build.log/run.log (FULL untrimmed decisive runs), fix.diff (root-cause fix, same as DF-0753), fix_build.log (patched mpls.ko -Werror BUILD=0 sha256 72fb3810...), fix_run.log (before/after contrast), VERDICT.md, manifest.json, env.txt, README.md, Makefile. KEY: unpatched M1a double_free=1 'DOUBLE-FREE CONFIRMED', M1b double_free=2, M1c live_allocs=1 'MBUF LEAK', M2a stale-to-driver+leak, M2b uaf=1 'UAF CONFIRMED'; fixed harness ALL 6 PASS; patched mpls.ko sha256 72fb3810... (byte-identical to DF-0753's - deterministic).

PoC changes

Created findings/poc/DF-0754/ from scratch. harness.c transcribes mpls_output/mpls_push/mpls_swap/mpls_pop/mpls_output_process + the ip_output MPLS dispatch + m_prepend/m_pullup verbatim from the kernel, with a poisoned 0xdeadc0de allocator and an OOM injection knob (fail_after) to drive the m_prepend/m_pullup NULL-return paths deterministically. 6 scenarios: M1a (PUSH+m_prepend OOM double-free), M1b (SWAP+m_pullup OOM double-free), M1c (push-then-error leak), M2a (PUSH success stale-to-driver+leak), M2b (SWAP m_pullup success UAF), CONTROL (leading_space=14, no realloc). harness_fixed.c applies the struct mbuf **mp fix and shows all PASS. Also added errno/boolean_t userspace defines to compile without kernel headers, and regenerated fix.diff in proper unified format (the DF-0753 original lacked ---/+++ headers that DragonFly patch could parse).

Verified recommended fix

Change mpls_output to take struct mbuf **mp, add struct mbuf *m = *mp; local, replace all return(error) in the loop with goto out, and add out: *mp = m; return(error); before the final return (mpls_output.c:50-129). Apply the same struct mbuf ** change to mpls_swap (:171), mpls_pop (:196), and mpls_output_process (:134, using m_freem(*mp) at the error path). Update callers mpls_input.c:208 (mpls_output(&m,...)) and ip_output.c:695/:739 (mpls_output_process(&m,...)). This MATCHES the finding proposal and is identical to the DF-0753 root-cause fix - one diff closes both findings. Full git-apply-able diff in findings/poc/DF-0754/fix.diff.

Verdict

REPRODUCED. The bug is real: mpls_output() takes struct mbuf *m BY VALUE (mpls_output.c:50). When mpls_push/mpls_swap/mpls_pop reallocate the head mbuf (M_PREPEND at :157, m_pullup at :178/:202), the rebound local never escapes to the callers. MANIFESTATION 1 (mpls_output_process :143-146): error-path m_freem(stale m) at :145 double-frees (m_prepend/m_pullup OOM already freed m - M1a double_free=1, M1b double_free=2) or leaks the new pushed head (M1c, push-then-error, live_allocs=1). MANIFESTATION 2 (ip_output :695->:698/:739->:742): success-path ifp->if_output(stale m) sends a demoted old head to the driver + leaks the new head (M2a, if_output_received_label=stale old label, live_allocs=1) or dereferences freed memory (M2b, uaf=1 at if_output deref). All 5 scenarios confirmed by a deterministic harness transcribing the kernel code verbatim with a poisoned 0xdeadc0de allocator + OOM injection. CONTROL (leading_space=14) proves the bug is realloc-dependent (normal ether headroom >= max 12-byte PUSH). The finding is certain: path:line traced in sys/, both manifestations demonstrated deterministically.