BPF and Spectre Hardening
eBPF is the single most exposed speculative-execution attack surface in the kernel: it invites userspace to upload a program that the kernel then runs in ring 0, with the program’s instructions and data partly attacker-controlled. The verifier proves the program is safe along its architectural execution paths — but a CPU executing speculatively (transiently, before a mispredicted branch or store is resolved) can run code paths the verifier never blessed, and the side effects on the cache can be measured to exfiltrate kernel memory. That is exactly the Spectre family of vulnerabilities, and because a BPF program is a precise, attacker-authored gadget generator, it is “an ideal tool for crafting Spectre attacks” — so the verifier and the per-architecture JIT carry dedicated mitigations. The two that matter most are Spectre v1 (bounds-check bypass, CVE-2017-5753), defended by either masking pointer arithmetic so a speculatively-out-of-bounds access is forced back in range, or — since Linux 6.17 — by inserting a speculation barrier (
BPF_NOSPEC, anLFENCEon x86-64) and abandoning the unsafe speculative path; and Spectre v4 (speculative store bypass, CVE-2018-3639), defended by inserting a barrier after risky stack writes. Spectre v2 (branch-target injection) is handled at the JIT level via retpolines/LFENCEon indirect jumps, with constant blinding andCONFIG_BPF_JIT_ALWAYS_ONas supporting hardening. This note pins the 6.12-era masking mechanism and the 6.17/6.18 barrier mechanism to source, fetched at both the 6.12 LTS and 6.18 LTS tags.
The verifier’s Spectre work is conditional on privilege: a fully trusted loader (one whose token or capabilities satisfy CAP_PERFMON) is allowed to skip the masking entirely, while an unprivileged-equivalent program receives the full treatment. This is the load-bearing reason unprivileged BPF is risky and is disabled by default — see Unprivileged BPF and Its Restrictions.
Mental Model — what a Spectre gadget looks like in BPF
The canonical Spectre v1 gadget is a bounds check followed by a dependent array access:
if (index < array_len) /* (A) architectural bounds check */
secret = array[index]; /* (B) load gated by (A) */
leak = probe[secret * 64]; /* (C) cache the secret bit */A modern CPU predicts the branch at (A) taken even for an out-of-bounds index, speculatively executes (B) reading kernel memory out of bounds, and (C) brings a secret-dependent cache line in. The branch misprediction is later squashed — (B) and (C) “never happened” architecturally — but the cache footprint of (C) survives and is timed to recover secret. In an uploaded BPF program the attacker controls index, array, and probe, so it can build this gadget at will. The verifier cannot simply reject it, because the architectural path is perfectly safe; it must make the speculative path safe too.
flowchart TB SRC["Attacker-authored BPF bytecode<br/>(bounds check + dependent load)"] VERIF["Verifier: explore architectural<br/>AND speculative paths"] D1{"Trusted?<br/>bpf_bypass_spec_v1(token)<br/>= CAP_PERFMON || mitigations=off"} SKIP["Skip masking — trust the loader"] V1["Spectre v1:<br/>6.12 -> mask ptr ALU (sanitize_ptr_alu)<br/>6.17+ -> insert BPF_NOSPEC, drop spec path"] V4["Spectre v4 (SSB):<br/>BPF_NOSPEC after risky stack write"] JIT["Per-arch JIT"] LF["BPF_NOSPEC -> LFENCE (x86)<br/>indirect jmp -> retpoline/LFENCE (v2)<br/>+ constant blinding"] SRC --> VERIF --> D1 D1 -- "yes" --> SKIP D1 -- "no" --> V1 --> JIT VERIF --> V4 --> JIT JIT --> LF
The BPF Spectre pipeline. What it shows: the verifier explores both real and speculative paths; if the loader is trusted (CAP_PERFMON or mitigations=off) it skips v1 masking, otherwise it applies pointer masking (6.12) or barrier insertion (6.17+) for v1 and barrier insertion for v4; the JIT then lowers the abstract BPF_NOSPEC instruction to a real LFENCE, hardens indirect jumps against v2, and blinds constants. The insight to take: the mitigations live at two layers — the verifier decides where a barrier or mask is needed by simulating speculation, and the JIT turns those abstract decisions into machine-specific fences. Neither alone is sufficient.
Spectre v1 (Bounds-Check Bypass) — the 6.12 masking mechanism
In Linux 6.12 LTS the primary v1 defense for pointer arithmetic is masking. When the verifier sees an ADD/SUB of a scalar to a pointer (sanitize_needed() returns true only for BPF_ADD/BPF_SUB), it routes through sanitize_ptr_alu() (verifier.c, v6.12). The idea: compute, at verification time, the maximum legal offset for the pointer’s region (retrieve_ptr_limit() — MAX_BPF_STACK for PTR_TO_STACK, map_ptr->value_size for PTR_TO_MAP_VALUE), store it as an alu_limit, and record in insn_aux_data that this instruction must be masked (alu_state, a bitfield of BPF_ALU_NEG_VALUE/BPF_ALU_IMMEDIATE/BPF_ALU_SANITIZE_SRC/BPF_ALU_SANITIZE_DST).
To find whether masking can be defeated, the verifier also simulates the speculative path: sanitize_speculative_path() calls push_stack(env, ..., speculative=true), marking the registers unknown, so the verifier explores what a mispredicting CPU could do after the masking truncates the offset. If that simulation finds an unsafe access the program is rejected; otherwise the masking is committed. State derived from a speculative simulation may “never prune a non-speculative execution one” (old->speculative && !cur->speculative blocks the prune) — the verifier keeps speculative and real states distinct.
The actual mask is injected at the end of verification by do_misc_fixups(). For a register-based ALU64 ADD/SUB X carrying alu_state, the verifier rewrites the single instruction into a sequence that computes a mask in the auxiliary register BPF_REG_AX and ANDs the offset with it (verifier.c do_misc_fixups, v6.12):
BPF_MOV32_IMM(BPF_REG_AX, alu_limit) ; AX = limit
BPF_ALU64_REG(BPF_SUB, BPF_REG_AX, off_reg) ; AX = limit - off
BPF_ALU64_REG(BPF_OR, BPF_REG_AX, off_reg) ; AX |= off
BPF_ALU64_IMM(BPF_NEG, BPF_REG_AX, 0) ; AX = -AX
BPF_ALU64_IMM(BPF_ARSH, BPF_REG_AX, 63) ; AX = sign-extend -> all-ones or all-zeros
BPF_ALU64_REG(BPF_AND, BPF_REG_AX, off_reg) ; off &= mask
Walked symbol-by-symbol: BPF_REG_AX is the verifier’s hidden scratch register; off_reg is the attacker’s offset. The sequence computes limit - off then ORs in off; the arithmetic-shift-right by 63 (BPF_ARSH ... 63) broadcasts the sign bit across all 64 bits, producing all-ones if the offset is within [0, limit] and all-zeros if it is out of range. ANDing off with that mask therefore leaves a legal offset untouched but zeroes an out-of-bounds offset — so even if the CPU speculatively bypasses the bounds branch, the pointer arithmetic it speculates on has already been forced back to a safe value. The isneg/isimm cases add a multiply-by--1 to normalize a negative offset and a simpler immediate-only path. This is a constant-time, branchless mask — it cannot itself be speculated around, because the masking is the arithmetic that produces the pointer.
A parallel, simpler form lives in map array lookups: arraymap.c computes an index_mask at map-creation time. The mask is (1ULL << fls_long(max_entries - 1)) - 1 — fls_long finds the position of the most-significant set bit, so this rounds the entry count up to the next power of two and subtracts one (the source computes it in u64 to dodge a 1U << 32 undefined-behavior case on 32-bit). When not bypassing v1, both the C fast path (array->value + elem_size * (index & array->index_mask)) and the JIT-inlined lookup (BPF_ALU32_IMM(BPF_AND, ret, array->index_mask)) AND the index with the mask. Because the map size is rounded to a power of two and the mask is size - 1, a speculatively-out-of-bounds index wraps back inside the array rather than reading past it — the textbook array[index & mask] defense.
Spectre v1 — the 6.17/6.18 barrier-fallback rework
The masking approach is precise but limited: it only handles ADD/SUB pointer arithmetic, and the original kernel behavior for other suspected-v1 patterns was simply to reject the unprivileged program (commit 9183671af6db). That rejected many benign programs. Linux 6.17 (the symbols error_recoverable_with_nospec/insn_aux->nospec are absent at the v6.15 and v6.16 tags and present at v6.17 — verified by fetching all three) introduced a more general mechanism: “the kernel still optimistically attempts to verify all speculative paths but uses speculation barriers against v1 when unsafe behavior is detected” (LWN, “bpf: Mitigate Spectre v1 using barriers”). This is the state of 6.18 LTS.
The mechanism, traced in verifier.c at v6.18: the main verification loop runs do_check_insn() per instruction. When that returns a recoverable error on a speculative path, the verifier no longer rejects the program — it plants a barrier instead:
err = do_check_insn(env, &do_print_state);
...
if (error_recoverable_with_nospec(err) && state->speculative) {
/* Prevent this speculative path from ever reaching the
* insn that would have been unsafe to execute. */
insn_aux->nospec = true;
insn_aux->alu_state = 0; /* drop any masking marking */
goto process_bpf_exit;
}error_recoverable_with_nospec() deems -EPERM, -EACCES, and -EINVAL recoverable (but not -ENOMEM, which would just recur). The logic is: if executing instruction I would be unsafe only under speculation, mark I’s aux data with nospec = true, stop exploring this speculative path (goto process_bpf_exit), and rely on a barrier to ensure the CPU never transiently reaches I. To bound complexity, when the verifier later re-enters a speculative path and hits an instruction already flagged nospec, it short-circuits immediately (if (state->speculative && insn_aux->nospec) goto process_bpf_exit;).
The barrier itself is patched in by do_misc_fixups(): for any instruction whose aux carries nospec, it prepends BPF_ST_NOSPEC() (verifier.c v6.18):
if (env->insn_aux_data[i + delta].nospec) {
*patch++ = BPF_ST_NOSPEC();
*patch++ = *insn; /* barrier comes *before* the guarded insn */
...
}So the abstract instruction stream becomes ... ; NOSPEC ; <guarded insn> ; .... The trade-off versus pure masking is exactly the classic precision-versus-coverage one: barriers cover any unsafe speculative pattern (not just ADD/SUB), letting more programs verify, but a barrier serializes the pipeline and so is more costly than a single AND when it sits in a hot loop. The authors measured “BPF program execution time” increasing “0% to 62%” for tracing/profiling workloads and a “14% slowdown in SCTP performance” for a network load balancer (LWN).
Uncertain
Verify: whether the older
sanitize_ptr_alu()pointer-masking path is retained, partially removed, or fully superseded by the barrier-fallback in 6.18. Reason: the v6.18 verifier.c still containssanitize_ptr_alu(line ~14306) and the newnospecbarrier logic, so both coexist, but the exact division of labor (which patterns get masked vs barriered) was not traced instruction-by-instruction at v6.18. To resolve: read the v6.18adjust_ptr_min_max_vals/sanitize_ptr_alu/do_check_insncall graph end to end. uncertain
Spectre v4 (Speculative Store Bypass) — barrier after risky stack writes
Spectre v4 / SSB (CVE-2018-3639) is the store-to-load case: the CPU may speculatively let a load bypass an older store to the same address (predicting no aliasing), reading a stale value. In BPF this matters when a stack slot is written and then re-read — a speculative load could see the previous occupant of that slot (potentially a leaked pointer). The verifier detects spillable writes and marks them: when !env->bypass_spec_v4 and a stack write spills a register, it sets env->insn_aux_data[insn_idx].sanitize_stack_spill = true (verifier.c, v6.12).
do_misc_fixups() then inserts a barrier after the marked store:
if (type == BPF_WRITE && env->insn_aux_data[...].sanitize_stack_spill) {
struct bpf_insn patch[] = { *insn, BPF_ST_NOSPEC() };
...
}So the stream becomes <store> ; NOSPEC, fencing the store before any dependent load can speculatively bypass it. (In 6.18 this same store-protection rides the more general nospec_result flag, which the source notes is “only used to mitigate Spectre v4” and is applied to write-ops that must not be skipped by a following load.) Note the difference from v1: v1 barriers go before the guarded instruction (stop reaching it speculatively), v4 barriers go after the store (stop a later load from bypassing it).
Uncertain
Verify: the historical SSB mitigation in older kernels was a zero-write to the stack slot (
BPF_ST_MEM(BPF_DW, BPF_REG_FP, off, 0)) as described in the Samsung KSPP study, but the 6.12 source usesBPF_ST_NOSPEC()(an LFENCE) instead. The KSPP write-up freezes the pre-barrier mechanism. Treat the LFENCE form as authoritative for 6.12/6.18 (read from source); the zero-write is the older approach. To resolve: bisect the commit that switched SSB from zero-write to barrier. uncertain
The BPF_NOSPEC instruction and JIT lowering
BPF_NOSPEC is an internal pseudo-instruction (opcode 0xc0, not exposed to userspace), emitted via the BPF_ST_NOSPEC() macro in include/linux/filter.h. Each architecture’s JIT lowers it to its own speculation barrier. On x86-64 (arch/x86/net/bpf_jit_comp.c, v6.12):
case BPF_ST | BPF_NOSPEC:
EMIT_LFENCE(); /* 0x0F 0xAE 0xE8 */
break;LFENCE is a load fence that, on affected Intel/AMD parts, halts speculative execution until prior instructions retire — Intel’s recommended barrier for both bounds-check-bypass and store-bypass. Other architectures handle the same BPF_NOSPEC opcode differently: on ARM64, the JIT’s BPF_ST | BPF_NOSPEC case emits nothing and instead “rel[ies] on the firmware mitigation of Speculative Store Bypass as controlled via the ssbd kernel parameter” (arch/arm64/net/bpf_jit_comp.c, v6.12) — i.e. the barrier is a no-op because the platform mitigation is applied globally rather than per-instruction. On an x86 CPU that is not vulnerable, the LFENCE is likewise effectively free or patched out.
Spectre v2 (Branch-Target Injection) and supporting JIT hardening
Spectre v2 poisons the indirect-branch predictor so a mispredicted indirect jump speculatively lands on attacker-chosen code. BPF’s indirect-jump surface is the tail call (a jump from one program into another via a prog-array map). The x86 JIT routes every indirect jump through emit_indirect_jump() (bpf_jit_comp.c, v6.12), which is CPU-feature-driven:
if (cpu_feature_enabled(X86_FEATURE_RETPOLINE_LFENCE)) {
EMIT_LFENCE(); /* lfence; jmp *%reg */
EMIT2(0xFF, 0xE0 + reg);
} else if (cpu_feature_enabled(X86_FEATURE_RETPOLINE)) {
emit_jump(&prog, &__x86_indirect_thunk_array[reg], ip); /* retpoline thunk */
} else {
EMIT2(0xFF, 0xE0 + reg); /* bare jmp *%reg + int3 trap */
}So depending on the mitigation the host selected, a BPF tail call becomes either an LFENCE-guarded indirect jump, a retpoline (a thunk that traps speculation in a benign infinite loop instead of a predicted target), or a bare jump on hardware that needs neither. Function returns similarly route through emit_return() using the kernel’s return-thunk when X86_FEATURE_RETHUNK is set. BPF reuses the same kernel-wide retpoline/return-thunk infrastructure rather than rolling its own.
Two supporting JIT hardening layers complete the picture. CONFIG_BPF_JIT_ALWAYS_ON (kernel/bpf/Kconfig, v6.12) “Enables BPF JIT and removes BPF interpreter to avoid speculative execution of BPF instructions by the interpreter” — the interpreter is a giant computed-goto dispatch loop that is itself a speculation hazard, so security-conscious configs compile it out and force bpf_jit_enable=1 permanently. (Relatedly, CONFIG_BPF_UNPRIV_DEFAULT_OFF defaults to y, setting unprivileged_bpf_disabled=2 so unprivileged loads are off by default — covered in Unprivileged BPF and Its Restrictions.) Constant blinding (bpf_jit_harden) randomizes the immediate bytes the JIT writes into executable memory; it is primarily a code-injection (JIT-spray) defense rather than a speculation defense, but it overlaps the Spectre conversation because both concern attacker influence over emitted machine code — its full mechanism lives in JIT Hardening and Constant Blinding and is not re-explained here.
The privilege gate — who gets mitigated
The entire v1/v4 verifier apparatus is conditioned on three env flags set at verification start (verifier.c, v6.12):
env->allow_ptr_leaks = bpf_allow_ptr_leaks(env->prog->aux->token);
env->bypass_spec_v1 = bpf_bypass_spec_v1(env->prog->aux->token);
env->bypass_spec_v4 = bpf_bypass_spec_v4(env->prog->aux->token);And, from include/linux/bpf.h, v6.12:
static inline bool bpf_bypass_spec_v1(const struct bpf_token *token)
{ return cpu_mitigations_off() || bpf_token_capable(token, CAP_PERFMON); }So a program loaded by a CAP_PERFMON-trusted principal (directly, or via a BPF token whose owning namespace grants CAP_PERFMON), or a system booted with mitigations=off, has bypass_spec_v1 == true and the masking/barrier insertion is skipped (can_skip_alu_sanitation() short-circuits, the array index_mask is set to all-ones). The reasoning: CAP_PERFMON already implies the ability to read arbitrary kernel memory via perf, so withholding it would be pointless. An unprivileged program gets bypass_spec_v1 == false and receives the full treatment. This is the precise mechanism behind the rule “unprivileged BPF is a Spectre liability” — the mitigations exist exactly for that case.
Failure Modes and Common Misunderstandings
- “The verifier rejected my program for no reason.” On pre-6.17 kernels a suspected-v1 pattern the masker couldn’t handle was simply rejected for unprivileged loaders; on 6.17+ the same program may now verify (a barrier is inserted) but run slower. If it still rejects, the speculative simulation found a genuinely unsafe access (
REASON_STACK/REASON_BOUNDSfromsanitize_ptr_alu). See Reading and Debugging Verifier Errors. - Assuming a privileged load is hardened. It is not —
CAP_PERFMON(ormitigations=off) disables the v1/v4 verifier mitigations. The masking and barriers protect against untrusted programs; a trusted program is assumed to be non-malicious and runs without the overhead. - Confusing the BPF layer with the syscall-entry layer. This note is about Spectre inside BPF programs. The Spectre hardening of the syscall dispatch path itself (masking the syscall number, VERW/MDS clearing, entry-time retpolines) is a separate concern — see Speculation Barriers and Spectre Hardening at the Syscall Boundary.
- Thinking constant blinding stops Spectre. It does not — blinding stops code injection (JIT spray); Spectre is a side channel that leaks data the program is already allowed to compute on. The two are orthogonal defenses that happen to share the JIT.
- Interpreter assumptions. On a hardened kernel the interpreter is gone (
BPF_JIT_ALWAYS_ON); code that assumes an interpreter fallback exists (e.g. on a JIT-unsupported instruction) will see a hard load failure instead.
Alternatives and Research Directions
- Reject-only (pre-barrier behavior). The simplest policy — refuse any program that might contain a v1 gadget. Safe but rejects many benign programs; superseded by masking and then barriers.
- Masking only (6.12). Precise and cheap for
ADD/SUBpointer arithmetic, but narrow — doesn’t cover all speculative-unsafe patterns. - Barriers (6.17+). General but coarser and costlier in hot paths; the current default direction. The optimistic “verify all speculative paths, barrier where unsafe” model is the LPC’24/RAID’24 work cited in LWN.
mitigations=off/ trusted loaders. Skip everything — appropriate only on hardware known unaffected or in fully trusted single-tenant deployments. Never for multi-tenant or unprivileged BPF.- Static analysis tools (VeriFence and similar). Academic work (e.g. VeriFence, RAID’24) aims for more precise Spectre defenses that barrier fewer instructions while preserving safety — the same precision-vs-coverage frontier the kernel itself is walking.
Production Notes
The practical guidance is blunt: keep unprivileged_bpf_disabled at its default (on), prefer tokens over re-enabling unprivileged BPF, and do not run with mitigations=off on shared hosts. The Spectre-class verifier bypasses are the principal reason the kernel community moved unprivileged BPF to off-by-default; multiple CVEs over 2018–2022 were verifier mispredictions that let speculation read kernel memory. The 6.17 barrier rework is a notable shift — it lets more legitimate unprivileged-equivalent programs load while staying safe, at a measured runtime cost (LWN).
Uncertain
Verify: the precise upstream release in which the “Mitigate Spectre v1 using barriers” series merged. Reason: the LWN article is the patch-submission (dated 2025-06-03, targeting bpf-next) and the
error_recoverable_with_nospecsymbol is present at the v6.17 tag and absent at v6.16, which strongly indicates a 6.17 merge — but the exact merge commit / release was inferred from tag-presence, not read from a changelog. To resolve: check the git log forerror_recoverable_with_nospec’s introducing commit and its first release tag. uncertain
See Also
- eBPF Verifier — the static analyzer whose speculative-path simulation drives every mitigation here
- Verifier Memory Safety and Pointer Types — the pointer-type tracking (
PTR_TO_STACK,PTR_TO_MAP_VALUE) that masking limits build on - Verifier Complexity Limits and State Pruning — why barriers short-circuit speculative-path exploration to bound work
- JIT Hardening and Constant Blinding — the orthogonal code-injection defense that overlaps the JIT
- Unprivileged BPF and Its Restrictions — why unprivileged BPF is a Spectre liability and is off by default
- BPF Token and Privilege Delegation — how a
CAP_PERFMON-bearing token relaxes (bypass_spec_v1) these mitigations - BPF Tail Calls — the indirect-jump surface hardened against Spectre v2 with retpolines/LFENCE
- CAP_BPF and BPF Privilege Model —
CAP_PERFMONand the trust level that gates mitigation - Speculation Barriers and Spectre Hardening at the Syscall Boundary — the sibling syscall-entry Spectre defenses (different layer)
- Linux eBPF MOC — parent map (§9, Security, Privilege, and the Unprivileged-BPF Story)