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

tcp6_ctlinput dispatches ICMPv6 Packet-Too-Big to tcp_mtudisc with no sequence validation and no IPv6 minimum-MTU floor: blind off-path t_maxseg corruption up to a kernel divide-by-zero panic

Summary

The IPv6 twin of the ICMP error path skips both protections the IPv4 path has: tcp_ctlinput (v4) validates the quoted th_seq against [snd_una, snd_max) before notifying, while tcp6_ctlinput copies only the port pair and never looks at the sequence number, and passes the raw 32-bit icmp6_mtu to tcp_mtudisc with no IPV6_MMTU floor - although icmp6_mtudisc_update() correctly enforces the floor for the route. An off-path blind IPv6 spoofer needs only the victim 4-tuple (locally enumerable via the world-readable pcblist, DF-0337). With mtu=60: maxopd = 0 and t_maxseg becomes 0; mtu in [41,59] gives DF-0338-class corrupted t_maxseg. t_maxseg==0 divides tcp_close()'s ssthresh recalculation by zero. VERIFIED twice as a system-down panic plus twice as a process-killing in-kernel fault on the stock INVARIANTS guest (ptb6.c: PTB type 2 mtu=60 with garbage th_seq never validated; pcblist shows t_maxseg=0; SO_LINGER abort -> 12x 'MSS (0) too small' then 'Fatal trap 18: integer divide fault ... Stopped at tcp_close+0x66e: divq %r8,%eax'). Remote off-path unauthenticated with spoofing; IPv6/ICMPv6 enabled by default. No memory-write primitive (protocol-state corruption) - ceiling remote panic/DoS. Fix validated (RFC 8200 IPV6_MMTU floor): identical PoC leaves t_maxseg unchanged, legitimate PTB mtu=1300 still shrinks MSS.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3317 Β· 16 files
FileTypeDescriptionSize
ptb6.c β€” 10.5 KB view raw
build.sh β€” 97 B view raw
run.sh β€” 176 B view raw
run.log β€” 2.0 KB view raw
panic.txt β€” 1.9 KB view raw
panic_console_full.txt β€” 2.2 KB view raw
panic_console_raw.txt β€” 1.7 KB view raw
fix.diff β€” 672 B view raw
fix_build.log.tail β€” 447 B view raw
fix_inspect.log β€” 351 B view raw
fix_run.log β€” 468 B view raw
fix_sanity.log β€” 351 B view raw
env.txt β€” 995 B view raw
VERDICT.md β€” 5.5 KB ↓ raw
verdict.json β€” 6.0 KB view raw
manifest.json β€” 1.1 KB view raw
VERDICT.md
↓ download raw

DF-3317 VERDICT β€” reproduced (remote-equivalent blind ICMPv6 PTB β†’ t_maxseg=0 β†’ tcp_close divide-by-zero panic)

Status: reproduced. Impact: panic (remote-unauthenticated-equivalent kernel DoS; protocol-state corruption, not a memory-write primitive). Confidence: certain.

What was proven, end to end, on the stock INVARIANTS guest

DragonFly dfbsd 6.5-DEVELOPMENT #0 (Thu Jul 2 06:02:54 UTC 2026, X86_64_GENERIC), stock kernel, no sysctl changes:

  1. The v6 PTB path performs no sequence validation and no MTU floor. ptb6 -x forges ICMP6_PACKET_TOO_BIG, mtu=60, th_seq=0x41424344 (garbage β€” never validated) quoting the victim 4-tuple. Source: tcp_subr.c:1582-1590 (raw icmp6_mtu β†’ arg), 1626-1630 (ports-only copy, in6_pcbnotify(..., tcp_mtudisc)). The v4 twin validates seq at 1509-1511; the v6 path does not β€” verified both in source and by experiment (the garbage seq was accepted).
  2. t_maxseg driven to 0. after-PTB : t_state=5 t_maxseg=0 t_maxopd=0 (dumped via net.inet.tcp.pcblist β€” the world-readable sysctl). Mechanics: maxopd = 60 - (40+20) = 0 (1820-1823) stored at 1852/1871. With timestamps negotiated, mss = 0-12 = -12 and the unsigned clamp at 1868 (so->so_snd.ssb_hiwat < mss with mss int -12) yields garbage t_maxseg = 419430 β€” also observed (run4).
  3. Kernel divide-by-zero panic on close. With t_rttupdated >= 16 (echo rounds; VJ timing works with TCP_NOOPT) and the ssthresh-save block entered (1006-1008; entered because rmx_ssthresh != 0 on the ::1 host route β€” seeded with route change -ssthresh 1024, which models the organic state left by any previously congested connection; dosavessthresh would also work), the aborting close (SO_LINGER{1,0} β†’ tcp_drop β†’ tcp_close) executes i = (i + tp->t_maxseg/2) / tp->t_maxseg (1013) with t_maxseg==0: - First reproduction: Fatal trap 18: integer divide fault while in kernel mode ... current process = Idle ... Stopped at tcp_close+0x66e: divq %r8,%eax β€” system-down panic, preceded by 12x tcpcb 0xfffff8008997b9a0: MSS (0) too small to hold options! (the kernel's own diagnostic on the corrupted tcpcb). - Second reproduction (identical signature, captured in panic.txt/panic_console_full.txt). - Two additional runs where the fault hit in the closing process's syscall context were delivered as pid NNN (ptb6), uid 0: exited on signal 11 (core dumped) β€” process kill instead of system panic.

Why root-local PoC == remote-unauthenticated attacker

The PoC uses a raw ICMPv6 socket (root-only) purely to inject the packet on lo0 from this host. The packet itself is bit-identical to what an off-path IPv6 spoofer sends: src = victim peer, dst = victim, checksum computed by the kernel exactly as a real router's stack would. No local knowledge beyond the 4-tuple is used β€” th_seq is garbage (proving the missing check), and 4-tuples are enumerable locally via the world-readable net.inet.tcp.pcblist (DF-0337) or guessable remotely. ICMPv6 is not filtered by default on inbound WAN/LAN on DragonFly, and IPv6 is enabled by default.

Primitive characterization (per contract)

  • Write primitive: none β€” this is protocol-state corruption (t_maxseg, t_maxopd fully attacker-chosen within [0, 2^32-60] mapped through tcp_mtudisc arithmetic), so uid=0 escalation is not on the table.
  • Ceiling demonstrated: persistent per-connection MSS corruption of any IPv6 TCP connection (blind), tiny-MSS packet amplification for mtu in (60,1279], garbage-huge t_maxseg for mtu in [41,59] βˆͺ timestamped-60, and kernel panic (fatal trap 18, remote-triggerable) for the mtu=60/no-options/close chain. Panic requires the victim connection to close with β‰₯16 RTT samples while the route has non-zero cached ssthresh β€” both are ordinary states for any real connection.

Fix validation

fix.diff adds the RFC 8200 Β§5.2 floor to tcp6_ctlinput's PRC_MSGSIZE handling (if (arg < IPV6_MMTU) goto out;), applied in-guest to /usr/src, make nativekernel + make installkernel, reboot:

  • Baseline (stock kernel): after-PTB t_maxseg=0 + fatal trap 18 at tcp_close (see panic.txt, run.log).
  • Patched kernel: after-PTB t_maxseg=16324/14336 (unchanged), no MSS(0) diagnostics, aborting close returns normally, guest healthy. β†’ fix_status: fixed.

Recommended follow-up (not in fix.diff, needs a small restructure): copy the quoted th_seq and apply the v4-style [snd_una, snd_max) check (RFC 5927) to also defeat in-window-blind MSS shrinking for mtu β‰₯ 1280, i.e. legitimate-looking PTBs.

Known-findings cross-reference

  • DF-0338 (v4 tcp_mtudisc forged-MTU β†’ corrupted t_maxseg, Low): same sink function, but that finding is about the missing lower bound in tcp_mtudisc itself; DF-3317 is the missing input validation in the v6 dispatcher (no seq check + no 1280 floor) which additionally makes the attack blind (no ISN guess). The panic chain (tcp_subr.c:1013 division by t_maxseg==0) is new here.
  • DF-0336/0337/0339: re-checked during this pass β€” all still present in tree (tcp6_getcred live so_cred at 1387; raw bcopy at 1285-1288; tcp_new_isn signed last_offset at 1674/1707/1742).

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

fix.diff (IPV6_MMTU floor) applied to /usr/src in-guest, make nativekernel + installkernel, reboot into #1. Identical PoC on patched kernel: after-PTB t_maxseg unchanged (14336/16324 vs 0 on baseline), zero 'MSS (0)' dmesg entries, aborting close returns rc=0, guest healthy; legitimate PTB mtu=1300 still shrinks MSS to 1240 (PMTUD preserved). Baseline panic reproduced twice on stock #0 prior.

fix_inspect.log, fix_run.log, fix_sanity.log, fix_build.log.tail, fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Thu Sep 10 17:27:19 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

remote off-path IPv6 spoofer -> forged ICMPv6 PTB (type 2, mtu=60, garbage th_seq never validated) quoting victim 4-tuple -> icmp6_notify_error -> tcp6_ctlinput:1575-1590 (raw icmp6_mtu) -> in6_pcbnotify -> tcp_mtudisc:1820-1871 -> maxopd=0, t_maxseg=0 (u_int) -> victim closes conn with t_rttupdated>=16 and ssthresh-save block entered -> tcp_close:1013 integer division by t_maxseg==0 -> Fatal trap 18 (#DE) -> kernel panic (system down). No memory-write primitive: impact ceiling is remote unauthenticated kernel panic / persistent per-connection MSS corruption (blind), incl. tiny-MSS amplification for mtu in (60,1279] and DF-0338-class garbage t_maxseg for mtu in [41,59].

Evidence (decisive lines)

["run.log: 'after-PTB : t_state=5 t_maxseg=0 t_maxopd=0' on stock kernel #0 (and fix_run.log shows it unchanged on patched #1)", "panic.txt / panic_console_full.txt: 12x 'tcpcb ...: MSS (0) too small to hold options!' + 'Fatal trap 18: integer divide fault while in kernel mode' + 'Stopped at tcp_close+0x66e: divq %r8,%eax' + 'current process = Idle' (two fatal reproductions; two signal-11 process-kill variants logged at 17:16:16 and 17:17:53)", "fix.diff: the IPV6_MMTU floor in tcp6_ctlinput's PRC_MSGSIZE branch", 'fix_inspect.log/fix_run.log: patched kernel - t_maxseg 14336 before AND after PTB, close returns rc=0, dmesg MSS(0) count 0', 'fix_sanity.log: patched kernel still honors legitimate PTB mtu=1300 (t_maxseg 14336->1240)', 'VERDICT.md: full narrative incl. the raw-socket-is-injection-only privilege-model note']

PoC changes

seed PoC assumptions fixed during verification: (1) AF_INET6 SOCK_RAW never transmits a caller-built IPv6 header (kernel prepends its own; raw_ip6.c:392-418 also computes the checksum) - the first build double-headered the packet and died as 'unknown icmp6 type (96)'; (2) TCP_NOOPT on the victim socket is required for t_maxseg==0 exactly (with timestamps, mss=0-12=-12 and the unsigned clamp at tcp_subr.c:1868 yields garbage 419430); (3) the ssthresh-save block must be entered at close - seeded via 'route change -inet6 -host ::1 -ssthresh 1024', modeling the organic cached state of any previously congested connection; (4) SO_LINGER{1,0} close (abort -> tcp_drop -> tcp_close) for a deterministic synchronous teardown; (5) pcblist parsing needs '#define _KERNEL_STRUCTURES' + sys/socketvar.h + -I/usr/src/sys.

Verified recommended fix

tcp6_ctlinput PRC_MSGSIZE: discard Packet-Too-Big with next-hop MTU < IPV6_MMTU (RFC 8200 5.2 MUST); follow up with v4-style quoted-sequence validation

Verdict

tcp6_ctlinput() dispatches ICMPv6 Packet-Too-Big to tcp_mtudisc() with no sequence-number validation and no RFC 8200 1280-octet minimum-MTU floor (tcp_subr.c:1582-1590, 1626-1630), so an off-path blind forger needs only the victim 4-tuple. A forged PTB with mtu=60 drove t_maxseg/t_maxopd to 0 (verified via net.inet.tcp.pcblist; with timestamp options the negative mss is clamped to garbage t_maxseg=419430), and the next close of that connection executed tcp_subr.c:1013's 'i = (i + tp->t_maxseg/2)/tp->t_maxseg' with t_maxseg==0, producing 'Fatal trap 18: integer divide fault ... Stopped at tcp_close+0x66e: divq' (system-down panic, current process=Idle/netisr; twice reproduced on the stock INVARIANTS kernel; two further runs where the fault hit the closing syscall thread delivered signal 11 to the process). fix.diff adds the RFC-mandated 'if (arg < IPV6_MMTU) goto out;' and was validated by in-guest nativekernel rebuild: patched kernel leaves t_maxseg unchanged (14336) on the identical PoB, close returns cleanly, and legitimate PTB mtu=1300 still shrinks MSS to 1240.