Skip to content

eBPF datapath: IHL offset, IPv6 ext-headers, byte order, atomic counts, incident sampling - #63

Closed
Tumult1337 wants to merge 4 commits into
awlx:mainfrom
Tumult1337:pr/ebpf-datapath
Closed

eBPF datapath: IHL offset, IPv6 ext-headers, byte order, atomic counts, incident sampling#63
Tumult1337 wants to merge 4 commits into
awlx:mainfrom
Tumult1337:pr/ebpf-datapath

Conversation

@Tumult1337

Copy link
Copy Markdown
Contributor

eBPF XDP datapath hardening.

Commits

  • Harden datapath: atomic rate counting, LRU bad_flags map, VLAN parsing, byte order.
  • Locate the IPv4 TCP header using IHL instead of a fixed 20-byte offset.
  • Walk IPv6 extension headers before dispatching L4 checks.
  • Sample kernel telemetry emits so a flood cannot saturate the perf ring (perf).

Reviewer notes (intentional, low severity)

  • Frames still carrying a VLAN ethertype after 3 stacked tags are XDP_DROPed (fail-closed) unless in monitor mode — affects QinQ deployments with 4+ tags only.
  • IPv6 packets whose extension-header chain exceeds the walk limit are passed (documented, consistent with other unparseable-packet paths).
  • Pre-existing (not this change): the v4 blocked-IP path increments the stored block timestamp per dropped packet; v6 does not. Minor TTL drift under sustained flood — flagging for a future follow-up.

Validation: go build ./... clean (eBPF object compiled separately on a Linux/clang host).

Four datapath-correctness fixes to protector.bpf.c, verifier-validated by
loading the collection (all programs accepted):

- check_rate_limit incremented a CPU-shared rate_limit value with a plain
  read-modify-write, losing increments under a same-source flood spread across
  RX queues so the count stayed under the limit. Use __sync_fetch_and_add and
  test the post-increment value (matching blocked_ips/policy_blocks).
- bad_flags / bad_flags_v6 were plain HASH maps capped at 1000, so a spoofed
  scan flood filled them and further inserts failed with E2BIG, blinding the
  userspace signal pipeline to new scanners. Switch to LRU_HASH (like every
  sibling per-source map) and size via BADFLAGS_MAP_SIZE.
- A frame with more stacked VLAN tags than the parser unwinds left h_proto as a
  VLAN ethertype and fell through to XDP_PASS uninspected. Parse up to 3 tags
  and fail closed (XDP_DROP, honoring monitor/dry-run) when still tagged.
- meta.window was stored in network byte order while Go decodes EventMetadata
  little-endian, byte-swapping the TCP window used for JA4T fingerprinting.
  Apply bpf_ntohs like seq/ts_val.
The XDP and TC programs located the TCP header at (void *)(ip + 1), a hardcoded
20-byte IP header, ignoring the attacker-controlled IHL field. A packet carrying
any IP options (IHL>5) then had its option bytes parsed as the TCP header: a
SYN+FIN/Xmas/NULL scan with one IP option read benign bytes where the flags
should be and evaded check_tcp_flags, and the TC path misparsed JA4T/timestamp
data the same way.

ipv4_tcp_header validates IHL (5..15), computes the L4 offset as ihl*4, and
bounds-checks before returning the header (NULL if invalid/truncated). Applied at
the XDP flag check and both TC monitor paths.

Verifier-accepted; BPF_PROG_TEST_RUN confirms a SYN+FIN scan carrying an IP
option is now dropped while a benign SYN with options passes.
The XDP program dispatched the IPv6 ICMPv6/UDP/fragment/TCP checks on
ip6->nexthdr directly, so a single extension header (e.g. an 8-byte Hop-by-Hop
Options header in front of the L4 header) left nexthdr=0 and matched none of the
checks - the packet fell through to XDP_PASS. One extension header defeated every
IPv6 L4 protection; a UDP/ICMPv6 flood or a SYN/Xmas scan behind a Hop-by-Hop
header bypassed rate limiting and flag detection entirely.

parse_ipv6_l4 walks the extension-header chain (bounded to MAX_IPV6_EXT_HEADERS,
respecting each header's length, with per-step packet-bounds checks and
fail-closed on a truncated/over-long chain) to find the true upper-layer protocol
and header offset. All IPv6 L4 decisions now run off that result, and the TCP
flag check reads the header at its real post-extension-header offset. Replaces
the unused, length-ignoring count_ipv6_ext_headers helper.

Verifier-accepted; BPF_PROG_TEST_RUN confirms a UDP flood behind a Hop-by-Hop
header is now dropped while a single such packet still passes.
The XDP program called bpf_perf_event_output on every XDP_DROP (incidents) and
on every SYN (JA4T events). Under a multi-Mpps flood that emits one perf event
per packet, saturating the ring buffer - which drops events for everyone and
burns CPU on the per-event copy and userspace wakeup - precisely when the host
is most loaded, a self-inflicted amplification on the drop path.

Add per-CPU emission budgets (incident_budget, event_budget) that cap emits to
MAX_EMITS_PER_SEC_PER_CPU per 1s window. Enforcement is untouched - the packet
still drops and handshake tracking still runs for every SYN - only the
audit/telemetry stream is sampled. Per-CPU counters avoid cross-core contention.

Verifier-accepted; BPF_PROG_TEST_RUN over 3000 dropping packets confirms every
packet is still dropped while incident emits cap at the per-CPU budget (1000)
instead of 3000.
awlx added a commit that referenced this pull request Aug 8, 2026
…x 5.4

make e2e-ebpf-test failed to load tc_ingress_syn_monitor: 'BPF program is too
large. Processed 1000001 insn'. Root cause: PR #63 changed the IPv4 TCP header
location from a fixed 20-byte offset to the variable IHL offset
(ipv4_tcp_header). Feeding that variable-offset pointer into
parse_tcp_timestamp's 10-iteration variable-advance option walk multiplied the
verifier's per-iteration branch states past the 1M-instruction limit. Pre-#63
the same walk ran from a constant offset and verified fine.

A noinline BPF-to-BPF subprogram is not an option here: passing PTR_TO_PACKET to
a subprogram is only reliably verifiable on Linux 5.10+, and this project
targets Linux 5.4+.

Fix: parse the TCP timestamp from a constant-offset header, gated on IHL==5 (no
IPv4 options), so the option walk regains its pre-#63 constant base and the
verifier stays in budget. SYN/flags detection and handshake tracking still use
the IHL-correct header, so the security-relevant behavior from #63 is preserved;
only the clock-skew timestamp (a nicety) is skipped for the rare option-bearing
IPv4 packets. parse_tcp_timestamp stays inlined (5.4-safe).

Validated: clang compiles the object clean in a Linux container; the timestamp
parser remains fully inlined (no BPF-to-BPF call). Verifier load itself requires
a real kernel + root (CI e2e-ebpf).
awlx added a commit that referenced this pull request Aug 8, 2026
make e2e-ebpf-test failed to load tc_ingress_syn_monitor: 'BPF program is too
large. Processed 1000001 insn'. clang compiles fine; the verifier rejects the
load.

Root cause: parse_tcp_timestamp walks TCP options in an unrolled loop that is
inlined into the TC ingress SYN paths for both IPv4 and IPv6. PR #63 changed the
IPv4 caller to locate the TCP header via the variable IHL offset
(ipv4_tcp_header) instead of a fixed 20-byte offset - correct for reading flags
past IPv4 options, but it feeds a variable-offset base into that loop. The
per-iteration branch states of a variable-length option walk grow steeply with
the iteration count; at 10 iterations (inlined twice) the accumulated state
exploration tipped the program past the verifier's 1M-instruction limit. On
current main the same 10-iteration walk verified fine only because it ran from a
constant offset.

A noinline BPF-to-BPF subprogram is not an option: passing PTR_TO_PACKET to a
subprogram is only reliably verifiable on Linux 5.10+, and this project targets
Linux 5.4+.

Fix: bound the option walk to the first TCP_TS_MAX_OPTIONS (8) options. Real TCP
SYNs place the timestamp option within the first few options (Linux/Windows/
macOS all at or before ~6), so this still finds it in practice, while keeping
all of PR #63's intent intact - the IHL-correct header is still used for
SYN/flags detection, handshake tracking, AND timestamp parsing (including for
IPv4 option-bearing packets), IPv6 handling, rate limits, byte order, incident
sampling, and monitor/enforcement behavior are unchanged. parse_tcp_timestamp
stays inlined (5.4-safe).

Validated by loading the compiled object against a real kernel in a privileged
container: tc_ingress_syn_monitor drops from >1,000,001 to 250,774 processed
insns (xdp_filter 17,857; tc_egress 109), all well under the 1M limit. The
verifier's processed-insn count for this object was identical on that kernel and
the GitHub CI kernel (both hit exactly 1,000,001 before the fix), so the margin
transfers. Kernel eBPF load itself still requires the CI e2e-ebpf job.
awlx added a commit that referenced this pull request Aug 8, 2026
@awlx

awlx commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Superseded by and merged through #77, which preserves this contribution and includes the follow-up fixes. Thanks for the contribution!

@awlx awlx closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants