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)
PoC verification
Evidence pack
findings/poc/DF-3360 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.koproves 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 buildopts+40..41lands 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.
DF-3360 VERDICT
Reproduced? No β the defect is certain, the shipped build neutralizes it.
What was proven, and how
-
Source-level reachability (certain).
pf_normalize_tcpopt()(sys/net/pf/pf_norm.c:1907-1957) pullscnt = th_off*4 - 20bytes intou_char opts[TCP_MAXOLEN](= exactly 40, sys/netinet/tcp.h:149). The walk accepts an option withoptlen == optp[1]as long as2 <= optlen <= cnt. A final option[0x02, 0x02]after 38 bytes of filler (th_off == 15) leavescnt == 2, passes the check, and executesmss = (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) exceedsr->max_mss,*mss = htons(r->max_mss)writes 2 bytes past the array (pf_norm.c:1944). -
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 ismovzwl 0x2(%r15)(pf_normalize_tcpopt+0xba) with%r15walkable to opts+38, and the storemov %ax,0x2(%r15)(+0x102). No bound vs the 40-byte array exists. -
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. -
Why nothing manifests on this build. Frame layout from the same disassembly:
optslives atrbp-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_sumis 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_testableConfirmed 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.
No comments yet.