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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3293 Β· 11 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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:

  1. IPv6: no code on the v6 path validates th_off against the segment length. ip6_input enforces only pkthdr.len == 40 + ip6_plen (ip6_input.c:628-639); tcp6_input pulls up only the 20-byte base header (tcp_input.c:618); the off <= tlen-equivalent pull-up at tcp_input.c:771 runs only for off > 20 and only after the KASSERT at 766.
  2. IPv4 behind a decap path that leaves a stale M_LENCHECKED: gre_input2() (sys/netinet/ip_gre.c:180-193) clears M_HASH but not M_LENCHECKED, so the re-injected inner packet skips ip_lengthcheck() in ip_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 via tap(4) write, which lands in ether_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, inner th_off=15 with ip_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 whose tqe_len lies 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_URG set and th_urp <= (u_long)tlen (tcp_input.c:2387, always true for negative tlen), tcp_pulloutofband() walks past the data and hits the unconditional panic("tcp_pulloutofband") (tcp_input.c:2798) β€” a plain panic(), 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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Sep 10 06:35:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

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.