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)
PoC verification
Evidence pack
findings/poc/DF-3317 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
- The v6 PTB path performs no sequence validation and no MTU floor.
ptb6 -xforgesICMP6_PACKET_TOO_BIG, mtu=60, th_seq=0x41424344(garbage β never validated) quoting the victim 4-tuple. Source: tcp_subr.c:1582-1590 (rawicmp6_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). - t_maxseg driven to 0.
after-PTB : t_state=5 t_maxseg=0 t_maxopd=0(dumped vianet.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 < msswithmssint -12) yields garbaget_maxseg = 419430β also observed (run4). - 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 becausermx_ssthresh != 0on the ::1 host route β seeded withroute change -ssthresh 1024, which models the organic state left by any previously congested connection;dosavessthreshwould also work), the aborting close (SO_LINGER{1,0} β tcp_drop β tcp_close) executesi = (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 12xtcpcb 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 aspid 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_maxopdfully 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_mtudiscforged-MTU β corrupted t_maxseg, Low): same sink function, but that finding is about the missing lower bound intcp_mtudiscitself; 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
fixedfix.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
Confirmed kernel references
- sys/netinet/tcp_subr.c:1575
- sys/netinet/tcp_subr.c:1582
- sys/netinet/tcp_subr.c:1584
- sys/netinet/tcp_subr.c:1626
- sys/netinet/tcp_subr.c:1628
- sys/netinet/tcp_subr.c:1820
- sys/netinet/tcp_subr.c:1852
- sys/netinet/tcp_subr.c:1868
- sys/netinet/tcp_subr.c:1871
- sys/netinet/tcp_subr.c:1013
- sys/netinet/tcp_subr.c:945
- sys/netinet/tcp_subr.c:1509
- sys/netinet6/icmp6.c:1039
- sys/netinet6/icmp6.c:1078
- sys/netinet/tcp_var.h:223
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.
No comments yet.