tcp_input(): TCP data offset (th_off) validated only by KASSERT β one-packet unauthenticated remote panic on default INVARIANTS kernels; negative-tlen protocol-state corruption on production kernels
Summary
tcp_input.c:764-768 computes off=th_off<<2 and guards off>=sizeof(tcphdr)&&off<=tlen only with a KASSERT ('already checked and pulled up in ip_demux()'). That claim holds only for plain IPv4 ethernet traffic. Two reachable packet classes bypass it: (1) IPv6 - nothing validates th_off at all (ip6_input checks only pkt.len==40+plen; tcp6_input pulls up only the 20-byte base header; the IP6_EXTHDR_CHECK runs after the KASSERT), so any IPv6/TCP packet with th_off<5 trips the KASSERT before pcb lookup - no listener, no connection, no config; (2) IPv4 behind gre(4) - gre_input2 decapsulates, clears M_HASH but NOT M_LENCHECKED, so ip_hashfn skips ip_lengthcheck for the inner packet and a forged inner th_off*4>tlen reaches the same KASSERT (wg(4) clears both flags - the intended protocol gre forgot). The default kernel config ships INVARIANTS, making this a default-config one-packet remote panic. On non-INVARIANTS kernels tlen-=off goes negative and flows through the state machine: rcv_nxt regression, reassembly entries with lying lengths, and with TH_URG an unconditional panic('tcp_pulloutofband'). VERIFIED both PoCs on stock guest via tap injection: IPv6 th_off=4 -> 'panic: bad TCP data offset 16 (tlen 20)' with no preconditions; gre inner th_off=15 -> same. Fix validated (runtime check + tcps_rcvbadoff + companion gre M_LENCHECKED clear): patched kernel drops both, stays up. No memory-corruption primitive (m_adj clamps) - remote DoS ceiling.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3293 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| tcp_off6.c | β | 4.4 KB | view raw | |
| tcp_off4_gre.c | β | 6.0 KB | view raw | |
| panic.txt | β | 872 B | view raw | |
| panic.b.txt | β | 1.2 KB | view raw | |
| run.log | β | 338 B | view raw | |
| run.2.log | β | 367 B | view raw | |
| build.log | β | 641 B | view raw | |
| env.txt | β | 543 B | view raw | |
| fix.diff | β | 1.0 KB | view raw | |
| VERDICT.md | β | 4.9 KB | β raw | |
| code_hashes.txt | β | 200 B | view raw |
DF-3293 VERDICT β tcp_input() th_off validation gap
Status: REPRODUCED (panic, both triggers) Β· Fix: FIXED (validated by kernel rebuild)
What was claimed
sys/netinet/tcp_input.c:760-768 validates the TCP data offset (th->th_off)
only with a KASSERT β compiled out of production (non-INVARIANTS) kernels β
and the comment claiming "already checked and pulled up in ip_demux()" is
false for two reachable packet classes:
- IPv6: no code on the v6 path validates
th_offagainst the segment length.ip6_inputenforces onlypkthdr.len == 40 + ip6_plen(ip6_input.c:628-639);tcp6_inputpulls up only the 20-byte base header (tcp_input.c:618); theoff <= tlen-equivalent pull-up at tcp_input.c:771 runs only foroff > 20and only after the KASSERT at 766. - IPv4 behind a decap path that leaves a stale
M_LENCHECKED:gre_input2()(sys/netinet/ip_gre.c:180-193) clearsM_HASHbut notM_LENCHECKED, so the re-injected inner packet skipsip_lengthcheck()inip_hashfn()(ip_demux.c:295-298) and its TCP header is never validated. (wg(4) clears both flags β if_wg.c:85 β showing the intended protocol; gre(4) forgot.)
What was demonstrated (guest: DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC
β the default config, which ships options INVARIANTS, sys/config/X86_64_GENERIC:56)
Baseline kernel #0 (stock INVARIANTS)
- PoC A (
tcp_off6.c): one IPv6/TCP packet,th_off=4, valid checksums, to a closed port on a v6-capable interface β injected viatap(4)write, which lands inether_input()exactly like a wire packet:
panic: bad TCP data offset 16 (tlen 20)
tcp_input() at tcp_input+0x11e1
tcp6_input() at tcp6_input+0x64
transport6_processing_handler() ...
β unauthenticated, one-packet, no listener, no config, default kernel.
Guest died (vm.sh status β down). Evidence: panic.txt.
- PoC B (
tcp_off4_gre.c): gre0 tunnel configured (root setup; attacker = spoofed tunnel peer), outer IP/GRE frame, innerth_off=15withip_len=40(tlen=20):
panic: bad TCP data offset 60 (tlen 20)
tcp_input() at tcp_input+0x11e1
transport_processing_oncpu.isra.7() ...
ip_input() at ip_input+0x71f
Proves the stale-M_LENCHECKED bypass end-to-end (gre0 Ipkts confirmed
the decap). Evidence: panic.b.txt.
Patched kernel #1 (fix.diff applied in-guest, make nativekernel, reboot)
Both PoCs re-run byte-identically:
- PoC A: no panic;
netstat -s -p tcp"discarded for bad header offset fields" 0β1; guest up. (run.log) - PoC B: no panic; counter 1β2;
gre0 Ipkts=1(decap still happens); guest up. (run.2.log)
Production-kernel impact (reasoned, not executed β the guest has one kernel)
With INVARIANTS off, tlen -= off (tcp_input.c:768) produces a negative
tlen that flows through the whole segment state machine:
- inline fast data path (tcp_input.c:1346) and step-6 inline path (2438) do
tp->rcv_nxt += tlenβ receiver sequence regression / permanent stall; tcp_reass()queues entries whosetqe_lenlies about the mbuf contents (tcp_input.c:523) β reassembly bookkeeping corruption (m_adj()clamps the actual trims β uipc_mbuf.c:1870-1883 β so the corruption stays in TCP state, not mbuf memory);- with
TH_URGset andth_urp <= (u_long)tlen(tcp_input.c:2387, always true for negative tlen),tcp_pulloutofband()walks past the data and hits the unconditionalpanic("tcp_pulloutofband")(tcp_input.c:2798) β a plainpanic(), present in production builds.
So: INVARIANTS (default) kernels = remote one-packet panic; production kernels = remote protocol-state corruption DoS (+ unconditional panic via URG on the GRE vector).
Exploit chain
Not a memory-corruption primitive (m_adj clamps the trims), so there is no uid-0 route; the impact ceiling is remote denial of service. No escalation chain attempted beyond that.
Root cause line references
sys/netinet/tcp_input.c:764-768β the KASSERT-only validation (the bug).sys/netinet/ip_demux.c:295-298βip_hashfnskipsip_lengthcheckwhenM_LENCHECKEDis set (assumes flag always accurate).sys/netinet/ip_gre.c:180-193β GRE decap clearsM_HASHonly.sys/config/X86_64_GENERIC:56β default config ships INVARIANTS.
Fix
fix.diff: runtime check (off < sizeof(struct tcphdr) || off > tlen β
count tcps_rcvbadoff, goto drop), matching FreeBSD's long-standing
practice. Validated: baseline panics gone, drops counted, guest stable.
Recommended companion (separate commit, defense in depth):
gre_input2() should clear M_LENCHECKED as well.
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
fixedBaseline kernel #0 panicked on both PoCs (panic.txt, panic.b.txt). Patched kernel #1 (fix.diff, nativekernel rebuild) drops both PoCs with tcps_rcvbadoff incrementing (run.log: 0->1, run.2.log: 1->2), gre decap still exercised (gre0 Ipkts=1), guest stable through both runs.
['run.log', 'run.2.log', 'build.log']
Confirmed kernel references
Detail
Evidence (decisive lines)
["panic.txt: baseline PoC A backtrace 'panic: bad TCP data offset 16 (tlen 20)' tcp_input<-tcp6_input<-transport6_processing_handler", "panic.b.txt: baseline PoC B backtrace 'panic: bad TCP data offset 60 (tlen 20)' tcp_input<-ip_input (GRE decap, stale M_LENCHECKED)", "run.log: patched kernel #1, PoC A -> no panic, 'discarded for bad header offset fields' 0->1, guest up", 'run.2.log: patched kernel #1, PoC B -> no panic, counter 1->2, gre0 Ipkts=1 (decap still exercised), guest up', 'VERDICT.md: full narrative incl. production-kernel (negative tlen) impact analysis with path:line', 'fix.diff: runtime th_off validation, applied in-guest with patch -p1, validated by make nativekernel + reboot + rerun']
PoC changes
PoC seeds were rewritten from scratch: injection moved to tap(4) writes (tapwrite -> ether_input), which is the reliable receive-injection primitive on this guest (BPF lo0 writes fail EAFNOSUPPORT via lo_output; BPF BIOCSFEEDBACK on vtnet0 never re-entered if_input). PoC B initially failed checksum (tcpstat.tcps_rcvbadsum): fixed pseudo-header byte order (zero, proto, tcp-length). tap0 MAC is queried at runtime via SIOCGIFADDR because it changes on every create.
Verified recommended fix
Replace the tcp_input.c:765-767 KASSERT with a runtime check: if (off < sizeof(struct tcphdr) || off > tlen) { ++tcpstat.tcps_rcvbadoff; goto drop; } (see fix.diff)
Verdict
tcp_input() validates the TCP data offset only with a KASSERT (tcp_input.c:765-767); on the default INVARIANTS config (X86_64_GENERIC) a single crafted IPv6 packet with th_off<5 (no listener, no preconditions) panics the kernel in tcp_input before pcb lookup, and on IPv4 the same is reachable behind gre(4) because gre_input2() clears M_HASH but leaves a stale M_LENCHECKED, so ip_hashfn() skips ip_lengthcheck() for the decapsulated inner packet. Both triggers reproduced as one-packet remote panics on the stock guest; on production (non-INVARIANTS) kernels tlen goes negative and corrupts TCP segment state (rcv_nxt regression, reassembly entries with lying tqe_len) and can hit the unconditional panic("tcp_pulloutofband") with URG. fix.diff turns the KASSERT into a runtime check (drop + tcps_rcvbadoff++); rebuilt kernel #1 drops both PoCs cleanly with the counter incrementing, guest stable.
No comments yet.