Speculation Barriers and Spectre Hardening at the Syscall Boundary
A system call is the moment a CPU running attacker-controlled userspace code transitions, in the space of one instruction, into the most privileged code in the system — with attacker-chosen values sitting in registers and an attacker-chosen number selecting which kernel function runs. That makes the syscall boundary the single richest target for transient-execution attacks: speculation that runs past a bounds check, past a privilege transition, or that leaks the contents of microarchitectural buffers filled by kernel work. Since the 2018 Spectre/Meltdown disclosures, the Linux entry path has been peppered with defenses that mostly cost nothing on unaffected hardware (they are
ALTERNATIVEno-ops patched in only when the CPU is vulnerable):array_index_nospec()masks the syscall number before it can index anything,barrier_nospec()(anLFENCE) fences off speculative loads, theswapgsfences stop the GS-base from being speculatively wrong,IBRS/retpoline/UNTRAIN_RET/CLEAR_BRANCH_HISTORYblunt indirect-branch and return prediction, and aVERWinstruction on the return path clears CPU buffers to defeat MDS. This note traces each of those at the x86-64 syscall entry/exit, as of Linux 6.12 LTS (arch/x86/entry/common.c, entry_64.S).
Mental Model
Speculative execution is the CPU guessing what comes next and running it before it knows whether the guess is right. If the guess is wrong, the architectural results are discarded — but the microarchitectural side effects (which cache lines got loaded, which buffers got read) persist, and a side-channel can read them back. Spectre-class attacks are about steering that speculation into a “gadget” that touches secret data, then recovering the secret from the cache footprint.
The syscall boundary concentrates three flavors of this:
- Bounds-check bypass (Spectre v1): the kernel checks
nr < NR_syscalls, but the CPU can speculate past the branch and use a too-largenrto index a table — reading out of bounds. The fix is to mask the index itself so that under misspeculation it is forced to a safe value, not just to add a branch the CPU can ignore. - Branch/return target injection (Spectre v2 and relatives — RETBleed, BHI, SRSO): the indirect calls and returns inside the kernel can be steered by attacker-poisoned predictors. The fix is to constrain or flush those predictors at the user→kernel transition.
- Microarchitectural data sampling (MDS, and the conditional-
swapgsv1 variant): speculation can sample stale data out of internal CPU buffers (store/fill/load-port buffers), or run kernel code with a user GS base. The fix is a fence aroundswapgsand a buffer-clearingVERWbefore returning to userspace.
flowchart TB US["Userspace<br/>(attacker-controlled regs, nr in RAX)"] US -->|"syscall instruction"| SG["swapgs<br/>(switch to kernel GS)"] subgraph ENTRY["entry_SYSCALL_64 (entry_64.S)"] SG --> SAVE["PUSH_AND_CLEAR_REGS<br/>build pt_regs, zero scratch regs"] SAVE --> IB["IBRS_ENTER<br/>(set SPEC_CTRL.IBRS)"] IB --> UR["UNTRAIN_RET<br/>(IBPB / return-thunk)"] UR --> BHB["CLEAR_BRANCH_HISTORY<br/>(BHB clear loop)"] BHB --> DO["call do_syscall_64"] end subgraph C["do_syscall_64 → do_syscall_x64 (common.c)"] DO --> CHK{"unr < NR_syscalls ?"} CHK -->|"no"| NI["__x64_sys_ni_syscall (-ENOSYS)"] CHK -->|"yes"| MASK["unr = array_index_nospec(unr, NR_syscalls)"] MASK --> DISP["x64_sys_call(regs, unr)<br/>dispatch handler"] end DISP --> EXIT["syscall return path"] EXIT --> VERW["CLEAR_CPU_BUFFERS (VERW)<br/>then swapgs; sysretq"] VERW --> US
The speculation hardening applied across one x86-64 syscall, entry to exit. What it shows: branch/return-predictor scrubbing (IBRS_ENTER, UNTRAIN_RET, CLEAR_BRANCH_HISTORY) happens immediately after the register frame is built; the syscall-number bounds check is followed by array_index_nospec() masking before any dispatch; buffer-clearing VERW happens last, right before returning. The insight: these are layered defenses against distinct attacks — masking for v1, predictor scrubbing for v2/RETBleed/BHI/SRSO, VERW for MDS — and on unaffected CPUs each is an ALTERNATIVE that patches to nothing, so the cost is paid only where the hardware needs it.
The Spectre-v1 Defense: Masking the Syscall Number
Why the number is a gadget
The classic Spectre v1 gadget is if (i < n) x = array[i];: a CPU that mispredicts the branch as taken will speculatively load array[i] for an out-of-bounds i, leaving a cache footprint that leaks array’s neighbours or whatever array[i] pointed at (spectre.rst; the original Spectre paper, Kocher et al. 2018). The syscall dispatch is exactly this shape: userspace puts an arbitrary value in RAX, the kernel checks nr < NR_syscalls, and on the fast path uses nr to reach a handler. A mispredicted bounds check would let nr run far out of range during speculation.
array_index_nospec — masking, not branching
The kernel’s tool for this is array_index_nospec() from include/linux/nospec.h. The crucial idea is that it does not add another branch (the CPU can speculate past branches); it computes a mask with straight-line arithmetic and ANDs it into the index, so that even under misspeculation the index is clamped:
/* include/linux/nospec.h */
static inline unsigned long array_index_mask_nospec(unsigned long index,
unsigned long size)
{
OPTIMIZER_HIDE_VAR(index); /* stop the compiler reasoning about index */
return ~(long)(index | (size - 1UL - index)) >> (BITS_PER_LONG - 1);
}
#define array_index_nospec(index, size) ({ ... (typeof(_i))(_i & _mask); })Walking the arithmetic symbol by symbol for the in-range case index < size: size - 1 - index is non-negative, and index is non-negative, so their bitwise OR has its top (sign) bit clear; casting to signed long and arithmetic-shifting right by BITS_PER_LONG - 1 (63 on a 64-bit machine) sign-extends that clear sign bit to all zeros, then ~ flips it to all ones — a mask of ~0. For the out-of-range case index >= size, size - 1 - index is negative (its top bit set), the OR’s sign bit is set, the arithmetic shift sign-extends to all ones, and ~ flips it to 0 — a zero mask. ANDing the index with ~0 leaves it unchanged when in range; ANDing with 0 forces it to 0 (a safe slot) when out of range. OPTIMIZER_HIDE_VAR() prevents the compiler from “helpfully” optimizing the mask away based on a value range it thinks it has proven — because the compiler reasons about architectural values, not the speculative ones the attacker controls (nospec.h).
Where it sits in the syscall path
On x86-64 in 6.12, the dispatch is no longer a single sys_call_table[nr] indexed load — arch/x86/entry/syscall_64.c turns it into a generated switch (x64_sys_call()), and the array sys_call_table[] is kept only so kernel/trace/trace_syscalls.c can resolve handler addresses (syscall_64.c). The masking happens in do_syscall_x64() before the dispatch:
/* arch/x86/entry/common.c */
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
unsigned int unr = nr; /* negative nr -> huge unsigned -> fails check */
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls); /* clamp under misspeculation */
regs->ax = x64_sys_call(regs, unr); /* dispatch */
return true;
}
return false;
}Treating nr as unsigned int is itself part of the defense: a negative syscall number becomes a huge unsigned value that fails < NR_syscalls, so one comparison covers both ends of the range. The array_index_nospec() line then guarantees that even if the CPU speculates the bounds check as passing for an out-of-range unr, the value handed to x64_sys_call() is masked to a safe in-range slot, so the speculative dispatch cannot reach an attacker-chosen address or read out-of-bounds metadata. The x32 and IA-32-emulation paths (do_syscall_x32, do_syscall_32_irqs_on) apply the identical pattern with their own table sizes (common.c).
Uncertain
Verify: the precise residual speculation risk of a jump-table-compiled
switch(x64_sys_call) versus the old array-indexedsys_call_table[nr]. Reason: the v1 concern classically targets an indexed load; the kernel still masksunrbefore theswitch, which suggests the maintainers consider a compiler-generated jump table to carry equivalent risk, but the comment insyscall_64.conly explains the table is retained for tracing, not the speculation rationale. To resolve: read the commit that converted x86-64 dispatch fromsys_call_table[]indexing to thex64_sys_call()switch and its changelog. uncertain
barrier_nospec — the heavier LFENCE hammer
Where masking the index is not enough — for example when the speculative load itself must be fenced rather than the index clamped — the kernel uses barrier_nospec(). On x86 it is an LFENCE patched in via ALTERNATIVE:
/* arch/x86/include/asm/barrier.h */
#define barrier_nospec() alternative("", "lfence", X86_FEATURE_LFENCE_RDTSC)LFENCE serializes the instruction stream so that loads after it cannot execute speculatively before it. It is heavier than array_index_nospec() (which is branchless arithmetic with no pipeline stall), so the kernel prefers masking and reserves barrier_nospec() for places like copy_from_user’s pointer checks and __get_user speculation fences. The generic stub in nospec.h (#define barrier_nospec() do {} while (0)) means architectures without a speculation problem compile it away entirely (nospec.h). See The access_ok Check and User Pointer Validation and copy_to_user and copy_from_user for the uaccess use of this fence.
The Conditional-swapgs Spectre-v1 Variant
The Spectre-v1 documentation explicitly calls out the entry code’s swapgs: the user→kernel transition conditionally executes swapgs to switch the GS base from the user value to the kernel per-CPU base, and a mispredicted condition could leave kernel code running speculatively with the wrong GS base, dereferencing a user-controlled per-CPU pointer (spectre.rst, “kernel entry code … conditional swapgs checks”). The defense is a pair of LFENCEs gated by CPU-feature ALTERNATIVEs:
/* arch/x86/entry/calling.h */
.macro FENCE_SWAPGS_USER_ENTRY /* in the path that DID swapgs from user */
ALTERNATIVE "", "lfence", X86_FEATURE_FENCE_SWAPGS_USER
.endm
.macro FENCE_SWAPGS_KERNEL_ENTRY /* in the path that did NOT swapgs (from kernel) */
ALTERNATIVE "", "lfence", X86_FEATURE_FENCE_SWAPGS_KERNEL
.endmThese appear in the interrupt/exception entry paths in entry_64.S (error_entry, paranoid_entry), not in the entry_SYSCALL_64 fast path — the syscall instruction’s transition is structured so the GS swap is unconditional there. But they are part of the same boundary-hardening story and are why the Spectre-v1 doc lists “conditional swapgs” as a v1 vector (entry_64.S, calling.h).
Branch- and Return-Predictor Scrubbing (Spectre v2, RETBleed, BHI, SRSO)
Spectre v2 and its descendants steer indirect branches and returns inside the kernel using predictors the attacker poisoned from userspace. The entry_SYSCALL_64 fast path scrubs these immediately after building the register frame:
; arch/x86/entry/entry_64.S, entry_SYSCALL_64
PUSH_AND_CLEAR_REGS rax=$-ENOSYS ; save user regs, zero scratch regs
movq %rsp, %rdi
movslq %eax, %rsi ; sign-extend syscall nr as int
IBRS_ENTER ; raise SPEC_CTRL.IBRS for kernel
UNTRAIN_RET ; flush return predictor (IBPB / return-thunk)
CLEAR_BRANCH_HISTORY ; clear Branch History Buffer
call do_syscall_64Each line maps to a distinct vulnerability:
IBRS_ENTERwrites the Indirect Branch Restricted Speculation bit into theIA32_SPEC_CTRLMSR so that, while in the kernel, indirect-branch predictions cannot be controlled by less-privileged code. It is gated onX86_FEATURE_KERNEL_IBRSandCONFIG_MITIGATION_IBRS_ENTRY; on retpoline-mitigated kernels it patches to nothing because retpolines (in the indirect-call thunks) handle v2 instead (calling.h).UNTRAIN_RETaddresses return-predictor attacks (RETBleed, CVE-2022-29900/29901, and AMD’s SRSO / “Inception”, CVE-2023-20569). It expands to an Indirect Branch Prediction Barrier (IBPB) or a return-thunk untraining sequence depending onX86_FEATURE_ENTRY_IBPB/ the SRSO config — overwriting poisoned return-stack-buffer state before the kernel executes anyRET(nospec-branch.h).CLEAR_BRANCH_HISTORYmitigates Branch History Injection (BHI, CVE-2022-0001), where the attacker poisons the Branch History Buffer to steer an indirect branch even under Enhanced IBRS. It patches in acall clear_bhb_loop(a sequence engineered to overwrite the BHB) underX86_FEATURE_CLEAR_BHB_LOOP; the Spectre doc notes BHI was first practical via unprivileged eBPF and that full mitigation needsBHI_DIS_Sor the clearing sequence (spectre.rst, nospec-branch.h).
On return, IBRS_EXIT clears the kernel IBRS bit, and FILL_RETURN_BUFFER (in __switch_to_asm) stuffs the Return Stack Buffer on context switch to stop cross-task RSB poisoning (entry_64.S). These are the v2-family analogues of the v1 number-masking: instead of clamping a value, they sanitize the predictor state that the boundary crossing exposes.
MDS Buffer Clearing on the Return Path (VERW)
Microarchitectural Data Sampling (MDS — RIDL/Fallout/ZombieLoad, CVEs 2018-12126/12127/12130 and 2019-11091) lets speculation sample stale data out of the store buffer, fill buffers, and load ports — buffers that kernel work fills with secret data (mds.rst). The mitigation reuses the otherwise-obsolete VERW instruction: affected CPUs received a microcode update that overloads the memory-operand form of VERW to also clear those buffers (nospec-branch.h).
In 6.12 the clearing happens on the return-to-user path, immediately before sysretq (and the IRET path’s swapgs), via the CLEAR_CPU_BUFFERS macro:
; entry_64.S, syscall return via SYSRET
swapgs
CLEAR_CPU_BUFFERS ; -> "verw mds_verw_sel(%rip)" under X86_FEATURE_CLEAR_CPU_BUF
sysretq/* arch/x86/include/asm/nospec-branch.h */
.macro CLEAR_CPU_BUFFERS
ALTERNATIVE "", "verw mds_verw_sel(%rip)", X86_FEATURE_CLEAR_CPU_BUF
.endmThe placement is deliberate: clearing buffers as the kernel leaves ensures no secret the kernel just touched lingers in a buffer that a sibling SMT thread or the returning userspace could sample. Only the memory-operand form of VERW clears buffers (the register form does not), which is why the operand is the static mds_verw_sel selector value in memory (nospec-branch.h). The whole thing is an ALTERNATIVE gated on X86_FEATURE_CLEAR_CPU_BUF, so an unaffected CPU runs a no-op.
Uncertain
Verify: that the historical
mds_user_clearstatic key (named in the task brief) has been replaced by theX86_FEATURE_CLEAR_CPU_BUFALTERNATIVE-patchedCLEAR_CPU_BUFFERSmacro, and that as of 6.12 the clear is on the return-to-user path rather than at entry. Reason: older write-ups and the brief refer tomds_user_clear; the 6.12 source uses the feature-bitALTERNATIVEform and placesCLEAR_CPU_BUFFERSbeforesysretq/IRET, which is the post-rework arrangement. To resolve: confirmed inentry_64.Sandnospec-branch.hat v6.12 (X86_FEATURE_CLEAR_CPU_BUF, nomds_user_clearstatic-branch in the entry path) — but the exact kernel version wheremds_user_clearwas converted to the feature bit should be pinned from the changelog. uncertain
The C helper mds_clear_cpu_buffers() (a plain verw on the kernel DS selector) still exists for the idle path (mds_idle_clear_cpu_buffers()), which clears buffers before entering deeper idle states where an SMT sibling might sample them (nospec-branch.h).
Why the Syscall Boundary Specifically
Three properties make this boundary the prime gadget site, all of which compound the per-call cost discussed in Why System Calls Are Expensive:
- Attacker controls the inputs. The syscall ABI (The x86-64 Syscall Calling Convention) hands userspace direct control of
RAX(the number that indexes The System Call Table) and six argument registers — precisely the “attacker-controlled input parameter” the Spectre doc says makes a gadget useful (spectre.rst). - The transition crosses a privilege and address-space boundary.
swapgs, the CR3 switch (KPTI), and the predictor state are all things that change at the boundary and can be speculatively wrong, which is unique to the entry/exit code. - It runs on every privileged request. Because every resource access funnels through it, a single gadget here is reachable from any process — and a mitigation here protects the entire kernel surface, which is why the entry code is where the most defenses are concentrated.
Failure Modes and Common Misunderstandings
- “
array_index_nospecis just a bounds check.” It is the opposite: the bounds check is the branch the CPU ignores under speculation;array_index_nospecis branchless masking that survives misspeculation. Adding moreifs does nothing. - “Mitigations always slow my syscalls.” Only on affected hardware. Every defense above is an
ALTERNATIVE/static-key/feature-bit construct that patches to a no-op when the booting CPU is not vulnerable; the cost is paid selectively. The exceptions are the always-present software constructs like retpoline thunks where built in. - “VERW at entry.” As of 6.12 the MDS buffer-clear (
CLEAR_CPU_BUFFERS) is on the return-to-user path, not entry — clearing what the kernel touched as it leaves. Putting it at entry would not protect data the kernel reads during the call. - Disabling mitigations.
mitigations=off(or per-bug knobs likenospectre_v1,mds=off,spectre_v2=off) patches these out. The/sys/devices/system/cpu/vulnerabilities/files report the live state per bug; trusting a benchmark run with mitigations off as representative of production is a classic mistake. - SMT makes some of this leak anyway. MDS and BTB/L1 attacks can cross hyperthreads; the kernel’s per-boundary clearing helps, but the strongest mitigation for cross-sibling leakage is disabling SMT (
nosmt), which the MDS doc discusses (mds.rst).
Alternatives and When to Choose Them
array_index_nospec()vs.barrier_nospec()— prefer masking (branchless, no pipeline stall) when you can clamp an index; use theLFENCEofbarrier_nospec()only when the load itself must be serialized.- Retpoline vs. IBRS for Spectre v2 — retpolines (software, the indirect-call thunks) avoid the MSR-write cost of IBRS on older parts; eIBRS (enhanced IBRS in hardware) is preferred on CPUs that support it. The kernel selects automatically and patches the entry
IBRS_ENTERaccordingly. VERWclearing vs. disabling SMT — buffer-clearing handles same-thread sampling cheaply; onlynosmtfully closes cross-sibling MDS, at a large throughput cost.
Production Notes
The cumulative effect of these defenses is the dominant reason a bare getpid() got measurably slower across the 2018–2023 vulnerability cycle, and a major motivation for batching the boundary entirely with io_uring and serving hot reads from the vDSO without trapping at all. Operators tuning latency-sensitive, fully-trusted workloads sometimes set mitigations=off, accepting the security trade-off for the syscall-path speedup; the per-bug state is always auditable under /sys/devices/system/cpu/vulnerabilities/. The kernel’s hw-vuln admin guide is the authoritative, per-CPU reference for which mitigations apply (hw-vuln index).
See Also
- Why System Calls Are Expensive — the per-call cost these mitigations add to, and the motivation for batching
- The System Call Table — the table whose indexing
array_index_nospecprotects - System Call Numbers and the ABI — the attacker-controlled number that is the v1 gadget input
- The x86-64 Syscall Calling Convention — the registers userspace controls at the boundary
- Per-Architecture Syscall Entry Assembly —
entry_SYSCALL_64where the predictor scrubbing andVERWlive - The access_ok Check and User Pointer Validation / copy_to_user and copy_from_user — where
barrier_nospec()fences user-pointer speculation - The Generic Syscall Entry and Exit Layer — the C entry/exit code (
do_syscall_64) holding the v1 masking - Linux System Call Interface MOC — parent MOC (§4, Crossing Pointers Safely)
- Linux Security MOC — the broader CPU-vulnerability mitigation landscape