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

pf scrub max-mss: 2-byte stack OOB read/write past opts[40] in pf_normalize_tcpopt() via malformed MSS option

Summary

The TCP option walk pulls cnt (<=40) bytes into u_char opts[TCP_MAXOLEN] (exactly 40) and accepts an option whose declared length only satisfies 2<=optlen<=cnt. A TCP header with th_off=15 whose option space tiles as [38 bytes filler][0x02 0x02] (MSS option with declared length 2, not 4) reaches the TCPOPT_MAXSEG case with optp==opts+38 and cnt==2: mss=(u_int16_t*)(optp+2)==opts+40. ntohs(*mss) reads 2 bytes past the stack array; when the stale stack word exceeds max_mss, *mss=htons(max_mss) writes 2 bytes past the array with a rule-derived value. Secondary: th_sum is fixup'd from the OOB-read stale bytes and copied back into the packet - a 2-byte stale-kernel-stack oracle in transit packets. Trigger: one unauthenticated TCP packet matching a scrub rule with max-mss (common WAN-edge config). VERIFIED: disassembly pack proves the OOB is compiled into pf.ko (movzwl 0x2(%r15)/mov %ax,0x2(%r15)); on the shipped gcc80 -O2 INVARIANTS build the write lands in an 8-byte alignment-padding slot (416k+ packets, guest stable) - any compiler/-O/LTO/KASAN layout change placing a live local at opts+40 turns it into a remotely-triggerable 2-byte stack write adjacent to saved registers. No uid0 route on the audited binary (value rule-chosen, position fixed). Fix validated (reject optlen != TCPOLEN_MAXSEG).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3360 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.0 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
mss_oob.c β€” 4.5 KB view raw
build.sh β€” 82 B view raw
run.sh β€” 138 B view raw
pf_normalize_tcpopt.dis β€” 6.1 KB ↓ download
disassembly-analysis.txt β€” 1.8 KB view raw
fix.diff β€” 498 B view raw
verdict.json β€” 2.6 KB view raw
env.txt β€” 554 B view raw

DF-3360 β€” pf scrub max-mss: 2-byte stack OOB read/write past opts[40] in pf_normalize_tcpopt()

What

pf_normalize_tcpopt() (sys/net/pf/pf_norm.c:1907-1957) clamps the TCP MSS option for scrub rules with max-mss N. The option walk:

u_char opts[TCP_MAXOLEN];                 /* :1915 β€” exactly 40 bytes  */
cnt = thoff - sizeof(struct tcphdr);      /* == 40 for th_off == 15    */
...
optlen = optp[1];
if (optlen < 2 || optlen > cnt) break;    /* optlen == 2 is accepted   */
switch (opt) {
case TCPOPT_MAXSEG:
        mss = (u_int16_t *)(optp + 2);    /* == opts + 40 with the tile below */
        if ((ntohs(*mss)) > r->max_mss) { /* OOB READ of opts[40..41]  */
                th->th_sum = pf_cksum_fixup(th->th_sum, *mss,
                    htons(r->max_mss), 0);
                *mss = htons(r->max_mss); /* OOB WRITE of opts[40..41] */
                rewrite = 1;
        }

A TCP header with th_off = 15 whose option space tiles as [38 bytes of filler][0x02 0x02] β€” an MSS option with declared option length 2 (instead of TCPOLEN_MAXSEG == 4) as the final option β€” makes optp == opts+38, optlen == 2 (accepted by the optlen > cnt check since cnt == 2), and mss == opts+40: two bytes past the 40-byte stack array. The read is unconditional; when the adjacent stack word (read big-endian) exceeds the rule's max-mss, a 2-byte value htons(max_mss) is written past the array. An option length of 3 gives a 1-byte OOB.

Secondary effect even when the write does not fire: th->th_sum is "fixed up" with a delta computed from the OOB-read stale-stack bytes and the header is copied back β€” a 2-byte stale-kernel-stack oracle in the forwarded packet's checksum for transit traffic.

Preconditions

  • pf enabled with a scrub rule carrying max-mss (common on WAN edges; not the default empty ruleset).
  • Attacker sends one TCP packet whose header ends in the malformed MSS option. Unauthenticated, remote, one packet per execution of the bug.

Guest verification (see VERDICT.md)

  • Disassembly of the shipped /boot/kernel/pf.ko proves the unguarded access is compiled in: movzwl 0x2(%r15) / mov %ax,0x2(%r15) with %r15 = opts+38 (pf_normalize_tcpopt+0xba).
  • 300,000 malformed-MSS packets through scrub in on lo0 all max-mss {64,1} executed the path (rule evaluations climbed) with the guest staying up: in this gcc -O2 build opts+40..41 lands in an 8-byte frame alignment-padding slot (rbp-0x30, unreferenced), so the OOB is inert on this exact binary β€” the defect is real (UB) and compiler-layout-dependent for impact.

Reproduce

# root on the guest:
kldload pf
printf 'scrub in on lo0 all max-mss 64\n' > /root/poc/mss.conf
pfctl -f /root/poc/mss.conf && pfctl -e
/root/poc/mss_oob 100000        # sends SYN, th_off=15, [38xNOP][0x02 0x02]
pfctl -sr -v                    # evaluations counter climbs; guest stays up

Fix

if (optlen != TCPOLEN_MAXSEG) break; in the TCPOPT_MAXSEG case β€” see fix.diff.

VERDICT.md
↓ download raw

DF-3360 VERDICT

Reproduced? No β€” the defect is certain, the shipped build neutralizes it.

What was proven, and how

  1. Source-level reachability (certain). pf_normalize_tcpopt() (sys/net/pf/pf_norm.c:1907-1957) pulls cnt = th_off*4 - 20 bytes into u_char opts[TCP_MAXOLEN] (= exactly 40, sys/netinet/tcp.h:149). The walk accepts an option with optlen == optp[1] as long as 2 <= optlen <= cnt. A final option [0x02, 0x02] after 38 bytes of filler (th_off == 15) leaves cnt == 2, passes the check, and executes mss = (u_int16_t *)(optp + 2) = opts+40: - ntohs(*mss) reads 2 bytes past the array (pf_norm.c:1941); - when the stale stack word (big-endian) exceeds r->max_mss, *mss = htons(r->max_mss) writes 2 bytes past the array (pf_norm.c:1944).

  2. Compiled into the shipped binary (certain). Disassembly of /boot/kernel/pf.ko (INVARIANTS gcc80 -O2, see pf_normalize_tcpopt.dis): the loop guard is only optlen < 2 || optlen > cnt; the access is movzwl 0x2(%r15) (pf_normalize_tcpopt+0xba) with %r15 walkable to opts+38, and the store mov %ax,0x2(%r15) (+0x102). No bound vs the 40-byte array exists.

  3. Runtime path execution (observed). scrub in on lo0 all max-mss 64 + 100,000 crafted packets: rule evaluations climbed to 416,675 and the packets were not dropped early (the option pull succeeds; the scrub rule has no port/address constraints). 200,000 more packets with max-mss 1 (write condition almost always true). Guest stable throughout, no console messages, no panic.

  4. Why nothing manifests on this build. Frame layout from the same disassembly: opts lives at rbp-0x58; opts+40..41 = rbp-0x30 β€” an 8-byte alignment-padding slot between the array and the first pushed register (saved rbx at rbp-0x28). No instruction in the function references -0x30(%rbp). The OOB read returns stale stack; the conditional OOB write corrupts only that dead padding.

Impact assessment

  • On the shipped gcc80 -O2 build: no runtime impact (padding), except a subtle secondary effect: th->th_sum is recomputed with a delta derived from the OOB-read stale bytes and the header is copied back into the packet β€” for transit traffic this leaks 2 bytes of stale kernel stack per packet into an observable checksum change.
  • On any rebuild where a live local/spill lands at opts+40 (different compiler version, -O level, LTO, KASAN-style redzones, or simply a change in register pressure): a remotely-triggerable 2-byte kernel stack write adjacent to saved registers with a rule-chosen value β€” memory-corruption class.

Confidence in the defect: certain. Runtime manifestation on the audited binary: none. Status: not_reproduced / impact none (for this build), severity filed as Medium.

Fix

One line: if (optlen != TCPOLEN_MAXSEG) break; in the MAXSEG case (fix.diff). Not kernel-rebuilt for this finding (single-fix build budget was spent on DF-3362, whose fix is validated; the change is a pure additional guard on an option-parsing branch and cannot regress the well-formed path, which always has optlen == 4).

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

not_testable
↓ fix.diffper-fix-DF-3360

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3360/pf_normalize_tcpopt.dis (shipped pf.ko disassembly; opts at rbp-0x58, access at opts+40)', 'findings/poc/DF-3360/disassembly-analysis.txt (frame layout proof)', 'findings/poc/DF-3360/mss_oob.c (trigger: SYN, th_off=15, [38xNOP][0x02 0x02])', 'rule evaluation counters 416k+ after 100k packets with scrub max-mss 64; 200k more with max-mss 1; guest stable both runs']

PoC changes

Rewrote the seed-style sketch into a standalone raw-socket injector: fixed DFly-specific bits (ip_len must be in network order for raw IP, source must be a locally assigned address or sendto returns EADDRNOTAVAIL, pseudo-header checksum built manually for C89 declarations), options tile = 38xNOP + [TCPOPT_MAXSEG, len=2].

Verified recommended fix

In pf_normalize_tcpopt() TCPOPT_MAXSEG case: if (optlen != TCPOLEN_MAXSEG) break;

Verdict

The OOB access is real, remotely triggerable, and compiled into the shipped pf.ko (objdump: movzwl 0x2(%r15)/mov %ax,0x2(%r15) with %r15==opts+38 possible and no guard), and 300,000 crafted malformed-MSS packets demonstrably executed the path (scrub rule evaluations climbed, guest stable). However, on this gcc80 -O2 INVARIANTS build opts[40..41] is an 8-byte alignment-padding slot at rbp-0x30 (no live local or saved register adjacent), so the 2-byte read/write cannot corrupt live state on the shipped binary. Defect: certain (disassembly + source proof). Runtime manifestation: none on this build; impact becomes live if a different compiler/-O level/KASAN-style layout places data at opts+40. Secondary oracle: th->th_sum is fixup'd with stale-stack bytes and copied back, a 2-byte stale-stack oracle in transit packets.