check_established reads TCP th_flags from non-first IP fragments without pullup β OOB read + firewall misclassification
Summary
check_established :182 if(fid->proto==IPPROTO_TCP) fid->proto set unconditionally from ip->ip_p regardless of fragment offset. :184 L3HDR(struct tcphdr,ip)->th_flags reads byte ip+ip_hl*4+13. Dispatcher ip_fw3.c:384-392 only PULLUP_TO(hlen+sizeof(tcphdr)) when offset==0 non-first fragments skip pullup. pfil_run_hooks runs BEFORE ip_reass (ip_input.c:631 before :858) so individual fragments reach check_established. RFC791 min fragment payload 8 bytes: hlen=20 m_len=28 read at offset 33 = 5-byte over-read past valid data into mbuf cluster/inline buffer. No panic (read within allocated backing store) but firewall decision corrupted by stale/recycled heap bytes = established-rule bypass on fragments. FreeBSD ipfw2 uses cached f_id.flags instead of re-reading mbuf. Fix: use fid->flags (populated only for non-fragmented TCP at ip_fw3.c:391 stays 0 for fragments).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0747 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | sends crafted non-first TCP fragments with controlled th_flags byte | 6.4 KB | view raw |
| setup.sh | setup-script | loads ipfw3 modules with default-accept, installs established/allow rules | 1.2 KB | view raw |
| build.sh | build-script | compiles trigger.c | 149 B | view raw |
| run.sh | run-script | runs trigger and shows ipfw3 counters | 366 B | view raw |
| build.log | build-log | trigger build output | 106 B | view raw |
| run.log | run-log | baseline run (unpatched): rule 65534 delta = 2pkts/88B | 1.4 KB | view raw |
| run.2.log | run-log | confirmation runs 2+3: positional byte-13 proof | 1.2 KB | view raw |
| baseline_run.log | run-log | full baseline reproduction capture | 1.3 KB | view raw |
| fix_build.log | build-log | fixed module build output | 1.1 KB | view raw |
| fix_run.log | run-log | fixed module test: rule 65534 delta = 0pkts/0B | 1.6 KB | view raw |
| fix.diff | suggested-fix | use fid->flags instead of re-reading mbuf th_flags | 1.1 KB | view raw |
| env.txt | environment | uname, cc version, sysctls | 543 B | view raw |
| README.md | readme | how to reproduce | 3.0 KB | β raw |
| VERDICT.md | verdict | full analysis and fix validation | 5.6 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0747 β check_established reads TCP th_flags from non-first IP fragments without pullup
Finding
File: sys/net/ipfw3_layer4/ip_fw3_layer4.c:173-191 (function check_established)
Severity: Low
Impact: OOB read + firewall misclassification on fragmented TCP packets
Bug mechanism
check_established() re-reads L3HDR(struct tcphdr, ip)->th_flags from the mbuf
for every TCP packet, including non-first IP fragments. The dispatcher
ip_fw3_chk() (ip_fw3.c:384-409) only calls PULLUP_TO(hlen + sizeof(tcphdr))
when offset == 0 (first/unfragmented). For non-first fragments (offset > 0),
the pullup is skipped and the payload at ip + ip_hl*4 is fragment data, not a
TCP header.
th_flags is at offset 13 in struct tcphdr, so L3HDR(tcphdr, ip)->th_flags
reads at ip + 20 + 13 = ip + 33. For a minimum-size fragment (RFC 791: 8-byte
payload, 28-byte total), this is a 5-byte over-read past valid m_len into the
mbuf backing store. For fragments with β₯ 14 bytes of payload, the read is within
attacker-controlled fragment data.
Since pfil_run_hooks runs before ip_reass (ip_input.c:631 vs :858),
each fragment individually reaches check_established. The firewall's
established-rule decision is thus driven by attacker-controlled or stale bytes.
Build & Run
# Build the trigger
cc -O2 -Wall -o trigger trigger.c
# Setup (as root β loads ipfw3 with default-accept so SSH survives, adds rules)
sh setup.sh
# Run the trigger (as root β needs raw socket)
sh run.sh
# or directly:
./trigger
ipfw3 show # check rule-hit counters
Expected output (bug present, unpatched module)
- Rule
100 allow tcp established: counter increases (Cases 1, 3, 4 match) - Rule
65534 allow ip from any to any: counter increases by 2 pkts / 88 bytes for Case 2 (SYN byte at fragment position 13 β NOT established) - The attacker controls whether a non-first TCP fragment matches "established"
Expected output (fixed module)
- Rule
100: counter increases for ALL fragment cases (fid->flags=0 for fragments β (0 & mask) != SYN β always matches established) - Rule
65534: 0 delta β no fragments fall through
Precondition
An admin has loaded ipfw3 with a check_established rule (e.g. allow tcp ...
established). This is a standard firewall configuration. The vulnerability is
triggered by any packet reaching the host's ip_input β local (via raw socket
on loopback) or remote.
Fix
Use the cached fid->flags (populated only for first/unfragmented TCP segments
at ip_fw3.c:391) instead of re-reading the mbuf. For non-first fragments,
fid->flags stays 0, giving a deterministic, safe firewall decision. See
fix.diff.
Notes
- The OOB read does NOT panic (stays within mbuf backing store).
- No userspace info leak (the stale byte is used internally for firewall decision, never returned to userspace).
- No escalation path β this is a logic/misclassification bug, not a write primitive.
- FreeBSD ipfw2 uses the cached
f_id.flagsapproach (avoids this bug).
DF-0747 β VERDICT
Verdict: REPRODUCED (Low severity; OOB read + firewall misclassification; fix VALIDATED)
Mechanism
Trigger β primitive β effect
-
Trigger: A non-first IPv4 fragment of a TCP datagram arrives at
ip_input().pfil_run_hooks(&inet_pfil_hook, ...)runs atip_input.c:631beforeip_reass()atip_input.c:858, so individual fragments reach ipfw3 before reassembly. -
ipfw3 dispatcher (
sys/net/ipfw3/ip_fw3.c:317-411): - Line 368:proto = args->f_id.proto = ip->ip_p;β set unconditionally, regardless of fragment offset. Sof_id.proto == IPPROTO_TCPfor TCP fragments. - Line 371:offset = ntohs(ip->ip_off) & IP_OFFMASK;βoffset > 0for non-first. - Line 384:if (offset == 0)β thePULLUP_TO(hlen + sizeof(tcphdr))at line 387 is skipped for non-first fragments.args->f_id.flags(line 391) is never set. -
check_established (
sys/net/ipfw3_layer4/ip_fw3_layer4.c:173-191): - Line 182:if (fid->proto == IPPROTO_TCP)β true for TCP fragments (set at ip_fw3.c:368 unconditionally). - Line 184:L3HDR(struct tcphdr, ip)->th_flagsβL3HDRis defined atip_fw3.h:524as(T *)((uint32_t *)(ip) + (ip)->ip_hl), i.e.ip + ip_hl*4. For a standard 20-byte header,th_flagsis atip + 20 + 13 = ip + 33. - For a non-first fragment with 8-byte payload (RFC 791 minimum), the valid data ends at byte 27 (ip_len = 28). Reading at byte 33 is a 5-byte over-read past validm_leninto the mbuf backing store. - For fragments with β₯ 14 bytes of payload, byte 33 is within the attacker- controlled payload. The attacker controls the value read asth_flags. -
Effect: The firewall decision (established match) is driven by stale or attacker-controlled bytes. An attacker sending crafted non-first TCP fragments can deliberately cause a fragment to match or fail the established check by controlling byte 13 of the fragment payload.
Why no panic / no escalation
- The OOB read stays within the mbuf's allocated backing store (inline mbuf data area or cluster), so no page fault / panic.
- The stale byte is used internally for the firewall decision β never returned to userspace, so no info leak.
- This is a logic/misclassification bug, not a write primitive. No escalation path.
Reproduction evidence
Baseline (unpatched module, kernel #0)
Rule set: 100 allow tcp established, 65534 allow ip from any to any.
The trigger sends 4 crafted non-first TCP fragments (offset=8): - Case 1: 24B payload, byte[13]=0x10 (ACK) β should match established - Case 2: 24B payload, byte[13]=0x02 (SYN) β should NOT match - Case 3: 8B payload, byte[13] OOB read β stale byte - Case 4: 14B payload, byte[13]=0x10 (ACK) β should match
Counters delta (after - before), over 2 independent runs:
| Run | Rule 100 Ξ (established) | Rule 65534 Ξ (fallthrough) |
|---|---|---|
| 1 | +16 pkts / +1928 B | +2 pkts / +88 B |
| 2 | +14 pkts / +1732 B | +2 pkts / +88 B |
Rule 65534 delta = exactly 2 Γ 44 bytes = Case 2 fragment (44B) evaluated on both PFIL_IN and PFIL_OUT. This proves check_established read the attacker- controlled byte 0x02 (SYN) at fragment position 13 and classified the fragment as non-established.
Positional confirmation (test2)
Additional test varying the byte at position 13: - ACK@byte13 β MATCH (rule 100) - SYN@byte13 β NOT match (rule 65534) β 4 pkts / 176 B (2 fragments Γ 2 dirs) - 0xAA@byte13: (0xAA & 0x16) = 0x02 = SYN β NOT match (rule 65534)
Result: rule 65534 delta = 4 pkts / 176 B = exactly the SYN@13 + 0xAA@13 fragments. This confirms byte 13 is the exact position read.
Fixed module (ipfw3_layer4.ko replaced, same kernel #0)
With the fix, check_established uses fid->flags (0 for non-first fragments)
instead of reading the mbuf. All fragments deterministically match established.
| Test | Rule 100 Ξ | Rule 65534 Ξ |
|---|---|---|
| Baseline | +14/+16 | +2 pkts / 88 B |
| Fixed module | +16 | 0 pkts / 0 B |
The 2-packet fallthrough (Case 2 SYN@byte13) is gone with the fix. The misclassification is eliminated.
PoC changes
Created from scratch (no prior PoC existed on disk):
- trigger.c β sends 4 crafted non-first TCP fragments via raw socket (IP_HDRINCL)
to 127.0.0.1, controlling byte 13 of the fragment payload (the th_flags position)
- setup.sh β loads ipfw3 modules with default-accept, installs rules
- build.sh, run.sh β convenience scripts
Fix
fix.diff changes check_established to use the cached fid->flags instead of
re-reading L3HDR(struct tcphdr, ip)->th_flags from the mbuf. The fid->flags
field is populated only for first/unfragmented TCP segments at ip_fw3.c:391
(inside the offset == 0 branch). For non-first fragments, it stays 0, giving a
deterministic and safe firewall decision. The now-unused struct mbuf *m and
struct ip *ip local variables are removed.
This matches the FreeBSD ipfw2 approach (using cached f_id.flags).
Fix validation
- Method: Built the fixed
ipfw3_layer4.komodule from patched source, replaced/boot/kernel/ipfw3_layer4.ko, rebooted, re-ran trigger. - Kernel: Still #0 (the bug is in a loadable module, not the kernel binary).
- Module hash:
aec57daea3003e1342c79e336fc0f7d7bdb3496620092f67da62c0d5ef66e001 - Result: Rule 65534 delta went from 2 pkts / 88 B (buggy) to 0 pkts / 0 B (fixed). The attacker-controlled misclassification is eliminated.
Fix verification
fixedVALIDATED: baseline 2 pkts/88B on fallthrough (misclassified); fixed 0 pkts/0B (all match established).
BEFORE: 2 pkts/88B (SYN@byte13 NOT established). AFTER: 0 pkts/0B (ALL fragments established via fid->flags=0).
Confirmed kernel references
Detail
Exploit chain
none -- non-corruption. OOB read within mbuf backing store (no crash, no page fault, no leak to userspace). Firewall misclassification only. No write primitive.
Evidence (decisive lines)
baseline: SYN@byte13 fragment -> 2 pkts/88B on fallthrough (misclassified as non-established). fixed: 0 pkts/0B on fallthrough (all fragments match established via fid->flags=0).
PoC changes
Created from scratch: trigger.c (crafted non-first TCP fragments), setup.sh (ipfw3 load+rules), fix.diff (fid->flags instead of th_flags), build.sh, run.sh, VERDICT.md, manifest.json.
Verified recommended fix
In check_established:184, replace L3HDR(struct tcphdr, ip)->th_flags with fid->flags. For non-first fragments fid->flags=0 -> deterministic established match. Matches finding proposal + FreeBSD ipfw2. Full git-apply-able diff in findings/poc/DF-0747/fix.diff.
Verdict
REPRODUCED. check_established reads th_flags at ip+33 on every TCP packet including non-first IP fragments where TCP header is absent. For 8-byte-payload fragment: 5-byte OOB read past valid m_len. For larger fragments: attacker-controlled byte drives firewall decision. Confirmed via ipfw3 counter analysis.
No comments yet.