# DF-0751 — MPLS explicit-NULL label 0/2 infinite loop (single-packet remote DoS)

## Verdict: REPRODUCED (live kernel + harness) — FIX VALIDATED

**Impact:** `dos` / hard-hang. A single MPLS Ethernet frame (EtherType 0x8847,
label=0 or label=2 with the bottom-of-stack bit CLEAR) drives `mpls_input()`
into an infinite `goto again` loop that never advances the mbuf cursor,
pinning one CPU's netisr thread at 100% while holding the mplock. No panic,
no dmesg, no recovery short of power-cycling the guest. Remote unauthenticated
on-link attacker. `exploit_chain: none` (pure busy-loop DoS — no memory
corruption, no escalation primitive).

## The bug (confirmed line-by-line in `sys/netproto/mpls/mpls_input.c`)

The `mpls_input()` label-switch loop at `mpls_input.c:88-171`:

```
again:                                          // :101
    mpls = mtod(m, struct mpls*);               // :110  — reads CURRENT label
    label = MPLS_LABEL(ntohl(mpls->mpls_shim)); // :111
    switch (label) {
    case 0:                                     // :113  — IPv4 explicit NULL
        if (MPLS_STACK(ntohl(mpls->mpls_shim))) {  // :117 — S-bit set?
            m_adj(m, sizeof(struct mpls));      // :119  — CORRECT: advance
            netisr_queue(NETISR_IP, m);
            return;
        }
        goto again;   // :123  *** BUG: NO m_adj before goto ***

    case 2:                                     // :132  — IPv6 explicit NULL
        if (MPLS_STACK(ntohl(mpls->mpls_shim))) {  // :136
            m_adj(m, sizeof(struct mpls));      // :138  — CORRECT
            netisr_queue(NETISR_IPV6, m);
            return;
        }
        goto again;   // :142  *** BUG: NO m_adj before goto ***
```

On the S-bit-CLEAR path, `goto again` jumps back to `:101` **without calling
`m_adj(m, sizeof(struct mpls))`**. `mtod(m)` therefore returns the *same*
pointer every iteration, the *same* `mpls_shim` is re-read, the *same* `case 0`
branch is taken, and the loop never terminates. There is **no depth counter,
no iteration cap, no TTL decrement** anywhere in `mpls_input()` or its caller.
The caller `mpls_input_handler()` (`:77-85`) only does `get_mplock()` /
`mpls_input()` / `rel_mplock()`, so the spinning thread holds the mplock
forever, wedging the whole kernel.

## Reachability

MPLS is `optional mpls` (`sys/conf/files:1865-1868`) and is **not compiled
into X86_64_GENERIC** (no `mpls` symbols in `/boot/kernel/kernel`). However it
builds cleanly as a KLD module (`mpls.ko`) — the audit guest has full `/usr/src`
and `gcc 8.3`. An administrator who needs MPLS (`kldload mpls`) gets the
vulnerable code path.

The dispatch chain (all verified in source):
- `sys/net/if_ethersubr.c:1146-1150` — `ETHERTYPE_MPLS` (0x8847) → `NETISR_MPLS`
- `sys/netproto/mpls/mpls_proto.c` — `DOMAIN_SET(mpls)` → `mpls_init()` via `pr_init`
- `mpls_input.c:73` — `netisr_register(NETISR_MPLS, mpls_input_handler, mpls_hashfn)`
- `mpls_demux.c:mpls_hashfn` — only checks `>= 4 bytes` (`mpls_lengthcheck`) and
  reads the first label for hashing. **No depth validation.**

## Reproduction — TWO independent proofs

### Proof 1: Live kernel (guest hard-wedge)

1. Built `mpls.ko` from clean `/usr/src/sys/netproto/mpls` (Makefile:
   `KMOD=mpls; SRCS=mpls_demux.c mpls_input.c mpls_output.c mpls_proto.c;
   .include <bsd.kmod.mk>`). `cc 8.3 [DragonFly]`.
2. `kldload mpls.ko` — registers `NETISR_MPLS` handler.
3. Injected **one** MPLS frame via `bpf` write with `BIOCSFEEDBACK` on `vtnet0`:
   - Ethernet header: dst `ff:ff:ff:ff:ff:ff`, src `52:54:00:12:34:56`,
     EtherType `0x8847`
   - MPLS shim (network byte order): `00 00 00 40` → label=0, exp=0, **S=0**,
     TTL=64
4. **Result:** guest hard-wedged. `vm.sh status` → `down`. ssh → RC=124
   (timeout). **Zero** panics in `boot.log` (silent busy-loop, NOT a crash).
   `boot.log` frozen at boot timestamp — the kernel could not write to the
   serial console. Power-cycle (`vm.sh reset`) required to recover.

### Proof 2: Deterministic harness (faithful loop transcription)

`mpls_loop_harness.c` embeds the `mpls_input()` label-switch loop verbatim
with userspace stand-ins for `mbuf`/`mtod`/`m_adj`/`netisr_queue`. The **only**
addition is a depth-counter escape hatch (`cap = 1,000,000`) the production code
lacks. Run as unprivileged user `maxx`:

```
Frame A: label=0 S=0 TTL=64
UNPATCHED: iterations=1000001  exit=DEPTH CAP HIT (would loop forever)
           cursor adv: off=0 (NEVER ADVANCED — same label re-read every iter)
           VERDICT: *** INFINITE LOOP CONFIRMED ***
PATCHED:   iterations=10  exit=m_pullup too small -> drop  cursor adv=off=36
           VERDICT: loop TERMINATES cleanly. Fix is effective.

Frame B: label=2 S=0 (IPv6 explicit NULL, mpls_input.c:142)
UNPATCHED: iterations=1000001  VERDICT: *** INFINITE LOOP CONFIRMED ***

Control: label=0 S=1 (bottom-of-stack)
UNPATCHED: iterations=1  exit=netisr_queue(NETISR_IP) -> return
           VERDICT: terminates (S-bit path is correct)
```

## Impact ceiling

Remote unauthenticated on-link single-frame **hard hang**. One CPU pinned at
100% holding mplock → entire kernel unresponsive. No panic, no log, no
auto-recovery. This is a pure availability denial — **no memory corruption, no
read/write primitive, no privilege escalation**. `exploit_chain: none`.

CVSS `AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` (7.5 High) is appropriate.

## The fix (validated — `fix.diff`)

Two-part defence-in-depth, both targeting the root cause:

1. **`m_adj(m, sizeof(struct mpls))` before each `goto again`** (`:134` and `:155`
   post-patch) — advances the mbuf cursor so the next iteration reads the *next*
   label on the stack, matching the S-bit-set path at `:119`/`::138`.

2. **Depth cap** (`MPLS_LABEL_TTL_MAX = 32`) — a counter incremented each
   iteration; if exceeded, `mplss_invalid++` / `m_freem` / `return`. Prevents
   unbounded looping even if another label-switch path is later added without
   `m_adj`. RFC 3032 doesn't bound the stack, but legitimate stacks are a
   handful deep; 32 is generous (matches other BSDs).

### Fix validation (Phase 8 — single-fix module built + booted)

| | unpatched `mpls.ko` | patched `mpls.ko` (sha256 `21e72a39…`) |
|---|---|---|
| **trigger** | 1 frame (label=0, S=0) via bpf feedback | **same** trigger, 3× |
| **result** | guest hard-wedged; `status: down`; ssh RC=124; 0 panics | all 3 triggers return exit 0; guest `STILL_ALIVE`; load 0.13; CPU idle |

The fix closes the bug completely. The harness corroborates: the patched loop
terminates in 10 iterations (cursor advances 0→36, then `m_pullup` fails on the
exhausted buffer → clean drop).
