From 2b0070ad5876413eaa65af3ffb47596d7ee5d20d Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 15 Jul 2026 13:17:49 -0400 Subject: [PATCH 1/6] fix(xdp): race-free per-source RATE bucket via per-CPU LRU map (X1) The per-source rate limiter's RATE map RMW (refill + decrement in over_rate) was non-atomic on the CPU-shared LruHashMap, so an RSS-spread flood from one spoofed source could race across CPUs and leak the configured pps limit by roughly N_cpus x. Feasibility spike: a single shared bucket guarded by a bpf_spin_lock field was implemented first, per the brief. The verifier rejected it live via BPF_PROG_TEST_RUN on this kernel/toolchain: "map 'RATE' has to have BTF in order to use bpf_spin_lock". aya-ebpf 0.1.1's #[map] macro emits the legacy bpf_map_def-based `maps` ELF section rather than a BTF-defined map, so aya never populates btf_key_type_id/btf_value_type_id at map-creation time, and the kernel refuses bpf_spin_lock without it. This is an aya-ebpf 0.1.1 limitation, not a kernel one. Shipped fix: RATE is now an LruPerCpuHashMap<[u8;16], RateBucket>. Each CPU holds its own independent bucket for a given source, so over_rate's lookup is inherently isolated per CPU (the kernel indexes percpu map lookups by the running CPU) and the RMW needs no lock. This is race-free but looser than the shared-bucket design: the effective per-source limit becomes up to N_cpus x configured burst under an RSS-spread flood, never tighter. Userspace's rate_limit() now seeds every CPU's slot identically via PerCpuValues. Verified live (root, BPF_PROG_TEST_RUN, kernel 6.18): the program loads under the per-CPU RATE map value, and a new prog_test_run.rs test (pinned to CPU 0 so the sequence hits one bucket slot) confirms burst packets are admitted then the next is dropped with REASON_RATELIMIT. The existing veth-based ddos_drop.rs load test (rate_limited_source_under_load_admits_a_burst_then_drops_the_excess) still passes unchanged. --- crates/blackwall-xdp-common/src/lib.rs | 30 ++++ crates/blackwall-xdp-ebpf/src/main.rs | 29 +++- crates/blackwall-xdp/src/dataplane.rs | 22 ++- crates/blackwall-xdp/tests/prog_test_run.rs | 152 ++++++++++++++++++++ 4 files changed, 225 insertions(+), 8 deletions(-) diff --git a/crates/blackwall-xdp-common/src/lib.rs b/crates/blackwall-xdp-common/src/lib.rs index a55194b..1cfb094 100644 --- a/crates/blackwall-xdp-common/src/lib.rs +++ b/crates/blackwall-xdp-common/src/lib.rs @@ -45,6 +45,36 @@ pub struct LpmKeyV6 { } /// Per-source token bucket value for the rate-limit map. +/// +/// # Race-free RMW (X1): per-CPU fallback +/// +/// A `bpf_spin_lock`-guarded single shared bucket (one `LruHashMap` entry per +/// source, locked around the refill + decrement) was attempted first and +/// **rejected by the verifier** on this toolchain: aya-ebpf 0.1.1's `#[map]` +/// macro emits the legacy `bpf_map_def`-based `maps` ELF section rather than +/// a BTF-defined map, so aya never populates +/// `btf_key_type_id`/`btf_value_type_id` at map-creation time and the kernel +/// refuses `bpf_spin_lock` with `map 'RATE' has to have BTF in order to use +/// bpf_spin_lock` (reproduced live via `BPF_PROG_TEST_RUN`, kernel 6.18). +/// Fixing that would mean hand-rolling a BTF-defined-map ELF layout aya-ebpf +/// 0.1.1 doesn't emit for `#[map]` statics -- out of scope here. +/// +/// The shipped fix instead makes `RATE` an `LruPerCpuHashMap` (see the `RATE` +/// map declaration in `blackwall-xdp-ebpf/src/main.rs`): each CPU gets its +/// own independent [`RateBucket`] copy for a given source, so +/// `bpf_map_lookup_elem` from the eBPF program is inherently isolated per CPU +/// (the kernel indexes it by the running CPU) and the refill/decrement RMW in +/// `over_rate` needs no lock -- there is no other CPU that can observe or +/// mutate the same memory. +/// +/// This is race-free but **looser than the single shared-bucket design**: a +/// source's effective admitted rate becomes up to `N_cpus x configured +/// burst` (RSS can spread one spoofed-source flood across every RX +/// queue/CPU, each with its own full token bucket) rather than the exact +/// configured limit -- never *tighter*, only looser. Userspace +/// summing/reconciling per-CPU bucket state into a single reported rate is a +/// follow-on (not implemented here); today each CPU is seeded with the same +/// `tokens = burst` on install. #[repr(C)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RateBucket { diff --git a/crates/blackwall-xdp-ebpf/src/main.rs b/crates/blackwall-xdp-ebpf/src/main.rs index 273f919..45d578d 100644 --- a/crates/blackwall-xdp-ebpf/src/main.rs +++ b/crates/blackwall-xdp-ebpf/src/main.rs @@ -79,7 +79,7 @@ use aya_ebpf::bindings::xdp_action; use aya_ebpf::helpers::{bpf_ktime_get_ns, bpf_xdp_load_bytes}; use aya_ebpf::macros::{map, xdp}; use aya_ebpf::maps::lpm_trie::Key; -use aya_ebpf::maps::{HashMap, LpmTrie, LruHashMap, PerCpuArray, RingBuf, XskMap}; +use aya_ebpf::maps::{HashMap, LpmTrie, LruPerCpuHashMap, PerCpuArray, RingBuf, XskMap}; use aya_ebpf::programs::XdpContext; use blackwall_cookie::make_cookie_raw; use blackwall_xdp_common::{ @@ -95,8 +95,17 @@ use network_types::tcp::TcpHdr; static BLOCK_V4: LpmTrie<[u8; 4], u8> = LpmTrie::with_max_entries(65536, 1); #[map] static BLOCK_V6: LpmTrie<[u8; 16], u8> = LpmTrie::with_max_entries(65536, 1); +/// Per-source token bucket, keyed by 16-byte source (v4 zero-padded). An +/// `LruPerCpuHashMap` (X1 fallback — see [`RateBucket`]'s doc comment for why +/// a single `bpf_spin_lock`-guarded bucket was rejected by the verifier on +/// this toolchain): each CPU holds its own independent bucket for a given +/// source, so `over_rate`'s refill/decrement RMW is inherently race-free +/// (never observed or mutated by another CPU) at the cost of an effective +/// per-source limit of up to `N_cpus × configured burst` under an +/// RSS-spread flood, rather than the exact configured value. #[map] -static RATE: LruHashMap<[u8; 16], RateBucket> = LruHashMap::with_max_entries(1_048_576, 0); +static RATE: LruPerCpuHashMap<[u8; 16], RateBucket> = + LruPerCpuHashMap::with_max_entries(1_048_576, 0); #[map] static STATS: PerCpuArray = PerCpuArray::with_max_entries(REASON_COUNT, 0); /// Single-entry map (key `0`) holding the 128-bit SYN-cookie secret, pre-split @@ -1086,12 +1095,26 @@ fn protected_v6(dst: [u8; 16]) -> bool { /// Token-bucket check keyed by 16-byte source (v4 zero-padded). Returns `true` /// if the packet exceeds the source's budget and should be dropped. Sources /// with no existing bucket are unconfigured and always pass. +/// +/// # Race-free RMW (X1): per-CPU fallback +/// +/// `RATE` is an `LruPerCpuHashMap` (see [`RateBucket`]'s doc comment and the +/// `RATE` declaration above for why a single shared, `bpf_spin_lock`-guarded +/// bucket was rejected by the verifier on this toolchain), so +/// `RATE.get_ptr_mut` always returns a pointer to *this* CPU's own copy of +/// the bucket: the kernel indexes per-CPU map lookups by the running CPU, so +/// no other CPU can observe or mutate the same memory concurrently. The +/// refill + decrement below is therefore already race-free with no lock +/// needed -- at the cost of an effective per-source limit of up to +/// `N_cpus × configured burst` under an RSS-spread flood (never tighter, +/// only looser than the configured value). fn over_rate(src: [u8; 16]) -> bool { // SAFETY: `bpf_ktime_get_ns` is always safe to call from XDP context. let now = unsafe { bpf_ktime_get_ns() }; if let Some(b) = RATE.get_ptr_mut(&src) { // SAFETY: `get_ptr_mut` returned a valid, exclusively-held pointer to - // this source's bucket for the duration of this call. + // this CPU's copy of this source's bucket for the duration of this + // call; no other CPU can alias it (per-CPU map lookup). unsafe { let elapsed_ns = now.saturating_sub((*b).last_ns); // Plain 64-bit `wrapping_mul` lowers to a single BPF multiply; diff --git a/crates/blackwall-xdp/src/dataplane.rs b/crates/blackwall-xdp/src/dataplane.rs index 61fa370..9f4490a 100644 --- a/crates/blackwall-xdp/src/dataplane.rs +++ b/crates/blackwall-xdp/src/dataplane.rs @@ -19,8 +19,9 @@ use crate::manager::{XdpExecError, XdpExecutor}; use crate::XdpAction; use async_trait::async_trait; use aya::maps::lpm_trie::Key; -use aya::maps::{HashMap, LpmTrie, MapData, PerCpuArray, XskMap}; +use aya::maps::{HashMap, LpmTrie, MapData, PerCpuArray, PerCpuHashMap, PerCpuValues, XskMap}; use aya::programs::{Xdp, XdpFlags}; +use aya::util::nr_cpus; use aya::Ebpf; use blackwall_core::XdpMode; use blackwall_xdp_common::{ @@ -55,7 +56,12 @@ struct DataplaneMaps { /// IPv6 source blocklist (`{prefixlen:u32, addr:[u8;16]}` LPM key). block_v6: LpmTrie, /// Per-source token buckets, keyed by the 16-byte source (v4 zero-padded). - rate: HashMap, + /// + /// `LruPerCpuHashMap` — the X1 fallback (see [`blackwall_xdp_common::RateBucket`]'s + /// doc comment): every CPU holds its own independent bucket for a given + /// source, so the effective admitted rate is up to `N_cpus × configured + /// burst` under an RSS-spread flood. + rate: PerCpuHashMap, /// Per-CPU decision counters, indexed by `REASON_*`. stats: PerCpuArray, /// Single-entry (key `0`) map carrying the SYN-cookie secret the in-kernel @@ -477,6 +483,11 @@ impl DataplaneMaps { } /// Install a fresh token bucket for `addr`. + /// + /// `RATE` is per-CPU (X1 fallback), so every CPU's slot for this key must + /// be seeded identically with a full `tokens = burst` bucket — otherwise + /// whichever CPU an RSS-steered packet lands on would see a stale or + /// empty bucket from before this call. fn rate_limit(&mut self, addr: IpAddr, pps: u64, burst: u64) -> Result<(), XdpError> { let bucket = RateBucket { tokens: burst, @@ -484,9 +495,10 @@ impl DataplaneMaps { rate_pps: pps, burst, }; - self.rate - .insert(rate_key(addr), RateBucketPod(bucket), 0) - .map_err(map_err) + let cpus = nr_cpus().map_err(|(ctx, e)| XdpError::Map(format!("{ctx}: {e}")))?; + let values = PerCpuValues::try_from(vec![RateBucketPod(bucket); cpus]) + .map_err(|e| XdpError::Map(e.to_string()))?; + self.rate.insert(rate_key(addr), values, 0).map_err(map_err) } /// Remove any token bucket installed for `addr`. diff --git a/crates/blackwall-xdp/tests/prog_test_run.rs b/crates/blackwall-xdp/tests/prog_test_run.rs index 6e4c55a..159d88a 100644 --- a/crates/blackwall-xdp/tests/prog_test_run.rs +++ b/crates/blackwall-xdp/tests/prog_test_run.rs @@ -1112,3 +1112,155 @@ fn disabled_capture_pushes_nothing() { "capture disabled: the ring must stay empty" ); } + +// --- X1: race-free per-source RATE bucket --- +// +// The feasibility spike attempted a single shared bucket (one `LruHashMap` +// entry per source) guarded by a `bpf_spin_lock` field. The verifier rejected +// it on this toolchain/kernel: `map 'RATE' has to have BTF in order to use +// bpf_spin_lock` — aya-ebpf 0.1.1's `#[map]` macro emits the legacy +// `bpf_map_def`-based `maps` ELF section, not a BTF-defined map, so aya never +// populates `btf_key_type_id`/`btf_value_type_id` at map-creation time. The +// shipped fix is the fallback: `RATE` is now an `LruPerCpuHashMap`, so each +// CPU's copy of a source's bucket is independent and the refill/decrement +// needs no lock (see `blackwall_xdp_common::RateBucket`'s and +// `blackwall-xdp-ebpf`'s `RATE`/`over_rate` doc comments for the full +// rationale and the `N_cpus × configured burst` looser-bound trade-off). + +/// `#[repr(transparent)]` newtype so the foreign +/// [`blackwall_xdp_common::RateBucket`] POD can carry an [`aya::Pod`] impl +/// (the orphan rule forbids implementing it directly) — mirrors the +/// production `RateBucketPod` in `blackwall_xdp::dataplane`. +#[repr(transparent)] +#[derive(Clone, Copy)] +struct RateBucketPod(blackwall_xdp_common::RateBucket); + +// SAFETY: `RateBucket` is a `#[repr(C)]` `Copy + 'static` plain-old-data +// struct of four `u64` fields; `#[repr(transparent)]` makes `RateBucketPod` +// share its exact layout, so it is byte-for-byte valid as a BPF map value. +unsafe impl aya::Pod for RateBucketPod {} + +/// Install a fresh token bucket for `addr` (v4, zero-padded into the low four +/// bytes of the 16-byte key exactly like the eBPF program's own key) on +/// *every* CPU's slot, with `rate_pps = 0` so the burst is never refilled +/// mid-test — the (N+1)th packet on the pinned CPU is guaranteed to see zero +/// tokens. Mirrors the production `XdpDataplane::rate_limit` per-CPU seeding +/// (`RATE` is an `LruPerCpuHashMap`, X1 fallback). +fn install_rate_bucket(bpf: &mut Ebpf, addr: [u8; 4], burst: u64) { + use aya::maps::{PerCpuHashMap, PerCpuValues}; + use aya::util::nr_cpus; + + let mut map: PerCpuHashMap<_, [u8; 16], RateBucketPod> = + PerCpuHashMap::try_from(bpf.map_mut("RATE").expect("RATE map present")) + .expect("RATE is a PerCpuHashMap"); + let mut key = [0u8; 16]; + key[..4].copy_from_slice(&addr); + let bucket = blackwall_xdp_common::RateBucket { + tokens: burst, + last_ns: 0, + rate_pps: 0, + burst, + }; + let cpus = nr_cpus().expect("nr_cpus"); + let values = + PerCpuValues::try_from(vec![RateBucketPod(bucket); cpus]).expect("build per-CPU values"); + map.insert(key, values, 0).expect("insert rate bucket"); +} + +/// Pin the calling thread to CPU 0 for the duration of `f`, restoring the +/// original affinity mask afterward. +/// +/// `RATE` is per-CPU (X1 fallback), so `BPF_PROG_TEST_RUN` must execute every +/// packet in a burst sequence on the *same* CPU: otherwise the scheduler +/// could migrate the calling thread between calls and each packet would hit +/// a different, independently-full per-CPU bucket rather than draining one. +fn pin_to_cpu0(f: impl FnOnce() -> R) -> R { + // SAFETY: `old`/`only0` are zero-initialised, correctly-sized `cpu_set_t` + // buffers; `sched_getaffinity`/`sched_setaffinity` with pid `0` operate on + // the calling thread and write/read exactly `size_of::()` + // bytes into/from them. + unsafe { + let mut old: libc::cpu_set_t = std::mem::zeroed(); + let rc = libc::sched_getaffinity(0, std::mem::size_of::(), &raw mut old); + assert_eq!( + rc, + 0, + "sched_getaffinity failed: {}", + std::io::Error::last_os_error() + ); + + let mut only0: libc::cpu_set_t = std::mem::zeroed(); + libc::CPU_SET(0, &mut only0); + let rc = + libc::sched_setaffinity(0, std::mem::size_of::(), &raw const only0); + assert_eq!( + rc, + 0, + "sched_setaffinity(cpu0) failed: {}", + std::io::Error::last_os_error() + ); + + let result = f(); + + let rc = libc::sched_setaffinity(0, std::mem::size_of::(), &raw const old); + assert_eq!( + rc, + 0, + "sched_setaffinity(restore) failed: {}", + std::io::Error::last_os_error() + ); + + result + } +} + +/// X1: the verifier must accept `RATE`'s `LruPerCpuHashMap` value +/// (`prog.load()` below is the load/verify check) and, pinned to a single +/// CPU so every `BPF_PROG_TEST_RUN` call hits the same per-CPU bucket slot, +/// sequential calls must enforce the token bucket exactly as before X1: the +/// first `burst` packets from one source are admitted, the next is dropped +/// with `REASON_RATELIMIT`. +#[test] +#[ignore = "requires root + recent kernel; run in the lab CI job"] +fn rate_limited_source_is_admitted_up_to_burst_then_dropped() { + const SRC: [u8; 4] = [203, 0, 113, 42]; + const BURST: u64 = 3; + + pin_to_cpu0(|| { + let mut bpf = Ebpf::load(blackwall_xdp::PROGRAM_OBJECT).expect("load eBPF object"); + install_rate_bucket(&mut bpf, SRC, BURST); + + let prog: &mut Xdp = bpf + .program_mut("xdp_filter") + .expect("xdp_filter program present") + .try_into() + .expect("program is an Xdp"); + // The load/verify step is itself the X1 spike assertion: the verifier + // must accept the `LruPerCpuHashMap` RATE map value on this kernel. + prog.load() + .expect("verify + load xdp_filter (per-CPU RATE map value)"); + let prog_fd = prog.fd().expect("program fd").as_fd().as_raw_fd(); + + let frame = eth_ipv4(SRC); + for i in 0..BURST { + let action = run_xdp(prog_fd, &frame); + assert_eq!(action, XDP_PASS, "packet {i} within burst must be admitted"); + } + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_RATELIMIT), + 0, + "no packet within the burst should be rate-limited" + ); + + let over_burst = run_xdp(prog_fd, &frame); + assert_eq!( + over_burst, XDP_DROP, + "the (burst + 1)th packet must be rate-limited" + ); + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_RATELIMIT), + 1, + "exactly one packet must be counted as rate-limited" + ); + }); +} From f39e428debe7bf04ce81346f9ed5cfc9c68fd3c2 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 15 Jul 2026 13:29:43 -0400 Subject: [PATCH 2/6] feat(xdp): global SYN-cookie XDP_TX mint-rate cap (X3 eBPF) --- crates/blackwall-xdp-common/src/lib.rs | 79 ++++++++++- crates/blackwall-xdp-ebpf/src/main.rs | 99 ++++++++++++- crates/blackwall-xdp/tests/prog_test_run.rs | 146 ++++++++++++++++++++ 3 files changed, 320 insertions(+), 4 deletions(-) diff --git a/crates/blackwall-xdp-common/src/lib.rs b/crates/blackwall-xdp-common/src/lib.rs index 1cfb094..dab2991 100644 --- a/crates/blackwall-xdp-common/src/lib.rs +++ b/crates/blackwall-xdp-common/src/lib.rs @@ -21,8 +21,16 @@ pub const REASON_SYNCOOKIE: u32 = 3; /// (sub-project B3.1). Counts frames matching the redirect condition that were /// handed to the zero-copy/copy-mode `AF_XDP` receiver ahead of the kernel stack. pub const REASON_REDIRECT: u32 = 4; +/// A TCP SYN that cleared every gate (protected prefix + port, per-source +/// `RATE` budget, valid `COOKIE_KEY`) but was denied a SipHash-cookie SYN-ACK +/// because the global per-CPU [`TxBucket`] mint budget (sub-project X3) was +/// exhausted. The SYN falls through to its normal non-cookie verdict instead +/// of being answered via `XDP_TX`. Distinguishing this from [`REASON_PASS`] +/// lets userspace tell "the box is at its configured SYN-ACK ceiling" apart +/// from "nothing matched the fast path". +pub const REASON_SYNCOOKIE_TXCAPPED: u32 = 5; /// Number of reason codes (stats array length). -pub const REASON_COUNT: u32 = 5; +pub const REASON_COUNT: u32 = 6; /// LPM-trie key for the IPv4 source blocklist (`bpf_lpm_trie_key` layout). #[repr(C)] @@ -88,6 +96,53 @@ pub struct RateBucket { pub burst: u64, } +/// Value of the single-entry, per-CPU `TX_BUDGET` map: the global SYN-cookie +/// `XDP_TX` mint-rate token bucket (sub-project X3). +/// +/// # Why a *global* cap on top of the per-source `RATE` limiter +/// +/// [`RateBucket`] throttles per **source** address, but a spoofed SYN flood +/// rotates through addresses the attacker does not own -- each spoofed source +/// gets its own fresh, never-reused bucket, so the per-source limiter never +/// engages and the in-kernel cookie fast path mints (and `XDP_TX`-bounces) a +/// SYN-ACK for every single spoofed SYN. `TxBucket` bounds the **aggregate** +/// mint rate regardless of how many distinct (spoofed) sources are involved, +/// turning an unbounded gain-1 reflector into one with a hard ceiling. +/// +/// # Per-CPU fallback (mirrors [`RateBucket`]) +/// +/// Same X1 rationale applies here: this toolchain's `#[map]` macro cannot +/// emit a BTF-defined map, so a `bpf_spin_lock`-guarded single shared bucket +/// is rejected by the verifier. `TX_BUDGET` is instead a `PerCpuArray` with +/// one slot -- each CPU holds its own independent copy, so the refill/decrement +/// RMW in `tx_budget_ok` needs no lock. The **aggregate** ceiling across the +/// box is therefore up to `N_cpus x rate_pps` admitted SYN-ACKs per second, +/// not the single configured `rate_pps` -- looser than the nominal rate under +/// RSS spread, never tighter (same tradeoff [`RateBucket`] documents). +/// +/// # `rate_pps == 0` means "not configured" +/// +/// Unlike [`RateBucket`] (which is per-source and simply has no entry when +/// unconfigured, so the `HashMap` lookup misses and the caller never +/// throttles), `TX_BUDGET` is a `PerCpuArray` -- slot `0` always exists, even +/// before userspace ever writes to it, and reads back as all-zero. The eBPF +/// side's `tx_budget_ok` treats `rate_pps == 0` (the zero-initialised default, +/// or a value userspace explicitly leaves at zero) as "cap not configured" and +/// never throttles -- this is what keeps the fast path's pre-X3 behavior (and +/// tests) unchanged until Task 3's userspace setter installs a nonzero rate. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TxBucket { + /// Tokens currently available. + pub tokens: u64, + /// `bpf_ktime_get_ns()` of the last refill. + pub last_ns: u64, + /// Refill rate in packets (SYN-ACKs) per second. `0` means the cap is not + /// configured (see the struct-level doc comment) -- `tx_budget_ok` never + /// throttles in that case. + pub rate_pps: u64, +} + /// A single per-CPU counter entry. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -213,6 +268,28 @@ mod tests { assert_eq!(core::mem::size_of::(), 16); } + #[test] + fn tx_bucket_is_pod_no_padding() { + // Three `u64`s, no padding: the byte layout shared with the eBPF + // `TX_BUDGET` reader/writer. + assert_eq!(core::mem::size_of::(), 24); + assert_eq!(core::mem::align_of::(), 8); + let b = TxBucket { + tokens: 1, + last_ns: 2, + rate_pps: 3, + }; + assert_eq!(b.tokens, 1); + assert_eq!(b.last_ns, 2); + assert_eq!(b.rate_pps, 3); + } + + #[test] + fn reason_syncookie_txcapped_is_last_and_bumps_count() { + assert_eq!(REASON_SYNCOOKIE_TXCAPPED, 5); + assert_eq!(REASON_COUNT, 6); + } + #[test] fn capture_record_layout_is_24_bytes_no_padding() { // The header contract: u64 timestamp + four u32s, 8-byte aligned, no diff --git a/crates/blackwall-xdp-ebpf/src/main.rs b/crates/blackwall-xdp-ebpf/src/main.rs index 45d578d..5f0e9e0 100644 --- a/crates/blackwall-xdp-ebpf/src/main.rs +++ b/crates/blackwall-xdp-ebpf/src/main.rs @@ -83,8 +83,9 @@ use aya_ebpf::maps::{HashMap, LpmTrie, LruPerCpuHashMap, PerCpuArray, RingBuf, X use aya_ebpf::programs::XdpContext; use blackwall_cookie::make_cookie_raw; use blackwall_xdp_common::{ - CaptureFrame, CaptureRecord, CookieKeyValue, RateBucket, Stat, CAP_SNAP_LEN, REASON_BLOCKLIST, - REASON_COUNT, REASON_PASS, REASON_RATELIMIT, REASON_REDIRECT, REASON_SYNCOOKIE, + CaptureFrame, CaptureRecord, CookieKeyValue, RateBucket, Stat, TxBucket, CAP_SNAP_LEN, + REASON_BLOCKLIST, REASON_COUNT, REASON_PASS, REASON_RATELIMIT, REASON_REDIRECT, + REASON_SYNCOOKIE, REASON_SYNCOOKIE_TXCAPPED, }; use core::mem; use network_types::eth::{EthHdr, EtherType}; @@ -108,6 +109,14 @@ static RATE: LruPerCpuHashMap<[u8; 16], RateBucket> = LruPerCpuHashMap::with_max_entries(1_048_576, 0); #[map] static STATS: PerCpuArray = PerCpuArray::with_max_entries(REASON_COUNT, 0); +/// Single-slot (index `0`) global per-CPU SYN-cookie `XDP_TX` mint-rate token +/// bucket (sub-project X3 — see [`TxBucket`]'s doc comment for the full +/// rationale and the per-CPU aggregate-ceiling tradeoff). Zero-initialised +/// (`rate_pps == 0`) until userspace writes a nonzero rate, which +/// [`tx_budget_ok`] treats as "cap not configured" so the fast path's pre-X3 +/// behavior is unchanged by default. +#[map] +static TX_BUDGET: PerCpuArray = PerCpuArray::with_max_entries(1, 0); /// Single-entry map (key `0`) holding the 128-bit SYN-cookie secret, pre-split /// into the SipHash `(k0, k1)` pair (see [`CookieKeyValue`]). Populated from /// userspace before the program answers any SYN; an absent entry makes the SYN @@ -279,6 +288,15 @@ const MAX_TCP_SEG: usize = 64; /// seconds-since-boot the cookie core slots (`>> COUNTER_SHIFT`) internally. const NS_PER_SEC: u64 = 1_000_000_000; +/// Fixed burst cap for the global per-CPU [`TX_BUDGET`] token bucket +/// (sub-project X3). Unlike [`RateBucket`], [`TxBucket`] carries no per-instance +/// `burst` field (see its doc comment), so the cap is this compile-time +/// constant: a round number comfortably above any sane sustained per-CPU +/// SYN-ACK burst, so a correctly configured `rate_pps` is never truncated by +/// this ceiling -- it only guards against `tokens` growing without bound +/// between refills (e.g. after a long idle period). +const TX_BUDGET_BURST: u64 = 1_000_000; + #[inline(always)] fn ptr_at(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> { let start = ctx.data(); @@ -710,7 +728,19 @@ fn try_synack_v4(ctx: &XdpContext) -> Result { // Cookie time base: real monotonic seconds-since-boot (`CLOCK_MONOTONIC`); // `make_cookie_raw` slots it with `>> COUNTER_SHIFT` internally. // SAFETY: `bpf_ktime_get_ns` is always safe to call from XDP context. - let now_secs = unsafe { bpf_ktime_get_ns() } / NS_PER_SEC; + let now_ns = unsafe { bpf_ktime_get_ns() }; + let now_secs = now_ns / NS_PER_SEC; + + // X3: the global per-CPU mint budget, checked immediately before minting. + // A SYN that cleared every other gate (protected prefix+port, per-source + // `over_rate`, a valid cookie key) but exceeds the box's aggregate budget + // falls through to its normal non-cookie verdict instead of being answered. + if !tx_budget_ok(now_ns) { + let frame_len = (ctx.data_end() - ctx.data()) as u64; + count(REASON_SYNCOOKIE_TXCAPPED, frame_len); + return Err(()); + } + // Compute the stateless SYN-cookie with the shared no_std core. let (cookie_seq, mss) = make_cookie_raw(k0, k1, &src, src_port, &dst, dst_port, client_mss, now_secs); @@ -872,6 +902,19 @@ fn try_synack_v6(ctx: &XdpContext) -> Result { let client_seq = load_be32(ctx, OFF_TCP6_SEQ)?; let ack = client_seq.wrapping_add(1); + // X3: the global per-CPU mint budget, checked immediately before minting + // (mirrors `try_synack_v4`) — a SYN that cleared every other gate + // (protected prefix+port, per-source `over_rate`) but exceeds the box's + // aggregate budget falls through instead of paying for the SipHash cookie + // that `compute_cookie_v6` would otherwise compute. + // SAFETY: `bpf_ktime_get_ns` is always safe to call from XDP context. + let now_ns = unsafe { bpf_ktime_get_ns() }; + if !tx_budget_ok(now_ns) { + let frame_len = (ctx.data_end() - ctx.data()) as u64; + count(REASON_SYNCOOKIE_TXCAPPED, frame_len); + return Err(()); + } + // Compute the stateless SYN-cookie **before** any mutation (bails to `Err` — // hence `XDP_PASS`, frame untouched — if the cookie key is absent), over the // 16-byte v6 addresses read from the packet. @@ -1133,6 +1176,56 @@ fn over_rate(src: [u8; 16]) -> bool { false } +/// Check and consume one token from the global per-CPU SYN-cookie `XDP_TX` +/// mint budget (sub-project X3). Returns `true` if a SYN-ACK may be minted, +/// `false` if the caller must bail without minting. +/// +/// Callers invoke this **after** SYN validation, the [`protected_v4`]/ +/// [`protected_v6`]/[`protected_port`] gating, and the per-source [`over_rate`] +/// check (checked earlier, in [`try_filter`], before [`try_synack_v4`]/ +/// [`try_synack_v6`] are even called) -- so a non-SYN, unprotected-destination, +/// or already per-source-limited packet never consumes global budget. The +/// check sits immediately before the cookie is actually minted. +/// +/// `rate_pps == 0` (the [`TX_BUDGET`] slot's zero-initialised default) means +/// the cap has never been configured by userspace and this always returns +/// `true` -- see [`TxBucket`]'s doc comment. Once `rate_pps` is nonzero, the +/// refill/decrement mirrors [`over_rate`] exactly: 64-bit-only math +/// (`wrapping_mul` then `.min(TX_BUDGET_BURST)`; `saturating_mul`/ +/// `overflowing_mul` would emit an unsupported 128-bit `__multi3` on this +/// target), so wraparound at absurd elapsed values cannot over-credit tokens. +/// +/// Per-CPU (see [`TX_BUDGET`]'s doc comment): `get_ptr_mut` returns a pointer +/// to *this* CPU's own slot, so the RMW below is inherently race-free with no +/// lock needed -- at the cost of an aggregate ceiling of `N_cpus x rate_pps` +/// across the box, mirroring `over_rate`'s X1 per-CPU tradeoff. +#[inline(always)] +fn tx_budget_ok(now: u64) -> bool { + let Some(b) = TX_BUDGET.get_ptr_mut(0) else { + // No slot at index 0 (unreachable in practice: `with_max_entries(1, 0)` + // always has one): fail open, same as the "not configured" case. + return true; + }; + // SAFETY: `get_ptr_mut` returned a valid pointer to this CPU's own single + // TX_BUDGET slot; it is exclusively ours for the duration of this call (no + // other CPU can observe or mutate a per-CPU map's slot for this CPU). + unsafe { + if (*b).rate_pps == 0 { + // Cap not configured: never throttle (pre-X3 behavior). + return true; + } + let elapsed_ns = now.saturating_sub((*b).last_ns); + let refill = elapsed_ns.wrapping_mul((*b).rate_pps) / NS_PER_SEC; + (*b).tokens = ((*b).tokens.saturating_add(refill)).min(TX_BUDGET_BURST); + (*b).last_ns = now; + if (*b).tokens == 0 { + return false; + } + (*b).tokens -= 1; + } + true +} + #[cfg(not(test))] #[panic_handler] fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { diff --git a/crates/blackwall-xdp/tests/prog_test_run.rs b/crates/blackwall-xdp/tests/prog_test_run.rs index 159d88a..2b19ae4 100644 --- a/crates/blackwall-xdp/tests/prog_test_run.rs +++ b/crates/blackwall-xdp/tests/prog_test_run.rs @@ -1264,3 +1264,149 @@ fn rate_limited_source_is_admitted_up_to_burst_then_dropped() { ); }); } + +// --- X3: global per-CPU SYN-cookie XDP_TX mint-rate cap --- +// +// The per-source `RATE` limiter (X1) never engages against a spoofed SYN +// flood: each spoofed source address gets its own fresh, never-reused bucket, +// so the in-kernel cookie fast path would mint (and `XDP_TX`-bounce) a +// SYN-ACK for every single spoofed SYN with no ceiling. `TX_BUDGET` bounds the +// *aggregate* mint rate regardless of how many distinct source addresses are +// involved. + +/// `#[repr(transparent)]` newtype so the foreign +/// [`blackwall_xdp_common::TxBucket`] POD can carry an [`aya::Pod`] impl (the +/// orphan rule forbids implementing it directly) — mirrors `RateBucketPod`. +#[repr(transparent)] +#[derive(Clone, Copy)] +struct TxBucketPod(blackwall_xdp_common::TxBucket); + +// SAFETY: `TxBucket` is a `#[repr(C)]` `Copy + 'static` plain-old-data struct +// of three `u64` fields; `#[repr(transparent)]` makes `TxBucketPod` share its +// exact layout, so it is byte-for-byte valid as a per-CPU BPF map value. +unsafe impl aya::Pod for TxBucketPod {} + +/// Install a global `TX_BUDGET` cap of `cap` tokens on *every* CPU's slot, +/// with `rate_pps = 1` (nonzero, so `tx_budget_ok` treats the cap as +/// configured rather than "not configured => never throttle") and +/// `last_ns = u64::MAX` so `now.saturating_sub(last_ns)` is always `0` — no +/// refill can occur during the test no matter how much wall-clock time +/// elapses between `BPF_PROG_TEST_RUN` calls. Mirrors `install_rate_bucket`'s +/// no-refill trick, adapted because X3's cap-enable signal is `rate_pps != 0` +/// itself (unlike `RATE`, where `rate_pps = 0` is used to freeze refill while +/// the bucket is still "configured" by virtue of the entry existing at all). +fn install_tx_budget(bpf: &mut Ebpf, cap: u64) { + use aya::maps::{PerCpuArray, PerCpuValues}; + use aya::util::nr_cpus; + + let mut map: PerCpuArray<_, TxBucketPod> = + PerCpuArray::try_from(bpf.map_mut("TX_BUDGET").expect("TX_BUDGET map present")) + .expect("TX_BUDGET is a PerCpuArray"); + let bucket = blackwall_xdp_common::TxBucket { + tokens: cap, + last_ns: u64::MAX, + rate_pps: 1, + }; + let cpus = nr_cpus().expect("nr_cpus"); + let values = + PerCpuValues::try_from(vec![TxBucketPod(bucket); cpus]).expect("build per-CPU values"); + map.set(0, values, 0).expect("insert tx budget"); +} + +/// X3: with the cookie path fully enabled (protected prefix + port, a valid +/// cookie key) and `TX_BUDGET` seeded to a cap of `CAP` tokens with no refill +/// for the test's duration (see `install_tx_budget`), exactly the first `CAP` +/// of `TOTAL > CAP` SYNs to the protected destination must be answered via +/// `XDP_TX`; every SYN after that must fall through to its normal non-cookie +/// verdict (`XDP_PASS`, since nothing else in this test gates it) instead of +/// minting, and `REASON_SYNCOOKIE_TXCAPPED` must count exactly the denied +/// SYNs. Pinned to one CPU (X1's pattern) so every `BPF_PROG_TEST_RUN` call +/// hits the same per-CPU `TX_BUDGET` slot — otherwise the scheduler could +/// migrate the calling thread between calls and each SYN would hit a +/// different, independently-full per-CPU budget rather than draining one. +#[test] +#[ignore = "requires root + recent kernel; run in the lab CI job"] +fn global_tx_budget_caps_mints_then_stats_the_rest() { + const CAP: u64 = 3; + const TOTAL: u64 = 5; + + pin_to_cpu0(|| { + let mut bpf = Ebpf::load(blackwall_xdp::PROGRAM_OBJECT).expect("load eBPF object"); + install_tx_budget(&mut bpf, CAP); + let prog_fd = load_with_cookie_and_gate(&mut bpf); + + let mut minted = 0u64; + let mut capped = 0u64; + for i in 0..TOTAL { + let port_offset = u16::try_from(i).expect("small test index fits u16"); + let seq_offset = u32::try_from(i).expect("small test index fits u32"); + let syn = eth_ipv4_tcp_syn( + [203, 0, 113, 7], + [10, 0, 0, 1], + 50_000 + port_offset, + PROTECT_TCP_PORT, + 0x1000_0000 + seq_offset, + 1460, + ); + let action = run_xdp(prog_fd, &syn); + if i < CAP { + assert_eq!( + action, XDP_TX, + "SYN {i} within the global budget must mint via XDP_TX" + ); + minted += 1; + } else { + assert_eq!( + action, XDP_PASS, + "SYN {i} beyond the global budget must fall through to XDP_PASS" + ); + capped += 1; + } + } + assert_eq!(minted, CAP, "exactly CAP SYNs must have minted"); + assert_eq!( + capped, + TOTAL - CAP, + "the remaining SYNs must all be budget-capped" + ); + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_SYNCOOKIE_TXCAPPED), + TOTAL - CAP, + "REASON_SYNCOOKIE_TXCAPPED must count exactly the denied SYNs" + ); + }); +} + +/// X3 default-off regression guard: with `TX_BUDGET` left entirely unseeded +/// (the map's zero-initialised default — `rate_pps == 0`), the cap must be +/// treated as "not configured" and never throttle, so a single SYN mints +/// exactly as it did before X3. This is the same scenario the pre-X3 +/// `syn_to_protected_prefix_and_port_is_answered_via_xdp_tx` test exercises; +/// this test makes the "unseeded TX_BUDGET must not regress the existing +/// syncookie behavior" requirement explicit and independently checked. +#[test] +#[ignore = "requires root + recent kernel; run in the lab CI job"] +fn unseeded_tx_budget_never_throttles() { + let mut bpf = Ebpf::load(blackwall_xdp::PROGRAM_OBJECT).expect("load eBPF object"); + // Deliberately do NOT install TX_BUDGET. + let prog_fd = load_with_cookie_and_gate(&mut bpf); + + let syn = eth_ipv4_tcp_syn( + [203, 0, 113, 7], + [10, 0, 0, 1], + 54_321, + PROTECT_TCP_PORT, + 0x1122_3344, + 1460, + ); + let action = run_xdp(prog_fd, &syn); + assert_eq!( + action, XDP_TX, + "an unseeded TX_BUDGET must never throttle -- a SYN must still mint" + ); + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_SYNCOOKIE_TXCAPPED), + 0, + "an unseeded TX_BUDGET must never count a SYN as capped" + ); +} From 930ff61bbed81e928bbd2eb2511fd44fb905372e Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 15 Jul 2026 13:44:22 -0400 Subject: [PATCH 3/6] fix(xdp): scale SYN-cookie TX-cap burst to rate_pps; align v6 stat ordering (X3 follow-up) The global TX_BUDGET token bucket's refill was clamped to a fixed TX_BUDGET_BURST=1_000_000 regardless of rate_pps, so any idle window longer than burst/rate_pps seconds -- including the very first call after arming (last_ns seeded 0) -- refilled to the full 1M tokens no matter how small rate_pps was, largely defeating the cap. tx_budget_ok now derives the burst as min(2 x rate_pps, TX_BUDGET_BURST_MAX), so a conservative rate_pps gets a correspondingly tight burst. Separately, try_synack_v6 checked tx_budget_ok before validating the cookie key (try_synack_v4 checks the key first), so a v6 SYN with no key installed and an exhausted budget was mis-counted as REASON_SYNCOOKIE_TXCAPPED instead of falling through uncounted, like v4. Reordered to read/validate the key first, matching v4. --- crates/blackwall-xdp-common/src/lib.rs | 10 ++ crates/blackwall-xdp-ebpf/src/main.rs | 82 ++++++---- crates/blackwall-xdp/tests/prog_test_run.rs | 168 ++++++++++++++++++-- 3 files changed, 220 insertions(+), 40 deletions(-) diff --git a/crates/blackwall-xdp-common/src/lib.rs b/crates/blackwall-xdp-common/src/lib.rs index dab2991..04d9228 100644 --- a/crates/blackwall-xdp-common/src/lib.rs +++ b/crates/blackwall-xdp-common/src/lib.rs @@ -130,6 +130,16 @@ pub struct RateBucket { /// or a value userspace explicitly leaves at zero) as "cap not configured" and /// never throttles -- this is what keeps the fast path's pre-X3 behavior (and /// tests) unchanged until Task 3's userspace setter installs a nonzero rate. +/// +/// # Burst ceiling scales with `rate_pps` +/// +/// Unlike [`RateBucket`], `TxBucket` carries no per-instance `burst` field: +/// `tx_budget_ok` derives the ceiling as `min(2 x rate_pps, +/// TX_BUDGET_BURST_MAX)` (roughly 2 seconds of budget), not a fixed +/// constant -- otherwise any idle window (including the first call after +/// arming, since `last_ns` is seeded `0`) would refill `tokens` all the way to +/// a fixed ceiling regardless of how small `rate_pps` is, defeating the "hard +/// reflection ceiling" this cap exists for. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct TxBucket { diff --git a/crates/blackwall-xdp-ebpf/src/main.rs b/crates/blackwall-xdp-ebpf/src/main.rs index 5f0e9e0..23daf8b 100644 --- a/crates/blackwall-xdp-ebpf/src/main.rs +++ b/crates/blackwall-xdp-ebpf/src/main.rs @@ -288,14 +288,16 @@ const MAX_TCP_SEG: usize = 64; /// seconds-since-boot the cookie core slots (`>> COUNTER_SHIFT`) internally. const NS_PER_SEC: u64 = 1_000_000_000; -/// Fixed burst cap for the global per-CPU [`TX_BUDGET`] token bucket -/// (sub-project X3). Unlike [`RateBucket`], [`TxBucket`] carries no per-instance -/// `burst` field (see its doc comment), so the cap is this compile-time -/// constant: a round number comfortably above any sane sustained per-CPU -/// SYN-ACK burst, so a correctly configured `rate_pps` is never truncated by -/// this ceiling -- it only guards against `tokens` growing without bound -/// between refills (e.g. after a long idle period). -const TX_BUDGET_BURST: u64 = 1_000_000; +/// Absolute overflow guard on the burst ceiling `tx_budget_ok` derives from +/// [`TxBucket::rate_pps`] (sub-project X3 follow-up). Unlike [`RateBucket`], +/// [`TxBucket`] carries no per-instance `burst` field (see its doc comment), +/// so `tx_budget_ok` computes the actual burst as `min(2 x rate_pps, +/// TX_BUDGET_BURST_MAX)` -- a small multiple of the configured rate, so a +/// tight `rate_pps` yields a correspondingly tight burst instead of always +/// refilling to this constant regardless of how small `rate_pps` is. This +/// constant only bounds the *huge-rate_pps* case (and any idle-period +/// wraparound) from growing `tokens` without limit. +const TX_BUDGET_BURST_MAX: u64 = 1_000_000; #[inline(always)] fn ptr_at(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> { @@ -592,9 +594,12 @@ fn try_filter(ctx: &XdpContext) -> Result { /// Mint the stateless SipHash SYN-cookie for the packet's IPv6 connection tuple, /// reading the (pre-swap) tuple straight from the packet at the v6 header -/// offsets. Returns `(cookie_seq, mss_used)`, or `Err(())` when the cookie key -/// is absent (never installed) — the caller **must** invoke this before any -/// packet mutation so that a bail leaves the frame untouched. +/// offsets, given the already-read cookie secret `(k0, k1)` — the caller +/// **must** invoke this before any packet mutation so that a bail leaves the +/// frame untouched. The key is now a parameter rather than being re-read here +/// (sub-project X3 follow-up): [`try_synack_v6`] reads it first, before +/// [`tx_budget_ok`], so a SYN with no cookie key installed never consumes +/// global mint budget (mirrors [`try_synack_v4`]'s order). /// /// `#[inline(never)]`: this is deliberately its own bpf-to-bpf subprogram, so /// the SipHash scratch buffer (bigger for v6's 16-byte addresses) and the call @@ -606,10 +611,10 @@ fn try_filter(ctx: &XdpContext) -> Result { /// it. The v4 path keeps its cookie inline because its 4-byte-address scratch is /// small enough to fit self-contained. #[inline(never)] -fn compute_cookie_v6(ctx: &XdpContext) -> Result<(u32, u16), ()> { - // Read the secret from the userspace-populated map. Absent => bail so the - // caller falls through to `XDP_PASS`; never mint under a zero/garbage key. - let (k0, k1) = cookie_keys()?; +fn compute_cookie_v6(ctx: &XdpContext, k0: u64, k1: u64) -> Result<(u32, u16), ()> { + // `(k0, k1)` was already read (and validated present) by the caller, + // before the global `tx_budget_ok` check -- see this function's doc + // comment. // Cookie time base: real monotonic seconds-since-boot (`CLOCK_MONOTONIC`); // `make_cookie_raw` slots it with `>> COUNTER_SHIFT` internally. The // userspace responder validates the returning ACK against the same clock. @@ -902,11 +907,20 @@ fn try_synack_v6(ctx: &XdpContext) -> Result { let client_seq = load_be32(ctx, OFF_TCP6_SEQ)?; let ack = client_seq.wrapping_add(1); - // X3: the global per-CPU mint budget, checked immediately before minting - // (mirrors `try_synack_v4`) — a SYN that cleared every other gate - // (protected prefix+port, per-source `over_rate`) but exceeds the box's - // aggregate budget falls through instead of paying for the SipHash cookie - // that `compute_cookie_v6` would otherwise compute. + // Read the secret from the userspace-populated map **before** the X3 + // budget check below (sub-project X3 follow-up, matching `try_synack_v4`'s + // order): absent => bail to `XDP_PASS` without ever touching `TX_BUDGET`, + // so a SYN with no cookie key installed is attributed to the correct + // fall-through reason instead of misleadingly consuming (and being + // counted against) the global mint budget. + let (k0, k1) = cookie_keys()?; + + // X3: the global per-CPU mint budget, checked immediately before the + // (comparatively expensive) SipHash cookie computation -- a SYN that + // cleared every other gate (protected prefix+port, per-source + // `over_rate`, a valid cookie key) but exceeds the box's aggregate budget + // falls through instead of paying for the cookie that `compute_cookie_v6` + // would otherwise compute. // SAFETY: `bpf_ktime_get_ns` is always safe to call from XDP context. let now_ns = unsafe { bpf_ktime_get_ns() }; if !tx_budget_ok(now_ns) { @@ -915,10 +929,9 @@ fn try_synack_v6(ctx: &XdpContext) -> Result { return Err(()); } - // Compute the stateless SYN-cookie **before** any mutation (bails to `Err` — - // hence `XDP_PASS`, frame untouched — if the cookie key is absent), over the + // Compute the stateless SYN-cookie **before** any mutation, over the // 16-byte v6 addresses read from the packet. - let (cookie_seq, mss) = compute_cookie_v6(ctx)?; + let (cookie_seq, mss) = compute_cookie_v6(ctx, k0, k1)?; // --- in-place, same-length SYN -> SYN-ACK surgery --- // Reflect the frame: swap MACs, IPv6 addresses, TCP ports. Data offset kept @@ -1191,9 +1204,18 @@ fn over_rate(src: [u8; 16]) -> bool { /// the cap has never been configured by userspace and this always returns /// `true` -- see [`TxBucket`]'s doc comment. Once `rate_pps` is nonzero, the /// refill/decrement mirrors [`over_rate`] exactly: 64-bit-only math -/// (`wrapping_mul` then `.min(TX_BUDGET_BURST)`; `saturating_mul`/ -/// `overflowing_mul` would emit an unsupported 128-bit `__multi3` on this -/// target), so wraparound at absurd elapsed values cannot over-credit tokens. +/// (`wrapping_mul` then `.min(burst)`; `saturating_mul`/`overflowing_mul` +/// would emit an unsupported 128-bit `__multi3` on this target), so +/// wraparound at absurd elapsed values cannot over-credit tokens. +/// +/// The burst ceiling itself is `min(2 x rate_pps, TX_BUDGET_BURST_MAX)` -- +/// scaled to the configured rate rather than a fixed constant, so a +/// conservative `rate_pps` (e.g. `1_000`) gets a correspondingly tight burst +/// (`2_000`), not [`TX_BUDGET_BURST_MAX`] regardless of `rate_pps`. Without +/// this, any idle window longer than `burst / rate_pps` seconds -- including +/// the very first call after arming, since `last_ns` is seeded `0` -- would +/// refill to the full fixed ceiling no matter how small `rate_pps` is, +/// defeating the cap's purpose as a hard reflection ceiling. /// /// Per-CPU (see [`TX_BUDGET`]'s doc comment): `get_ptr_mut` returns a pointer /// to *this* CPU's own slot, so the RMW below is inherently race-free with no @@ -1215,8 +1237,14 @@ fn tx_budget_ok(now: u64) -> bool { return true; } let elapsed_ns = now.saturating_sub((*b).last_ns); + // Plain 64-bit `wrapping_mul` (see the module-level rationale in + // `over_rate`): `saturating_mul`/`overflowing_mul` would emit an + // unsupported 128-bit `__multi3` on this target. The burst is scaled + // to the configured rate (~2 seconds of budget), not a fixed + // constant -- see this function's doc comment. + let burst = (*b).rate_pps.wrapping_mul(2).min(TX_BUDGET_BURST_MAX); let refill = elapsed_ns.wrapping_mul((*b).rate_pps) / NS_PER_SEC; - (*b).tokens = ((*b).tokens.saturating_add(refill)).min(TX_BUDGET_BURST); + (*b).tokens = ((*b).tokens.saturating_add(refill)).min(burst); (*b).last_ns = now; if (*b).tokens == 0 { return false; diff --git a/crates/blackwall-xdp/tests/prog_test_run.rs b/crates/blackwall-xdp/tests/prog_test_run.rs index 2b19ae4..2396ba6 100644 --- a/crates/blackwall-xdp/tests/prog_test_run.rs +++ b/crates/blackwall-xdp/tests/prog_test_run.rs @@ -1286,16 +1286,14 @@ struct TxBucketPod(blackwall_xdp_common::TxBucket); // exact layout, so it is byte-for-byte valid as a per-CPU BPF map value. unsafe impl aya::Pod for TxBucketPod {} -/// Install a global `TX_BUDGET` cap of `cap` tokens on *every* CPU's slot, -/// with `rate_pps = 1` (nonzero, so `tx_budget_ok` treats the cap as -/// configured rather than "not configured => never throttle") and -/// `last_ns = u64::MAX` so `now.saturating_sub(last_ns)` is always `0` — no -/// refill can occur during the test no matter how much wall-clock time -/// elapses between `BPF_PROG_TEST_RUN` calls. Mirrors `install_rate_bucket`'s -/// no-refill trick, adapted because X3's cap-enable signal is `rate_pps != 0` -/// itself (unlike `RATE`, where `rate_pps = 0` is used to freeze refill while -/// the bucket is still "configured" by virtue of the entry existing at all). -fn install_tx_budget(bpf: &mut Ebpf, cap: u64) { +/// Install a global `TX_BUDGET` bucket on *every* CPU's slot with explicit +/// `tokens`/`last_ns`/`rate_pps`, giving each test full control over which of +/// `tx_budget_ok`'s two code paths it exercises: a pre-seeded `tokens` count +/// that must NOT be clamped down by the refill/burst logic (`install_tx_budget` +/// below), or a refill-derived `tokens` count that starts at `0` and must be +/// bounded by the rate-scaled burst ceiling (the X3 burst-scaling follow-up +/// test). +fn install_tx_budget_raw(bpf: &mut Ebpf, tokens: u64, last_ns: u64, rate_pps: u64) { use aya::maps::{PerCpuArray, PerCpuValues}; use aya::util::nr_cpus; @@ -1303,9 +1301,9 @@ fn install_tx_budget(bpf: &mut Ebpf, cap: u64) { PerCpuArray::try_from(bpf.map_mut("TX_BUDGET").expect("TX_BUDGET map present")) .expect("TX_BUDGET is a PerCpuArray"); let bucket = blackwall_xdp_common::TxBucket { - tokens: cap, - last_ns: u64::MAX, - rate_pps: 1, + tokens, + last_ns, + rate_pps, }; let cpus = nr_cpus().expect("nr_cpus"); let values = @@ -1313,6 +1311,30 @@ fn install_tx_budget(bpf: &mut Ebpf, cap: u64) { map.set(0, values, 0).expect("insert tx budget"); } +/// Install a global `TX_BUDGET` cap of `cap` tokens on *every* CPU's slot, +/// with `rate_pps = cap` (nonzero, so `tx_budget_ok` treats the cap as +/// configured rather than "not configured => never throttle"; sized so the +/// burst ceiling `tx_budget_ok` derives -- `min(2 x rate_pps, +/// TX_BUDGET_BURST_MAX)` -- is `>= cap` and therefore never clamps the +/// pre-seeded `tokens` down before the test drains them; see the X3 +/// burst-scaling follow-up, and `install_tx_budget_raw`'s doc comment) and +/// `last_ns = u64::MAX` so `now.saturating_sub(last_ns)` is always `0` — no +/// refill can occur during the test no matter how much wall-clock time +/// elapses between `BPF_PROG_TEST_RUN` calls. Mirrors `install_rate_bucket`'s +/// no-refill trick, adapted because X3's cap-enable signal is `rate_pps != 0` +/// itself (unlike `RATE`, where `rate_pps = 0` is used to freeze refill while +/// the bucket is still "configured" by virtue of the entry existing at all). +/// +/// `cap` must be nonzero (a `rate_pps` of `0` would instead mean "not +/// configured" and disable the cap entirely). +fn install_tx_budget(bpf: &mut Ebpf, cap: u64) { + debug_assert!( + cap > 0, + "cap must be nonzero, else rate_pps=0 disables the cap" + ); + install_tx_budget_raw(bpf, cap, u64::MAX, cap); +} + /// X3: with the cookie path fully enabled (protected prefix + port, a valid /// cookie key) and `TX_BUDGET` seeded to a cap of `CAP` tokens with no refill /// for the test's duration (see `install_tx_budget`), exactly the first `CAP` @@ -1410,3 +1432,123 @@ fn unseeded_tx_budget_never_throttles() { "an unseeded TX_BUDGET must never count a SYN as capped" ); } + +/// X3 burst-scaling follow-up: the burst ceiling `tx_budget_ok` refills +/// `tokens` up to must scale with the configured `rate_pps` +/// (`min(2 x rate_pps, TX_BUDGET_BURST_MAX)`), not always refill to a fixed +/// constant (the old, buggy `TX_BUDGET_BURST = 1_000_000`) regardless of how +/// small `rate_pps` is. +/// +/// Seeds `TX_BUDGET` with `tokens = 0`, `last_ns = 0` -- exactly the "just +/// armed" state, so the very first `tx_budget_ok` call on CPU0 sees a huge +/// `elapsed_ns` since boot and refills as far as the burst ceiling allows -- +/// and a conservative `RATE_PPS`. It then fires `TOTAL > 2 x RATE_PPS` SYNs. +/// Under the pre-fix behavior this would refill all the way to +/// `TX_BUDGET_BURST_MAX = 1_000_000` on the first call and every one of +/// `TOTAL` SYNs would mint; with the fix, only the rate-scaled burst +/// (`2 x RATE_PPS`) may mint and the rest must be attributed to +/// `REASON_SYNCOOKIE_TXCAPPED`. +#[test] +#[ignore = "requires root + recent kernel; run in the lab CI job"] +fn global_tx_budget_burst_scales_with_rate_pps() { + const RATE_PPS: u64 = 10; + const BURST: u64 = 2 * RATE_PPS; // mirrors tx_budget_ok's min(2 x rate_pps, MAX) + const TOTAL: u64 = 3 * RATE_PPS; // > BURST, so some SYNs must be capped + + pin_to_cpu0(|| { + let mut bpf = Ebpf::load(blackwall_xdp::PROGRAM_OBJECT).expect("load eBPF object"); + install_tx_budget_raw(&mut bpf, 0, 0, RATE_PPS); + let prog_fd = load_with_cookie_and_gate(&mut bpf); + + let mut minted = 0u64; + for i in 0..TOTAL { + let port_offset = u16::try_from(i).expect("small test index fits u16"); + let seq_offset = u32::try_from(i).expect("small test index fits u32"); + let syn = eth_ipv4_tcp_syn( + [203, 0, 113, 8], + [10, 0, 0, 1], + 40_000 + port_offset, + PROTECT_TCP_PORT, + 0x2000_0000 + seq_offset, + 1460, + ); + if run_xdp(prog_fd, &syn) == XDP_TX { + minted += 1; + } + } + assert_eq!( + minted, BURST, + "minted SYNs must be bounded by the rate-scaled burst \ + (2 x RATE_PPS = {BURST}), not the old fixed TX_BUDGET_BURST_MAX \ + ceiling (which would have let all {TOTAL} mint)" + ); + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_SYNCOOKIE_TXCAPPED), + TOTAL - BURST, + "every SYN beyond the rate-scaled burst must be tx-capped" + ); + }); +} + +/// X3 follow-up (v4/v6 ordering asymmetry, Fix 2): `try_synack_v6` must read +/// and validate the cookie key *before* consuming global `TX_BUDGET`, exactly +/// like `try_synack_v4` -- so a v6 SYN with no cookie key installed is never +/// attributed to `REASON_SYNCOOKIE_TXCAPPED` (it must instead fall through to +/// the generic `REASON_PASS`), even when the global budget also happens to be +/// exhausted. Before the fix, `try_synack_v6` checked `tx_budget_ok` first, +/// so this same combined state (`no key` + `cap exhausted`) would have +/// consumed a token and mis-counted the SYN as tx-capped. +#[test] +#[ignore = "requires root + recent kernel; run in the lab CI job"] +fn v6_syn_with_no_cookie_key_is_not_misattributed_to_tx_cap() { + let client_port = 54_321u16; + let server_port = 443u16; + let client_seq = 0x1122_3344u32; + let client_mss = 1460u16; + + let syn = eth_ipv6_tcp_syn( + CLIENT_IP6, + SERVER_IP6, + client_port, + server_port, + client_seq, + client_mss, + ); + + let mut bpf = Ebpf::load(blackwall_xdp::PROGRAM_OBJECT).expect("load eBPF object"); + // Global mint budget exhausted (nonzero rate_pps => configured; tokens=0 + // and last_ns=u64::MAX => frozen at empty, no refill can happen during + // the test) -- isolates "no cookie key" from "budget exhausted" so a + // misattribution would be unambiguous. + install_tx_budget_raw(&mut bpf, 0, u64::MAX, 1); + // B2.3c gate cleared (protected prefix + port)... + install_protect_prefix_v6(&mut bpf, 32, PROTECT_PREFIX6); + install_protect_port(&mut bpf, server_port); + // ...but deliberately do NOT install a cookie key. + let prog: &mut Xdp = bpf + .program_mut("xdp_filter") + .expect("xdp_filter program present") + .try_into() + .expect("program is an Xdp"); + prog.load().expect("verify + load xdp_filter"); + let prog_fd = prog.fd().expect("program fd").as_fd().as_raw_fd(); + + let (action, out) = run_xdp_out(prog_fd, &syn); + assert_eq!( + action, XDP_PASS, + "a v6 SYN with no cookie key installed must pass through, budget \ + exhaustion notwithstanding" + ); + assert_eq!(out, syn, "a passed SYN must be byte-for-byte unchanged"); + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_SYNCOOKIE_TXCAPPED), + 0, + "a SYN with no cookie key must never be counted as tx-capped -- the \ + key check must happen before the budget check" + ); + assert_eq!( + stat_packets(&mut bpf, blackwall_xdp_common::REASON_PASS), + 1, + "the no-key bail must be counted via the generic REASON_PASS path" + ); +} From 9062f1f6d0cee849d62228294b10b01441d55280 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 15 Jul 2026 13:58:07 -0400 Subject: [PATCH 4/6] feat(xdp): syn-cookie-tx-cap config + setter + metric + veth gate (X3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the userspace side of Task 2's TX_BUDGET SYN-cookie mint-rate cap: - XdpDataplane::set_syn_cookie_tx_cap(pps) seeds TX_BUDGET's rate_pps on every CPU, mirroring set_cookie_key/rate_limit's per-CPU seeding. - New `syn-cookie-tx-cap=` key on the `xdp` config directive -> XdpConfig.syn_cookie_tx_cap: u32, defaulting to a conservative DEFAULT_SYN_COOKIE_TX_CAP_PPS = 1000 (never 0/unlimited). Rejects 0 and non-numeric values. - blackwalld always calls set_syn_cookie_tx_cap whenever the SYN-cookie fast path is armed (cookie-ports non-empty), so TX_BUDGET is never left at its zero-initialised (unlimited) default while cookies are live. - New blackwall_xdp_syn_cookies_txcapped_total metric (REASON_SYNCOOKIE_TXCAPPED), rendered alongside the existing blackwall_xdp_syn_cookies_sent_total. - New root-gated veth lab gate in ddos_drop.rs: a spoofed-source SYN flood at a cookie port is bounded by a low tx cap (REASON_SYNCOOKIE_TXCAPPED > 0, minted << flood size), while a legitimate SYN sent after the bucket refills still mints a cookie. deception-syncookie.kdl was not extended as originally suggested — it drives the unrelated userspace stateless-tcp/NFQUEUE tier with xdp: None, so it cannot exercise TX_BUDGET at all; ddos_drop.rs is the real XDP veth gate (it already attaches the live eBPF program) and is the closest achievable proof. Also documents syn-cookie-tx-cap + the new metric in docs/deployment.md and adds a combined X1+X3 CHANGELOG entry. --- CHANGELOG.md | 1 + bin/blackwalld/src/main.rs | 14 +++ bin/blackwalld/src/metrics.rs | 1 + crates/blackwall-config/src/parser.rs | 64 +++++++++++ crates/blackwall-core/src/lib.rs | 2 +- crates/blackwall-core/src/xdp.rs | 20 ++++ crates/blackwall-metrics/src/lib.rs | 25 +++++ crates/blackwall-xdp/src/dataplane.rs | 71 +++++++++++- crates/blackwall-xdp/tests/ddos_drop.rs | 138 ++++++++++++++++++++++++ docs/deployment.md | 22 +++- 10 files changed, 353 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb34f1..2cd13c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project are documented here, following - nftables rendering bound the `prerouting` filter chain to the managed interface (`type filter hook prerouting … device`), which the kernel rejects — only ingress/egress chains may bind a device — so `blackwall-nft::apply` failed on any real ruleset. The chain is now unbound and classification is scoped per-rule with an `iifname` match, the correct pattern for a prerouting filter chain. Because an unbound chain runs for every interface, the closed posture (`default_state` closed) is now enforced by an explicit interface-scoped terminal `drop` rule instead of a chain-wide drop policy, so it no longer black-holes loopback or other-interface host traffic. Caught by the new deception↔scanner lab gate, the first end-to-end run of `apply` against a real `nft`. ### Added +- XDP armed-only hardening (sub-project X, follow-on to the M1 interlock's deferred "XDP data-plane armed-only bugs" item). **X1 — race-free per-source RATE bucket:** the per-source token bucket a concurrent RSS-steered flood could race (read-modify-write without a lock) is now provably race-free. A `bpf_spin_lock`-guarded single shared bucket was attempted first and rejected by the verifier on this toolchain (aya-ebpf 0.1.1's `#[map]` macro emits the legacy `bpf_map_def` ELF section, so the kernel never sees `RATE`'s BTF and refuses `bpf_spin_lock`); the shipped fix instead makes `RATE` an `LruPerCpuHashMap` — each CPU holds its own independent bucket for a given source, so the refill/decrement needs no lock (race-free) at the cost of a looser effective ceiling under RSS spread (up to `N_cpus × burst`, never tighter than the configured rate). **X3 — global SYN-cookie XDP_TX mint-rate cap:** the in-kernel cookie fast path was a gain-1 reflector against a spoofed-source flood (each spoofed source's per-source `RATE` bucket never re-triggers, since the address is never reused), so a new per-CPU `TX_BUDGET` token bucket now bounds the *aggregate* rate of `XDP_TX`-emitted cookie SYN-ACKs regardless of source count, mirroring `RATE`'s per-CPU-fallback tradeoff. Configured by a new `syn-cookie-tx-cap=` key on the `xdp` directive; **always seeded with a nonzero rate whenever `cookie-ports` is armed** — an operator who enables the cookie fast path without setting this knob gets a conservative built-in default (1000 pps) rather than an uncapped reflector, so there is no config shape that leaves `TX_BUDGET` at its zero-initialised ("unlimited") default while cookies are live. SYNs denied a cookie by the cap fall through to their normal non-cookie verdict. New metric `blackwall_xdp_syn_cookies_txcapped_total` (`REASON_SYNCOOKIE_TXCAPPED`) alongside the existing `blackwall_xdp_syn_cookies_sent_total`. Proven end to end by new root-gated veth lab gates (`ddos_drop.rs`): a real sustained flood against the live program, not `BPF_PROG_TEST_RUN` (single-shot, cannot show time-dependent bucket refill). - M1 arming interlock (AS214806 milestone M1) — the safety guards that make removing the `shadow` directive and letting the mitigation plane act for real *safe*. Six control-plane interlocks: (**C1**) **anycast self-protection** — a new repeatable `protect ` directive; a target inside a protected prefix is skipped *before* the eligibility check across RTBH, FlowSpec, and the XDP auto-sink, so a flood of your own anycast VIP can never blackhole your own service (a manual `add` of a protected target now returns `Rejected`, not a silent forever-pending `Deferred`). It also applies under `shadow`, so it stops false "would-blackhole-own-VIP" records in the observation window. (**C2**) **confirm-before-active** — the mitigation managers now roll back the in-memory active entry when the BGP announce / eBPF-map write fails, so the control plane never believes it mitigated something the router/kernel didn't take (previously a failed announce left a phantom "active" entry that deduped future detections into an invisible protection gap); surfaced as `blackwall_{rtbh,flowspec,xdp}_apply_failures_total`. (**C3**) **capability-gated OPEN** — FlowSpec/IPv6 announces *and* withdraws are gated on the peer's negotiated AFI/SAFIs, so a peer that never negotiated SAFI 133 no longer NOTIFICATION-resets the session in a loop (skipped + `blackwall_bgp_unnegotiated_announce_skipped_total{safi}`). (**C4**) a re-asserted FlowSpec rule whose action changed (e.g. tightening a rate-limit to a full drop mid-attack) now re-announces instead of silently no-op'ing. (**C6**) a cross-plane **rate cap** (`max-new-per-min` on the `rtbh` directive) bounds the transient blast radius of a detection storm or bug — new mitigations over the ceiling are rejected + counted (`blackwall_mitigations_ratecapped_total{plane}`), live-only. (**C5**) an **in-daemon disarm** on `SIGUSR1` — withdraws every announced route then keeps detecting + recording but applies nothing (record-only), so an operator can instantly stop mitigating without losing the detector; a `blackwall_armed` gauge reads 1 live / 0 shadow / 0 disarmed. All guards are inert under `shadow` (except C1's recording) and every new config field defaults to today's behavior. Proven end-to-end by three armed-mode lab gates against real BIRD 2.17.1 (protected-skip, rate re-announce, no-reset-on-un-negotiated-peer, SIGUSR1 withdraw-all). Arming remains `remove shadow + restart`; RPKI cross-check, the XDP data-plane armed-only bugs, per-plane arming, runtime re-arm, and a control-API disarm endpoint are deferred follow-ons. - BIRD iBGP-snippet generator (deployment #3): `blackwalld bird-config --config ` generates BIRD's side of the blackwall↔BIRD iBGP session from blackwall's own config, so prefix lists and session params aren't hand-maintained in both `blackwall.conf` and `bird.conf`. A pure `blackwall_bgp::render_bird_ibgp` emits an `include` file: `OWN_V4/V6` prefix defines plus one MP-BGP `protocol bgp blackwall` session (ipv4/ipv6/flow4/flow6 channels; unicast import filters `net ~ [prefix+]`, flow filters `net.dst ~ [prefix+]`; MD5 → an `include "blackwall-secret.conf";` reference so the secret never lands in the generated file; GTSM → `ttl security on`). A new `rtbh local-addr=` sets blackwall's BGP source, emitted as BIRD's `neighbor` and bound by the speaker as its source so the two sides match by construction. Validated against real **BIRD 2.17.1** by a new `bird-gen` lab gate (the generated include establishes the session and imports both a `/32` blackhole and a FlowSpec rule) plus a `bird -p` parse-check. BIRD stays the fan-out point — blackwall injects once, BIRD re-advertises to every upstream via its existing per-peer filters. Non-breaking. - Network-wide shadow mode (deployment #9): a global, opt-in `shadow` config directive that makes the mitigation plane log + record + meter every RTBH/FlowSpec/XDP mitigation the daemon *would* apply, **without executing it** — the interlock for running a detection-only deployment live on the security boundary. Shadow decorates the execution boundary only (a `ShadowBgpExecutor` that holds no BGP handle, so it's structurally incapable of announcing; no-op journals keep the mirror empty; the real iBGP session isn't spawned; the XDP map-apply is gated), while detection/selection/controller logic runs identically. Intended actions surface via INFO logs, a `blackwall_shadow_would_mitigate_total{plane,action}` counter, and `audit_log` rows (queryable through `/v1/audit`), with a startup `WARN: SHADOW MODE` banner. Non-breaking (absent `shadow` = live behavior). diff --git a/bin/blackwalld/src/main.rs b/bin/blackwalld/src/main.rs index 17899dc..88cd8fb 100644 --- a/bin/blackwalld/src/main.rs +++ b/bin/blackwalld/src/main.rs @@ -2115,6 +2115,15 @@ async fn run() -> Result<(), Box> { if !xdp_cfg.cookie_ports.is_empty() { match store.cookie_secret().await { Ok(secret) => { + // Task 3 (X3): whenever the cookie path is + // armed, ALSO seed the global `TX_BUDGET` + // mint-rate cap — never leave it at its + // zero-initialised (unlimited) default while + // cookie-ports is live, so an operator who + // enables cookie-ports without also setting + // `syn-cookie-tx-cap` still gets a bounded + // reflector (config defaults the cap to + // `DEFAULT_SYN_COOKIE_TX_CAP_PPS`). let activated = dataplane .set_cookie_key(secret) .and_then(|()| { @@ -2122,11 +2131,16 @@ async fn run() -> Result<(), Box> { }) .and_then(|()| { dataplane.set_protected_ports(&xdp_cfg.cookie_ports) + }) + .and_then(|()| { + dataplane + .set_syn_cookie_tx_cap(xdp_cfg.syn_cookie_tx_cap) }); match activated { Ok(()) => tracing::info!( ports = xdp_cfg.cookie_ports.len(), prefixes = policy.prefixes.len(), + tx_cap_pps = xdp_cfg.syn_cookie_tx_cap, "XDP: SYN-cookie fast path activated" ), Err(err) => tracing::warn!( diff --git a/bin/blackwalld/src/metrics.rs b/bin/blackwalld/src/metrics.rs index 58b0d2d..73dd38f 100644 --- a/bin/blackwalld/src/metrics.rs +++ b/bin/blackwalld/src/metrics.rs @@ -366,6 +366,7 @@ fn xdp_block(sources: &MetricsSources) -> Option { dropped_blocklist_packets: u64_to_f64(s.dropped_blocklist.packets), dropped_ratelimit_packets: u64_to_f64(s.dropped_ratelimit.packets), syn_cookies_sent_packets: u64_to_f64(s.syn_cookies_sent.packets), + syn_cookies_txcapped_packets: u64_to_f64(s.syn_cookies_txcapped.packets), blocked_entries: u64_to_f64(s.blocked_entries), ratelimit_entries: u64_to_f64(s.ratelimit_entries), })) diff --git a/crates/blackwall-config/src/parser.rs b/crates/blackwall-config/src/parser.rs index e0c3f4a..d205b35 100644 --- a/crates/blackwall-config/src/parser.rs +++ b/crates/blackwall-config/src/parser.rs @@ -716,6 +716,7 @@ pub fn parse(lines: &[Line]) -> Result { cookie_ports: Vec::new(), afxdp_udp_ports: Vec::new(), afxdp_udp_banner: None, + syn_cookie_tx_cap: blackwall_core::DEFAULT_SYN_COOKIE_TX_CAP_PPS, }; for tok in &line.words[1..] { let (k, v) = tok @@ -767,6 +768,15 @@ pub fn parse(lines: &[Line]) -> Result { "afxdp-udp-banner" => { cfg.afxdp_udp_banner = Some(decode_banner_escapes(v)); } + "syn-cookie-tx-cap" => { + let n = v + .parse::() + .map_err(|_| bad("xdp syn-cookie-tx-cap", v))?; + if n == 0 { + return Err(bad("xdp syn-cookie-tx-cap", "must be >= 1")); + } + cfg.syn_cookie_tx_cap = n; + } other => return Err(bad("xdp key", other)), } } @@ -2036,6 +2046,60 @@ flowspec concentration=0.8 max-flows=4 rate=0 max-rules=256 hold-down=60s bogus= assert_eq!(x.cookie_ports, vec![8080, 443]); } + #[test] + fn parses_xdp_syn_cookie_tx_cap() { + let p = parse_text( + "interface wan eth0\nxdp interface=eth0 cookie-ports=443 syn-cookie-tx-cap=5000\n", + ) + .unwrap(); + let x = p.xdp.expect("xdp set"); + assert_eq!(x.syn_cookie_tx_cap, 5000); + } + + #[test] + fn xdp_syn_cookie_tx_cap_absent_is_conservative_default() { + let p = parse_text("interface wan eth0\nxdp interface=eth0 cookie-ports=443\n").unwrap(); + let x = p.xdp.expect("xdp set"); + // Never 0/unlimited: an operator enabling cookie-ports without this + // knob must still get a bounded reflector. + assert_eq!( + x.syn_cookie_tx_cap, + blackwall_core::DEFAULT_SYN_COOKIE_TX_CAP_PPS + ); + assert_eq!(x.syn_cookie_tx_cap, 1000); + } + + #[test] + fn rejects_xdp_syn_cookie_tx_cap_zero() { + let err = parse_text("interface wan eth0\nxdp cookie-ports=443 syn-cookie-tx-cap=0\n") + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::BadValue { + what: "xdp syn-cookie-tx-cap", + .. + } + ), + "got {err:?}" + ); + } + + #[test] + fn rejects_xdp_syn_cookie_tx_cap_non_numeric() { + let err = parse_text("interface wan eth0\nxdp syn-cookie-tx-cap=notanumber\n").unwrap_err(); + assert!( + matches!( + err, + ConfigError::BadValue { + what: "xdp syn-cookie-tx-cap", + .. + } + ), + "got {err:?}" + ); + } + #[test] fn parses_xdp_afxdp_udp_ports() { let p = diff --git a/crates/blackwall-core/src/lib.rs b/crates/blackwall-core/src/lib.rs index 2502874..86a6be8 100644 --- a/crates/blackwall-core/src/lib.rs +++ b/crates/blackwall-core/src/lib.rs @@ -37,4 +37,4 @@ pub use resolve::{PolicyError, ResolvedService}; pub use rtbh::RtbhPolicy; pub use shape::{ShapeBandwidth, ShapeRule}; pub use target::ServiceTarget; -pub use xdp::{XdpConfig, XdpMode}; +pub use xdp::{XdpConfig, XdpMode, DEFAULT_SYN_COOKIE_TX_CAP_PPS}; diff --git a/crates/blackwall-core/src/xdp.rs b/crates/blackwall-core/src/xdp.rs index 3b1f83a..51fd283 100644 --- a/crates/blackwall-core/src/xdp.rs +++ b/crates/blackwall-core/src/xdp.rs @@ -14,6 +14,15 @@ pub enum XdpMode { Generic, } +/// Conservative default for [`XdpConfig::syn_cookie_tx_cap`] (packets per +/// second), used when the `syn-cookie-tx-cap=` directive key is absent. +/// +/// **Never `0`** (`0` means "unlimited" to the in-kernel `TX_BUDGET` bucket — +/// see `blackwall_xdp_common::TxBucket`'s doc comment): an operator who +/// enables `cookie-ports` without also specifying this knob must still get a +/// bounded SYN-ACK reflector rather than an unbounded gain-1 amplifier. +pub const DEFAULT_SYN_COOKIE_TX_CAP_PPS: u32 = 1000; + /// Configuration for the on-box XDP fast path (`xdp` directive); `None` on /// [`crate::Policy`] means XDP is disabled. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -52,4 +61,15 @@ pub struct XdpConfig { /// still truncates the banner to at most the request's payload length, so /// this can never amplify. pub afxdp_udp_banner: Option>, + /// Global cap (packets per second) on the in-kernel SYN-cookie `XDP_TX` + /// mint rate (`syn-cookie-tx-cap=` directive, sub-project X3), written into + /// the eBPF `TX_BUDGET` bucket via + /// `blackwall_xdp::XdpDataplane::set_syn_cookie_tx_cap`. Bounds the + /// *aggregate* SYN-ACK reflection rate regardless of how many distinct + /// (possibly spoofed) source addresses a flood spreads across — the + /// per-source `RATE` limiter alone never engages against a flood that never + /// reuses a source. Defaults to [`DEFAULT_SYN_COOKIE_TX_CAP_PPS`] + /// (never `0`/unlimited) so enabling `cookie-ports` without this knob still + /// yields a bounded reflector. + pub syn_cookie_tx_cap: u32, } diff --git a/crates/blackwall-metrics/src/lib.rs b/crates/blackwall-metrics/src/lib.rs index 79ef290..44db98a 100644 --- a/crates/blackwall-metrics/src/lib.rs +++ b/crates/blackwall-metrics/src/lib.rs @@ -100,6 +100,10 @@ pub struct XdpMetrics { /// Packets answered in-kernel with a SipHash-cookie SYN-ACK via `XDP_TX` /// (`REASON_SYNCOOKIE`, B2.3c). pub syn_cookies_sent_packets: f64, + /// SYNs that cleared every SYN-cookie gate but were denied a SYN-ACK + /// because the global `TX_BUDGET` mint-rate cap was exhausted + /// (`REASON_SYNCOOKIE_TXCAPPED`, sub-project X3). + pub syn_cookies_txcapped_packets: f64, /// Number of active blocklist entries (`BLOCK_V4` + `BLOCK_V6`). pub blocked_entries: f64, /// Number of active rate-limit entries (`RATE`). @@ -153,6 +157,21 @@ pub fn render_xdp_metrics(m: &XdpMetrics) -> String { "blackwall_xdp_syn_cookies_sent_total {}", format_value(m.syn_cookies_sent_packets) ); + let _ = writeln!( + out, + "\n# HELP blackwall_xdp_syn_cookies_txcapped_total SYNs that cleared every \ + SYN-cookie gate but were denied a SYN-ACK because the global XDP_TX mint-rate cap \ + was exhausted" + ); + let _ = writeln!( + out, + "# TYPE blackwall_xdp_syn_cookies_txcapped_total counter" + ); + let _ = writeln!( + out, + "blackwall_xdp_syn_cookies_txcapped_total {}", + format_value(m.syn_cookies_txcapped_packets) + ); let _ = writeln!( out, "\n# HELP blackwall_xdp_blocked_entries Active XDP source-blocklist entries" @@ -285,6 +304,7 @@ blackwall_bgp_reconnects_total 0 dropped_blocklist_packets: 42.0, dropped_ratelimit_packets: 7.0, syn_cookies_sent_packets: 9.0, + syn_cookies_txcapped_packets: 2.0, blocked_entries: 3.0, ratelimit_entries: 5.0, }; @@ -302,6 +322,10 @@ blackwall_xdp_packets_passed_total 1000 # TYPE blackwall_xdp_syn_cookies_sent_total counter blackwall_xdp_syn_cookies_sent_total 9 +# HELP blackwall_xdp_syn_cookies_txcapped_total SYNs that cleared every SYN-cookie gate but were denied a SYN-ACK because the global XDP_TX mint-rate cap was exhausted +# TYPE blackwall_xdp_syn_cookies_txcapped_total counter +blackwall_xdp_syn_cookies_txcapped_total 2 + # HELP blackwall_xdp_blocked_entries Active XDP source-blocklist entries # TYPE blackwall_xdp_blocked_entries gauge blackwall_xdp_blocked_entries 3 @@ -327,6 +351,7 @@ blackwall_xdp_ratelimit_entries 5 dropped_blocklist_packets: 0.0, dropped_ratelimit_packets: 0.0, syn_cookies_sent_packets: 0.0, + syn_cookies_txcapped_packets: 0.0, blocked_entries: 0.0, ratelimit_entries: 0.0, }); diff --git a/crates/blackwall-xdp/src/dataplane.rs b/crates/blackwall-xdp/src/dataplane.rs index 9f4490a..41cb4b9 100644 --- a/crates/blackwall-xdp/src/dataplane.rs +++ b/crates/blackwall-xdp/src/dataplane.rs @@ -25,8 +25,8 @@ use aya::util::nr_cpus; use aya::Ebpf; use blackwall_core::XdpMode; use blackwall_xdp_common::{ - CookieKeyValue, RateBucket, Stat, REASON_BLOCKLIST, REASON_PASS, REASON_RATELIMIT, - REASON_REDIRECT, REASON_SYNCOOKIE, + CookieKeyValue, RateBucket, Stat, TxBucket, REASON_BLOCKLIST, REASON_PASS, REASON_RATELIMIT, + REASON_REDIRECT, REASON_SYNCOOKIE, REASON_SYNCOOKIE_TXCAPPED, }; use ipnet::IpNet; use std::net::IpAddr; @@ -87,6 +87,12 @@ struct DataplaneMaps { /// UDP destination ports whose IPv4 datagrams are redirected to the `AF_XDP` /// socket (B3.1), keyed by the host-native `u16` port value. redirect_port: HashMap, + /// Single-entry (key `0`), per-CPU global SYN-cookie `XDP_TX` mint-rate + /// budget (sub-project X3 — see [`blackwall_xdp_common::TxBucket`]'s doc + /// comment). Per-CPU (the X1 fallback rationale applies here too), so every + /// CPU's slot must be seeded identically on write — see + /// [`DataplaneMaps::set_syn_cookie_tx_cap`]. + tx_budget: PerCpuArray, } /// Fixed map key of the sole `COOKIE_KEY` entry (mirrors the eBPF @@ -126,6 +132,17 @@ struct CookieKeyPod(CookieKeyValue); // its exact layout, so it is byte-for-byte valid as a BPF map value. unsafe impl aya::Pod for CookieKeyPod {} +/// `#[repr(transparent)]` newtype giving the foreign [`TxBucket`] POD an +/// [`aya::Pod`] impl for the `TX_BUDGET` per-CPU map. +#[repr(transparent)] +#[derive(Clone, Copy, Default)] +struct TxBucketPod(TxBucket); + +// SAFETY: `TxBucket` is a `#[repr(C)]` `Copy + 'static` plain-old-data struct +// of `u64` fields; `#[repr(transparent)]` makes `TxBucketPod` share its exact +// layout, so it is byte-for-byte valid as a per-CPU BPF map value. +unsafe impl aya::Pod for TxBucketPod {} + /// A snapshot of the data plane's per-CPU decision counters plus current map /// occupancy. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -142,6 +159,10 @@ pub struct XdpStats { /// Packets/bytes redirected to a userspace `AF_XDP` socket via `XSKS` /// (`REASON_REDIRECT`, B3.1). pub redirected: Stat, + /// Packets/bytes that cleared every SYN-cookie gate but were denied a + /// SipHash-cookie SYN-ACK because the global [`TxBucket`] mint budget was + /// exhausted (`REASON_SYNCOOKIE_TXCAPPED`, sub-project X3). + pub syn_cookies_txcapped: Stat, /// Number of blocklist entries (`BLOCK_V4` + `BLOCK_V6`). pub blocked_entries: u64, /// Number of rate-limit entries (`RATE`). @@ -250,6 +271,7 @@ impl XdpDataplane { protect_port: take_map(&mut ebpf, "PROTECT_PORT")?, xsks: take_map(&mut ebpf, "XSKS")?, redirect_port: take_map(&mut ebpf, "REDIRECT_PORT")?, + tx_budget: take_map(&mut ebpf, "TX_BUDGET")?, }; // B4.1: pin the capture ring + flag to bpffs so a separate @@ -322,6 +344,30 @@ impl XdpDataplane { self.locked()?.set_cookie_key(key) } + /// Install the global SYN-cookie `XDP_TX` mint-rate cap (sub-project X3) + /// into the `TX_BUDGET` map, in packets (SYN-ACKs) per second. + /// + /// This bounds the *aggregate* in-kernel cookie-mint rate regardless of how + /// many distinct (possibly spoofed) source addresses a SYN flood spreads + /// across, on top of the per-source `RATE` limiter — see + /// [`blackwall_xdp_common::TxBucket`]'s doc comment for the full rationale. + /// + /// Callers must pass a nonzero `pps`: the eBPF fast path treats + /// `rate_pps == 0` as "cap not configured" and never throttles, so a `0` + /// here would silently re-open the unbounded reflector this cap exists to + /// close. `blackwalld` always calls this whenever `cookie-ports` is armed, + /// seeded from [`blackwall_core::DEFAULT_SYN_COOKIE_TX_CAP_PPS`] when the + /// operator leaves `syn-cookie-tx-cap` unset, so `TX_BUDGET` is never left + /// at its zero-initialised (unlimited) default while the cookie path is + /// live. + /// + /// # Errors + /// + /// Returns [`XdpError::Map`] if the map write fails. + pub fn set_syn_cookie_tx_cap(&mut self, pps: u32) -> Result<(), XdpError> { + self.locked()?.set_syn_cookie_tx_cap(pps) + } + /// Install the box's own protected deception prefixes into `PROTECT_V4` /// (IPv4) and `PROTECT_V6` (IPv6), routing each prefix to the map for its /// family. @@ -513,6 +559,26 @@ impl DataplaneMaps { .map_err(map_err) } + /// Seed the single `TX_BUDGET` slot's refill rate on every CPU. + /// + /// `TX_BUDGET` is per-CPU (X1/X3 fallback — see [`TxBucket`]'s doc + /// comment), so — exactly like [`Self::rate_limit`]'s `RATE` seeding — + /// every CPU's slot must be written identically with a full `tokens = + /// rate_pps` bucket; otherwise whichever CPU an RSS-steered SYN lands on + /// could see a stale or empty bucket from before this call. + fn set_syn_cookie_tx_cap(&mut self, pps: u32) -> Result<(), XdpError> { + let rate_pps = u64::from(pps); + let bucket = TxBucket { + tokens: rate_pps, + last_ns: 0, + rate_pps, + }; + let cpus = nr_cpus().map_err(|(ctx, e)| XdpError::Map(format!("{ctx}: {e}")))?; + let values = PerCpuValues::try_from(vec![TxBucketPod(bucket); cpus]) + .map_err(|e| XdpError::Map(e.to_string()))?; + self.tx_budget.set(0, values, 0).map_err(map_err) + } + /// Insert each prefix into the `PROTECT_V4` (IPv4) or `PROTECT_V6` (IPv6) /// trie by family. Reuses the shared `lpm_key` encoding so each key layout is /// byte-identical to the corresponding blocklist trie. @@ -576,6 +642,7 @@ impl DataplaneMaps { dropped_ratelimit: self.sum_reason(REASON_RATELIMIT)?, syn_cookies_sent: self.sum_reason(REASON_SYNCOOKIE)?, redirected: self.sum_reason(REASON_REDIRECT)?, + syn_cookies_txcapped: self.sum_reason(REASON_SYNCOOKIE_TXCAPPED)?, blocked_entries: count_keys(self.block_v4.keys()) + count_keys(self.block_v6.keys()), ratelimit_entries: count_keys(self.rate.keys()), }) diff --git a/crates/blackwall-xdp/tests/ddos_drop.rs b/crates/blackwall-xdp/tests/ddos_drop.rs index a1506e4..25fea35 100644 --- a/crates/blackwall-xdp/tests/ddos_drop.rs +++ b/crates/blackwall-xdp/tests/ddos_drop.rs @@ -11,6 +11,14 @@ //! admits a burst then drops the sustained excess: a fast flood of M frames //! from one source yields `REASON_RATELIMIT > 0` *and* a bounded number of //! `REASON_PASS` (≈ `burst`). +//! 3. **SYN-cookie global TX-budget cap under load** (sub-project X3) — a +//! spoofed-source SYN flood (a fresh source per frame, so the per-source +//! `RATE` limiter never engages — see `blackwall_xdp_common::TxBucket`'s +//! doc comment) at a protected cookie port admits only a bounded burst of +//! in-kernel SipHash-cookie `XDP_TX` mints (`REASON_SYNCOOKIE`) before the +//! global `TX_BUDGET` cap engages (`REASON_SYNCOOKIE_TXCAPPED > 0`), and a +//! legitimate SYN sent after the bucket has had time to refill still mints +//! a cookie — proving the cap denies the *excess*, not everything. //! //! The rate limiter is **time/rate-dependent** and cannot be exercised by //! `BPF_PROG_TEST_RUN` (single-shot, no wall-clock between runs): only a real @@ -127,6 +135,44 @@ fn udp_frame(src: [u8; 4], dst_port: u16, marker: [u8; 4]) -> Vec { p } +/// Client MAC used in the crafted SYN (mirrors `prog_test_run.rs`'s golden +/// SYN-cookie frames; this file's own SYN-cookie test needs the same shape but +/// each `#[test]` binary is compiled separately, so the builder is duplicated +/// rather than shared). +const CLIENT_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; +/// Server MAC used in the crafted SYN. +const SERVER_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02]; + +/// Build an `Ethernet + IPv4 + TCP SYN` frame carrying a single 4-byte MSS +/// option (TCP data-offset 6). Input header checksums are left zero — the eBPF +/// program never validates them, only recomputes the reply's. +fn eth_ipv4_tcp_syn(src_ip: [u8; 4], dst_ip: [u8; 4], src_port: u16, dst_port: u16) -> Vec { + const IP: usize = 14; + const TCP: usize = 34; + let mut p = vec![0u8; 14 + 20 + 24]; + p[0..6].copy_from_slice(&SERVER_MAC); + p[6..12].copy_from_slice(&CLIENT_MAC); + p[12] = 0x08; + p[13] = 0x00; + p[IP] = 0x45; + let tot_len = u16::try_from(20 + 24).expect("tot_len fits in u16"); + p[IP + 2..IP + 4].copy_from_slice(&tot_len.to_be_bytes()); + p[IP + 8] = 64; // TTL + p[IP + 9] = 6; // protocol = TCP + p[IP + 12..IP + 16].copy_from_slice(&src_ip); + p[IP + 16..IP + 20].copy_from_slice(&dst_ip); + p[TCP..TCP + 2].copy_from_slice(&src_port.to_be_bytes()); + p[TCP + 2..TCP + 4].copy_from_slice(&dst_port.to_be_bytes()); + p[TCP + 4..TCP + 8].copy_from_slice(&0x1122_3344u32.to_be_bytes()); // seq + p[TCP + 12] = 6 << 4; // data offset = 6 words, reserved 0 + p[TCP + 13] = 0x02; // SYN + p[TCP + 14..TCP + 16].copy_from_slice(&64_240u16.to_be_bytes()); // window + p[TCP + 20] = 2; // MSS option kind + p[TCP + 21] = 4; // MSS option len + p[TCP + 22..TCP + 24].copy_from_slice(&1460u16.to_be_bytes()); + p +} + /// A raw `AF_PACKET` socket pre-bound to one interface's egress, reused to blast /// many frames at line rate (one persistent fd — no per-frame socket setup — so /// the flood is fast enough that the token bucket refills negligibly during it). @@ -319,3 +365,95 @@ fn rate_limited_source_under_load_admits_a_burst_then_drops_the_excess() { "admitted far more than a burst ({passed} passed, burst={burst})" ); } + +#[test] +#[ignore = "requires root + CAP_NET_ADMIN/RAW; run in the lab CI job"] +fn syn_cookie_tx_cap_engages_under_spoofed_flood_but_legit_syn_still_mints() { + // X3: the veth gate for the global SYN-cookie XDP_TX mint-rate cap. Unlike + // `RATE` (per-source), a spoofed flood that never reuses a source address + // never trips the per-source limiter at all — only a real, sustained flood + // against the live program (not `BPF_PROG_TEST_RUN`, which is single-shot + // and cannot show token-bucket refill over time) proves the *aggregate* + // `TX_BUDGET` cap engages. + let veth = VethPair::create(); + let mut dp = XdpDataplane::attach(&veth.a, XdpMode::Auto).expect("attach xdp_filter to veth_a"); + + // Arm the SYN-cookie fast path: secret + protected prefix + protected port. + let dst_ip = [198, 51, 100, 1]; + let cookie_port = 8443u16; + dp.set_cookie_key([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, + ]) + .expect("install cookie key"); + dp.set_protected_prefixes(&["198.51.100.1/32".parse::().expect("parse prefix")]) + .expect("install protected prefix"); + dp.set_protected_ports(&[cookie_port]) + .expect("install protected port"); + + // A conservative cap: burst ceiling is `min(2 * cap_pps, TX_BUDGET_BURST_MAX)` + // (see `TxBucket`'s doc comment) — small enough that a flood of hundreds of + // spoofed SYNs, sent back-to-back with negligible elapsed time, clearly + // exceeds it. + let cap_pps: u32 = 5; + dp.set_syn_cookie_tx_cap(cap_pps) + .expect("install syn-cookie tx cap"); + + let sender = Sender::open(&veth.b); + const M: u32 = 300; + + let before = dp.stats(); + // A fresh spoofed source per SYN so the per-source RATE limiter (unarmed + // here — no `dp.rate_limit` call) never engages; only the global + // `TX_BUDGET` cap can deny these. + for i in 0..M { + let src = [ + 10, + 77, + u8::try_from(i / 256).expect("fits in u8"), + u8::try_from(i % 256).expect("fits in u8"), + ]; + let sent = sender.send(ð_ipv4_tcp_syn(src, dst_ip, 40_000, cookie_port)); + assert!(sent, "flood SYN #{i} should transmit"); + } + let after_flood = dp.stats(); + + let minted = after_flood.syn_cookies_sent.packets - before.syn_cookies_sent.packets; + let txcapped = after_flood.syn_cookies_txcapped.packets - before.syn_cookies_txcapped.packets; + + // The cap engaged: most of the flood was denied a cookie SYN-ACK. + assert!( + txcapped > 0, + "TX_BUDGET cap never engaged (REASON_SYNCOOKIE_TXCAPPED delta = 0)" + ); + // ...but it is NOT a blanket deny: some SYNs cleared every gate and got the + // initial burst worth of cookie mints. + assert!( + minted > 0, + "no SYNs minted a cookie at all (cap engaged too early / gating broken)" + ); + // The cap bounds the admitted total far below a 1:1 mint rate — generous + // slack (RSS could in principle spread the flood across a few per-CPU + // TX_BUDGET slots on a multi-queue veth) still proves this is a real cap, + // not "everything passes". + assert!( + minted < u64::from(M) / 2, + "minted ({minted}) should be far below the flood size ({M}) — cap not bounding the rate" + ); + + // Let the bucket refill (cap_pps=5 => ~1 token every 200ms) and prove a + // legitimate SYN afterward still mints — the cap denies the *excess*, not + // every subsequent SYN forever. + std::thread::sleep(std::time::Duration::from_millis(500)); + let legit_src = [10, 88, 0, 1]; + let sent = sender.send(ð_ipv4_tcp_syn(legit_src, dst_ip, 40_001, cookie_port)); + assert!(sent, "post-flood legit SYN should transmit"); + // Give the kernel a moment to process the single frame before reading stats. + std::thread::sleep(std::time::Duration::from_millis(50)); + let after_legit = dp.stats(); + let legit_minted = after_legit.syn_cookies_sent.packets - after_flood.syn_cookies_sent.packets; + assert!( + legit_minted >= 1, + "a legitimate SYN sent after the bucket had time to refill should still mint a cookie" + ); +} diff --git a/docs/deployment.md b/docs/deployment.md index 0035e43..7164050 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -105,7 +105,8 @@ Then follow the two runbooks for the hands-on first-run procedure: Set `metrics listen=127.0.0.1:9100` in the config and scrape `GET /metrics`: - `flow`: BGP session state + reconnects, sFlow datagrams/decode-errors, active RTBH/FlowSpec counts, pending queue depths, detection/session/audit totals, - and (with `cookie-ports=` set) `blackwall_xdp_syn_cookies_sent_total`. + and (with `cookie-ports=` set) `blackwall_xdp_syn_cookies_sent_total` + + `blackwall_xdp_syn_cookies_txcapped_total`. - `run`: `blackwall_deception_sessions_active` (live in-flight) + session/audit totals, and (with `stateless-tcp ports=` set) `blackwall_stateless_syn_cookies_sent_total`, `blackwall_stateless_acks_validated_total`, `blackwall_stateless_acks_rejected_total`, @@ -194,11 +195,28 @@ install steps above) for this to work. services) passes through untouched. A legitimate client's follow-up ACK falls through (`XDP_PASS`) to the userspace stateless responder above, which validates the byte-identical cookie and serves the banner. +- **Global SYN-cookie mint-rate cap** — the in-kernel cookie fast path is a + gain-1 reflector against a spoofed-source flood (each spoofed source's + per-source rate limit never re-triggers, since the address is never reused), + so a global `syn-cookie-tx-cap=` bounds the *aggregate* rate of + `XDP_TX`-emitted cookie SYN-ACKs regardless of how many distinct sources the + flood spreads across: + ``` + xdp interface=eth0 cookie-ports=8080,443 syn-cookie-tx-cap=2000 + ``` + Always enforced once `cookie-ports` is armed — if you omit + `syn-cookie-tx-cap`, blackwall installs a conservative built-in default + (1000 pps) rather than leaving the reflector uncapped, so there is no way to + turn on the cookie fast path with an unbounded amplification ceiling. SYNs + denied a cookie because the cap is exhausted fall through to their normal + (non-cookie) verdict rather than being answered. - **Metrics:** `blackwall_stateless_syn_cookies_sent_total`, `blackwall_stateless_acks_validated_total`, `blackwall_stateless_acks_rejected_total`, and `blackwall_stateless_udp_responses_total` on the `run` daemon's `/metrics`; - `blackwall_xdp_syn_cookies_sent_total` on the `flow` daemon's `/metrics`. + `blackwall_xdp_syn_cookies_sent_total` and + `blackwall_xdp_syn_cookies_txcapped_total` (SYNs denied a cookie because + `syn-cookie-tx-cap` was exhausted) on the `flow` daemon's `/metrics`. ## POP sensor (sFlow) Anycast POPs are not `blackwalld` hosts: each POP runs `hsflowd` (host-sflow) From 47c7bd2b6cf1b2f363137134d1cc72dcf84957c0 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 15 Jul 2026 16:51:49 -0400 Subject: [PATCH 5/6] fix(xdp): seed TX budget before arming the cookie port gate; document passed/txcapped double-count (X3 hardening) --- bin/blackwalld/src/main.rs | 16 +++++++++++++--- crates/blackwall-xdp-ebpf/src/main.rs | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/bin/blackwalld/src/main.rs b/bin/blackwalld/src/main.rs index 88cd8fb..022590e 100644 --- a/bin/blackwalld/src/main.rs +++ b/bin/blackwalld/src/main.rs @@ -2124,17 +2124,27 @@ async fn run() -> Result<(), Box> { // `syn-cookie-tx-cap` still gets a bounded // reflector (config defaults the cap to // `DEFAULT_SYN_COOKIE_TX_CAP_PPS`). + // Seed the TX budget BEFORE arming the port + // gate: these are sequential in-kernel map + // writes, and a failure landing on the LAST + // call would otherwise leave PROTECT_PORT + // armed while TX_BUDGET is still unseeded + // (rate_pps==0 == UNLIMITED to the eBPF), a + // momentary unbounded reflector. With the + // cap seeded first, a failure here means + // set_protected_ports never runs and the + // fast path stays inert instead. let activated = dataplane .set_cookie_key(secret) .and_then(|()| { dataplane.set_protected_prefixes(&policy.prefixes) }) - .and_then(|()| { - dataplane.set_protected_ports(&xdp_cfg.cookie_ports) - }) .and_then(|()| { dataplane .set_syn_cookie_tx_cap(xdp_cfg.syn_cookie_tx_cap) + }) + .and_then(|()| { + dataplane.set_protected_ports(&xdp_cfg.cookie_ports) }); match activated { Ok(()) => tracing::info!( diff --git a/crates/blackwall-xdp-ebpf/src/main.rs b/crates/blackwall-xdp-ebpf/src/main.rs index 23daf8b..0a84f13 100644 --- a/crates/blackwall-xdp-ebpf/src/main.rs +++ b/crates/blackwall-xdp-ebpf/src/main.rs @@ -587,6 +587,10 @@ fn try_filter(ctx: &XdpContext) -> Result { } _ => {} } + // NOTE: this fallthrough also catches tx-capped SYNs (`try_synack_v4`/ + // `v6` returning `Err(())` after counting REASON_SYNCOOKIE_TXCAPPED), so + // `passed_total` is a superset of the tx-capped count -- the reason + // counters intentionally do not sum to the total packet count. count(REASON_PASS, frame_len); capture(ctx, REASON_PASS, xdp_action::XDP_PASS, frame_len); Ok(xdp_action::XDP_PASS) @@ -742,6 +746,13 @@ fn try_synack_v4(ctx: &XdpContext) -> Result { // falls through to its normal non-cookie verdict instead of being answered. if !tx_budget_ok(now_ns) { let frame_len = (ctx.data_end() - ctx.data()) as u64; + // Intentional double-count: this SYN is tallied under + // REASON_SYNCOOKIE_TXCAPPED here (the distinct "budget exceeded" + // signal), and then AGAIN under REASON_PASS once `try_filter` falls + // through to `XDP_PASS` on this `Err(())`. The XDP verdict (PASS) is + // correct either way; `passed_total` is a superset that includes + // tx-capped SYNs, so the reason counters do not sum to the packet + // total by design. count(REASON_SYNCOOKIE_TXCAPPED, frame_len); return Err(()); } @@ -925,6 +936,10 @@ fn try_synack_v6(ctx: &XdpContext) -> Result { let now_ns = unsafe { bpf_ktime_get_ns() }; if !tx_budget_ok(now_ns) { let frame_len = (ctx.data_end() - ctx.data()) as u64; + // Intentional double-count: see the matching comment in + // `try_synack_v4` -- this SYN is tallied under + // REASON_SYNCOOKIE_TXCAPPED here AND again under REASON_PASS once + // `try_filter` falls through to `XDP_PASS` on this `Err(())`. count(REASON_SYNCOOKIE_TXCAPPED, frame_len); return Err(()); } From 0a3a61f0f951dd0a345e91e4e036836c3e9ec15e Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 15 Jul 2026 16:52:26 -0400 Subject: [PATCH 6/6] docs: changelog for XDP data-plane armed-only hardening (X1 per-CPU RATE + X3 SYN-cookie TX cap) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd13c5..9c67368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here, following - Speedtest now runs providers sequentially, measures each download over the full window, and reports the fastest clean provider — fixing wildly variable, under-reporting results on fast links. ### Fixed +- XDP data-plane armed-only hardening (AS214806 M1 follow-up; the two data-plane bugs from the arming re-triage). **X1 — race-free per-source rate limiter:** the `RATE` map was a CPU-shared `LruHashMap` whose token-bucket read-modify-write in `over_rate` was non-atomic, so under a multi-CPU flood (one source spraying spoofed source ports, RSS-spread across CPUs) the per-source pps limit leaked ~N×. A `bpf_spin_lock` was spiked first (the exact fix) but the verifier rejects it on this toolchain (aya-ebpf 0.1.1's `#[map]` emits legacy non-BTF maps, which `bpf_spin_lock` requires), so `RATE` is now an `LruPerCpuHashMap` — each CPU an isolated bucket, RMW race-free without a lock. Trade-off (documented): the effective per-source limit becomes up to ~`N_cpus ×` configured (looser, never tighter); userspace per-CPU summing for accurate per-source reporting is a follow-on. **X3 — SYN-cookie `XDP_TX` mint-rate cap:** the in-kernel SYN-cookie fast path minted a SipHash SYN-ACK per inbound SYN via `XDP_TX` with no ceiling — a gain-1 reflector at the attacker's rate. A global per-CPU token bucket (`TX_BUDGET`) now caps SYN-ACK mints (burst scaled to the configured rate, `min(2×rate, MAX)`); over budget the SYN takes its normal `XDP_PASS`/drop verdict and increments `blackwall_xdp_syn_cookies_txcapped_total`. A `syn-cookie-tx-cap=` `xdp` directive sets it with a conservative default (1000 pps, never unlimited), and the cap is seeded before the cookie port gate is armed so the fast path can never run unbounded. Both paths are dormant unless XDP mitigation / `cookie-ports` are configured (no M0 impact). Proven by `BPF_PROG_TEST_RUN` + veth lab gates. - M1 arming hardening (control plane) — closes the three arming-safety follow-ups from the M1 interlock. (**#194**) the boot-time rehydrate path (RTBH/FlowSpec/XDP) that re-announces persisted mitigations on an armed restart no longer strands an entry when the re-announce fails: it now KEEPS the entry and retries on the next tick (a `pending_reapply` self-heal mirroring the journal `pending_mirror`), converging once the session recovers — and, on the mutable-rate planes (FlowSpec/XDP), the retry RE-DERIVES the controller's current rule/action rather than replaying the value captured at queue time, so a stale retry can never revert a fresher successful update; surfaced as `blackwall_{rtbh,flowspec,xdp}_reapply_pending` gauges. (**#193 residuals**) the SIGUSR1 disarm logged its `DISARMED` banner twice (once before the withdrawal, once after) — de-duped to the single accurate post-withdrawal banner; and the disarm withdraw-error tolerance is now tested for FlowSpec + XDP, not just RTBH. ### Added