Skip to content

fix(bpf): verify XDP program on 6.x kernels + cross-kernel CI#426

Merged
pigri merged 29 commits into
mainfrom
fix/xdp-verifier-6x
Jul 8, 2026
Merged

fix(bpf): verify XDP program on 6.x kernels + cross-kernel CI#426
pigri merged 29 commits into
mainfrom
fix/xdp-verifier-6x

Conversation

@pigri

@pigri pigri commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

fix(synapse-security/bpf): XDP program exceeds the BPF verifier limit on 6.x — bpf_loop + off-stack fingerprint data; add cross-kernel CI

Summary

The XDP firewall/fingerprint program
(crates/synapse-security/src/firewall/bpf/xdp.bpf.c) fails to load on stock 6.x
kernels: the verifier rejects it with -E2BIG
(BPF program is too large. Processed 1000001 insns). Where it can't load,
synapse silently drops to the AF_PACKET capture fallback — functional, but no XDP
fast path and no in-XDP drop.

This PR makes the program verify across kernel generations and adds a CI job that
proves it.

Root cause

The TCP-option parse loops in lib/tcp_fingerprinting.h
(parse_tcp_mss_wscale, fill_ja4t_option_kinds) walk PTR_TO_PACKET with a
variable stride. The verifier re-explores the loop body for every reachable
packet offset; the resulting state explosion pushes total processed instructions
just past the 1,000,000 ceiling.

It is not kernel-version-specific — measured identically (1,000,001 insns) on
x86 6.1/6.6/6.12/6.18/6.19 and on arm64 6.6. Newer verifiers do not prune it
under the limit, and compiler flags (-O2/-O3/unroll) don't change it. The fix
has to reduce the verifier's work.

Fix (lib/tcp_fingerprinting.h, behavior-preserving)

  1. bpf_loop() for option parsing. Each option loop now copies the ≤40
    option bytes once (constant-size ladder) into a per-CPU scratch buffer and
    walks it via bpf_loop(), so the verifier checks the body a single time.
    1,000,001 → 168,876 processed instructions.
  2. Fingerprint data off the stack. bpf_loop() adds a callback frame;
    combined with the 96-byte struct tcp_fingerprint_data held on the stack of
    the reachable subprograms, the call chain then broke the 512-byte BPF stack
    limit (combined stack size … 544. Too large). Moving that struct into a
    per-CPU scratch map value clears it.

Option fidelity and all map keys/values are unchanged. Per-CPU scratch is
race-free under XDP (preemption disabled).

Before / after

Real object, recompiled against each kernel's own BTF (CO-RE-off), JIT on:

Kernel Before After
x86 6.1.176 E2BIG (1,000,001) verifies
x86 6.6.125 E2BIG (1,000,001) verifies
x86 6.12.94 E2BIG (1,000,001) verifies
x86 6.18.37 E2BIG (1,000,001) verifies
x86 6.19.14 E2BIG (1,000,001) verifies
arm64 6.6 (target) E2BIG (1,000,001) verifies

CI (.github/workflows/xdp-e2e*.yml, tests/e2e-xdp/)

Adds a cross-kernel verification gate (matrix 6.6.125 / 6.12.94 / 6.19.14),
modeled on the k8spacket reusable-workflow pattern. Each leg dumps the target
kernel's BTF → recompiles xdp.bpf.c against it (CO-RE-off, so a FAIL is a real
verifier rejection, not an offset mismatch) → loads it in QEMU with
bpf_jit_enable=1 → asserts the program verifies. Test kernels are built with
BPF_JIT_ALWAYS_ON + DEBUG_INFO_BTF and cached; x86 uses KVM when the runner
provides it, TCG otherwise.

Requires a CARGO_REGISTRIES_GEN0SEC_TOKEN repo secret (to resolve
dendrite-core BPF headers via cargo metadata).

Testing

  • Matrix above: red on every kernel before, green after.
  • CI recipe validated end-to-end (make -C tests/e2e-xdp e2e KERNEL=6.6.125).

Notes

  • No functional change to userspace, maps, or the fingerprint output.
  • The XDP path now loads instead of falling back to AF_PACKET on affected
    targets.

The XDP program's TCP-option parse loops walk PTR_TO_PACKET with a
variable stride, so the verifier re-explores the loop body per packet
offset and total processed instructions exceed the 1,000,000 ceiling
(-E2BIG) on every 6.x verifier tested (6.1..6.19, x86 and arm64). Where
it cannot load, capture falls back to AF_PACKET.

Parse TCP options via bpf_loop() over a per-CPU scratch copy so the body
is verified once (1,000,001 -> 168,876 processed insns), and move the
96-byte tcp_fingerprint_data off the BPF stack into a per-CPU scratch map
value so the added callback frame stays within the 512-byte stack limit.

Behavior-preserving: same option fidelity, map keys and values.
Comment thread .github/workflows/xdp-e2e-reusable.yml Fixed
Comment thread .github/workflows/xdp-e2e.yml Fixed
@pigri pigri force-pushed the fix/xdp-verifier-6x branch from b8dd97a to 14c3f8c Compare July 4, 2026 20:51
Add tests/e2e-xdp: for each kernel version, dump its BTF, recompile
xdp.bpf.c against it (CO-RE-off, so a failure is a real verifier
rejection rather than an offset mismatch), and load it in QEMU with the
JIT forced on. Guards against XDP verifier complexity / stack-depth
regressions across kernel generations.

The workflows run a matrix (6.6.125 / 6.12.94 / 6.19.14) via a reusable
per-kernel job; test kernels are built with BPF_JIT_ALWAYS_ON + BTF and
cached. Requires the CARGO_REGISTRIES_GEN0SEC_TOKEN secret to resolve
dendrite-core BPF headers.
@pigri pigri force-pushed the fix/xdp-verifier-6x branch from 14c3f8c to 9815922 Compare July 4, 2026 21:09
pigri and others added 15 commits July 6, 2026 08:13
Make managed IDS work with just `ids.enabled: true`, driven by the
platform's rule-class selection (ids_rules.classes), without extra local
wiring:

- managed_rules_base_url falls back to the platform API URL
  (platform.base_url) when unset.
- managed_rules_path defaults to /var/lib/synapse/managed.rules.
- egress_ifaces defaults to ["auto"] (the XDP-attached NICs); "auto"
  entries are a no-op marker, not a literal interface name.
- config.yaml: document the above as commented defaults.
Both are Vec<String>, but a single value is the common case and
`egress_ifaces: auto` (a scalar) is the natural way to write it — which
failed with "invalid type: string "auto", expected a sequence". Accept
either a bare string or a sequence via a string-or-vec deserializer.
- Fingerprint prober logs (target synapse_utils::fingerprint::*, e.g. the
  JA4X prober) were landing in app.log via the root logger. Route them to
  a dedicated, size-rotated fingerprint.log with additive(false) so they
  stay out of app.log.
- eventbridge.log used a plain append writer with no rotation, unlike the
  log4rs-managed app/error/access logs. Add size-based rotation
  (max_log_size / log_file_count) so every log file rotates under the same
  policy.
blocked.log (synapse-blocking-log) used a plain mutex-guarded append
writer with no rotation, unlike the log4rs-managed app/error/access logs.
Add size-based rotation driven by the same logging.max_log_size /
log_file_count config so blocked.log rotates under the same policy as
every other log file.
telemetry.log (synapse-telemetry file sink) used a plain Mutex<File>
append writer with no rotation. Add size-based rotation via new
Config.max_log_size / log_file_count, wired from logging.max_log_size /
log_file_count — completing rotation coverage across every synapse log
file (log4rs app/error/access/terminal/fingerprint plus the custom-writer
blocked/eventbridge/telemetry logs).
- init_ids_runtime bailed when rule_paths was empty even with
  managed_rules_path set (contradicting the field's own doc). Now it starts
  when either is configured, and seeds an empty managed_rules_path file so
  the engine starts at 0 rules and hot-reloads when the bundle lands.
- The ids-rules sync only re-ran on its interval or a dedicated
  ids-rules-updated event, so a config-updated that changed ids_rules (and
  the startup race where the first sync precedes the initial config apply)
  left managed rules empty until the next interval. set_global_config now
  fires an on_ids_rules_change callback — config is stored before the
  callback, so the sync reads the fresh selection with no race.
synapse built thalamus RuleVars straight from the (often empty) config,
bypassing thalamus's own RuleVars::default() — so managed rules referencing
$HTTP_PORTS / $ORACLE_PORTS / $SSH_PORTS / etc. fell back to `any`
(over-broad matching + potential false positives) and flooded a per-rule
"Unknown port/address variable" WARN.

Add build_rule_vars(): thalamus defaults, then the standard Suricata port/
address vars it omits (inserted only when absent), then operator config on
top. Used by all four rule-loading paths (XDP, AF_PACKET, L7 ingest, Windows).
When geoip config leaves asn/city url+path empty (e.g. IPinfo Lite's
combined DB already carries ASN), asn_path/city_path resolve to None but
the manager still fell back to GeoLite2-{ASN,City}.mmdb and refresh_all
warned every cycle about the missing file. Track whether each optional DB
is configured and skip its refresh (and warning) when it isn't. Country
is unchanged.
- rustfmt the recent logging/config/ids changes (fmt job was failing).
- Update test/example call sites for the changed signatures that
  `clippy --all-targets` compiles: the eventbridge_log::start(path, max,
  count) test call, and synapse_telemetry::Config's new max_log_size /
  log_file_count fields in the e2e_emit example.
to_log_line() emitted layer/action/src/dst/rule/msg/hits/fingerprints but
dropped `source` (primary driving signal) and `signals` (all contributing
detectors) — which the OTEL block_event already carries from the same
BlockEvent. Add both, plus a canonical BlockSource::as_str() so the local
line uses identical names to the remote record (e.g. source=threat_intel,
signals=fingerprint_rule,ids).
… blocks

The platform derives the geo columns (country, ASN) server-side, so the
local blocked.log line lacked them. The smart-firewall already resolves
source-IP geo for the wirefilter ip.src.country/asn fields, so carry it onto
the BlockEvent via a new Builder::geo() and emit it in to_log_line. Wired at
the five external-facing smart-firewall emit sites (kernel_pump XDP +
afpacket); the east-west microseg path is left out (internal, geo N/A).
Fields are optional and skipped when unset, so other layers' lines are
unchanged.
blocked.log was human-readable text only. Add logging.blocked_log_format
(text|json, default text): in json mode the file sink writes one serde
BlockEvent object per line — the same JSON shape already used on the
log::info fallthrough — carrying every field (source/signals/geo/
fingerprints/eve). init() gains a `json` flag; unknown format values fall
back to text. Text remains the default, so existing deploys are unchanged.
The smart-firewall block message was a static "smart_firewall_rules
wirefilter match (kernel_pump)" — internal jargon (kernel_pump is a module
name) that says nothing about why the block fired. Carry the matched rule's
wirefilter expression through EvalMatch (via a name->expr map kept alongside
the ruleset, since CompiledRule doesn't expose its source text) and emit it
as rule_msg, so the log shows the actual condition, e.g.
`threat.score ge 90 and threat.advice eq "block"`. The identity edge-drop
path keeps an "identity edge-drop —" prefix since that enforcement detail
(edge drop vs IP ban) isn't in the expression.
The earlier guard (7fc9ddc) used asn_path.is_some(), but the config push
emits an empty-string path for an unconfigured DB, which serde deserializes
to Some(PathBuf("")) — is_some() is true, so refresh_all still tried
ASN/City, MaxMindManager fell back to its default GeoLite2-*.mmdb filename,
and the "Failed to open MMDB file" warning kept firing every refresh.
Require a non-empty path for asn_configured / city_configured.
@pigri pigri force-pushed the fix/xdp-verifier-6x branch from 2a0d3f7 to e1a425e Compare July 6, 2026 21:21
pigri added 9 commits July 7, 2026 00:00
Add `ml: { fingerprint_classifier, traffic_classifier, penalty }` as the
canonical home for the ML model configs. `classifier` was an over-generic
name for the JA4+ fingerprint model; it becomes `ml.fingerprint_classifier`,
parallel to `ml.traffic_classifier` / `ml.penalty`.

The deprecated top-level keys (`classifier`/`traffic_classifier`/`penalty`)
are still accepted and migrated into the flat runtime fields at load with a
deprecation warning, so existing platform-pushed configs keep working while
the config-generator transitions to emitting `ml:`. The `ml` section and the
migration are feature-gated behind `classifier`, like the fields they group.
Move the JA4+ classifier example under `ml.fingerprint_classifier` and
document `ml.traffic_classifier` / `ml.penalty` alongside it, matching the new
schema. The Windows example is updated in kind (still commented — ML is
Linux-only).
…restart

The worker tracked the applied ruleset version only in memory (RwLock, None on
a fresh process), so every restart re-downloaded the full managed ruleset
(~49k rules) even when managed.rules on disk was already the wanted version —
the version-equality skip at reconcile could never fire at startup.

Write a `{managed_rules_path}.version` sidecar (AppliedState JSON) on each
successful sync and seed `applied` from it in `new()`, so a restart with a
current on-disk ruleset skips the redundant re-download. The sidecar is only
trusted when managed.rules itself exists; a missing/unparseable sidecar falls
back to a normal sync. Mirrors the traffic/penalty model `.version` sidecar.
A model with enabled:true but no model_path/manifest_url silently never
registered its worker — nothing loaded, nothing downloaded, and the log stayed
empty, leaving operators to guess why. Emit a per-model startup warning naming
the missing field(s), for fingerprint_classifier / traffic_classifier /
penalty (the download models also flag a missing manifest_url with no local
file to fall back on).
An enabled traffic/penalty model previously needed model_path + manifest_url +
refresh_secs all set explicitly or it silently did nothing. Now `enabled: true`
alone works:
  model_path   → <working dir>/<name>.onnx (service WorkingDirectory=/var/lib/synapse)
  manifest_url → <platform.base_url>/models/<name>/manifest
  refresh_secs → 3600
fingerprint_classifier (local, no manifest) gets the model_path default only.
Any field set explicitly still overrides its default. Example config updated.
Add default_data_dir() = process cwd (the systemd units pin WorkingDirectory=
/var/lib/synapse), falling back to /var/lib/synapse when cwd is the filesystem
root or unavailable. Route the data-file path defaults through it so they
follow one working directory instead of scattered hardcoded paths:
  - managed_rules_path: /var/lib/synapse/managed.rules → {data_dir}/managed.rules
  - acme storage: /tmp/synapse-acme → {data_dir}/acme (was volatile — certs
    lost on reboot, risking Let's Encrypt rate limits)
  - ml.*.model_path already derived from cwd; now shares the helper
Log paths (/var/log), pid files and sockets (/run) are intentionally left
alone — those don't belong under the working dir.
Add a `logging.module_levels` map (target → level) so one component can be
turned up without flooding the whole log — e.g. `synapse_idp: debug` for
Thalamus (IDS) debug only, while everything else stays at `logging.level`.
Each entry becomes a log4rs per-target Logger (additive → routes to the same
app.log/console appenders); log4rs raises the global max level so the finer
records actually flow. Invalid levels are warned and skipped.
The rule-expression map static (added for rule_msg = matched expression) was
left ungated while OnceLock/RwLock and the sibling GLOBAL_RULESET /
GLOBAL_SOURCES statics are cfg(feature = "amygdala-reactor") only. Non-reactor
builds — the Windows MSI job (amygdala-ndis) — then failed to compile with
"cannot find type OnceLock/RwLock in this scope". Gate GLOBAL_EXPRESSIONS the
same way; everything that reads it already lives in reactor-gated functions.
`evaluate()` used RuleSet::first_match, so the first matching rule — even a
staged `log` rule — stopped evaluation and shadowed any enforcing rule behind
it. Change it to walk all rules (RuleSet::iter + CompiledRule::matches):
`log` matches are collected and evaluation continues; the first non-log match
(drop/allow) terminates the chain. Returns Vec<EvalMatch> (log entries then one
optional enforcing entry). All three call sites (kernel_pump north-south,
afpacket, east-west microseg) updated to emit a Notice per log match and act on
the enforcing one. Non-reactor build returns an empty vec.
pigri added 3 commits July 8, 2026 11:57
Firewall — per-(src,dst) north-south edge-drop across three backends: XDP
(banned_edges_v4/v6 maps), nftables (saddr.daddr timeout set + forward
chain) and iptables (FORWARD chain), with a userspace TTL sweeper.

IDS/SNI — extract tls.sni on one-sided / NAT'd captures and route it over
the eventbridge inspect broadcast so the smart-firewall tls.sni field
populates. Consumes thalamus 0.0.12 (drops the local [patch]; bumps the
pin across all consumers).

Security-event log — rename blocked.log -> events.log and the BlockEvent
schema -> SecurityEvent (v2). Config keys events_log_file/events_log_format
(blocked_log_* kept as serde aliases). Add sni, traffic_class/confidence,
classifier_score, penalty_severity/bucket, ids_sids, geo and eve to the
record, JSON schema and OTel attributes. Split the smart-firewall log-rule
action into a distinct 'log' (notice stays for detector detect-only).

Stores — ids_signals retains contributing SIDs; new penalty_signals holds
per-source penalty-model decisions.

Also fixes a stale dns_fqdn_repro test against the Vec-returning evaluate API.
Collapse the eprintln! call that nightly rustfmt fits on one line
(the wellness-check Formatting gate uses nightly).
The access-rules EdgeFallback dispatch calls ban_edge/unban_edge on
NftablesFirewall/IptablesFirewall, but on not(all(unix, feature="bpf"))
targets — Windows (not unix) and no-bpf legacy builds — those resolve to
the firewall_noop stubs, which lacked the methods, so the build failed.
Add no-op edge methods (+ the IpAddr import) so the dispatch compiles on
every target; real nftables/iptables edge-drop stays a Linux+bpf feature.
@pigri pigri merged commit 0e4f827 into main Jul 8, 2026
31 checks passed
@pigri pigri deleted the fix/xdp-verifier-6x branch July 8, 2026 11:33
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