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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0747 Β· 16 files
FileTypeDescriptionSize
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
README.md readme how to reproduce
↓ download 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.flags approach (avoids this bug).
VERDICT.md verdict full analysis and fix validation
↓ download raw

DF-0747 β€” VERDICT

Verdict: REPRODUCED (Low severity; OOB read + firewall misclassification; fix VALIDATED)

Mechanism

Trigger β†’ primitive β†’ effect

  1. Trigger: A non-first IPv4 fragment of a TCP datagram arrives at ip_input(). pfil_run_hooks(&inet_pfil_hook, ...) runs at ip_input.c:631 before ip_reass() at ip_input.c:858, so individual fragments reach ipfw3 before reassembly.

  2. 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. So f_id.proto == IPPROTO_TCP for TCP fragments. - Line 371: offset = ntohs(ip->ip_off) & IP_OFFMASK; β€” offset > 0 for non-first. - Line 384: if (offset == 0) β€” the PULLUP_TO(hlen + sizeof(tcphdr)) at line 387 is skipped for non-first fragments. args->f_id.flags (line 391) is never set.

  3. 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 β€” L3HDR is defined at ip_fw3.h:524 as (T *)((uint32_t *)(ip) + (ip)->ip_hl), i.e. ip + ip_hl*4. For a standard 20-byte header, th_flags is at ip + 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 valid m_len into 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 as th_flags.

  4. 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.ko module 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged; fixed ipfw3_layer4.ko sha256 aec57dae...)

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.