Kernel Privilege Escalation via eBPF Verifier Bypass and Defensive Telemetry

Article Hero

Technical Overview & Threat Model The extended Berkeley Packet Filter (eBPF) has evolved from an in-kernel packet inspection engine into a general-purpose execution environment inside the Linux kernel. By allowing userspace applications to attach sandboxed bytecode to kernel tracepoints, network interfaces, and security hooks (LSM), eBPF powers high-performance observability, networking, and security tooling. However, executing user-supplied bytecode inside ring 0 creates an acute security boundary: a single flaw in memory safety can compromise the entire operating system.

To mitigate this risk without incurring the overhead of virtualization or page table isolation, the Linux kernel relies on an internal static analyzer: the eBPF Verifier (kernel/bpf/verifier.c). The verifier mathematically analyzes all possible execution paths before any bytecode is passed to the Just-In-Time (JIT) compiler. It enforces strict invariants: programs must terminate, loops must be provably bounded, register types must match their operations, and memory dereferences must remain within allocated buffer boundaries.

The threat model centers on an unprivileged local user, a compromised service account, or an escape-oriented container workload. If an adversary identifies a logical inconsistency within the verifier's abstract interpretation engine, they can trick the verifier into believing a program is safe while generating instructions that execute an out-of-bounds (OOB) read or write at runtime. When combined with legacy configurations where unprivileged users can invoke the bpf() syscall (kernel.unprivileged_bpf_disabled = 0), verifier desynchronization flaws yield reliable local privilege escalation (LPE) directly to root (uid=0).

sequenceDiagram

    autonumber

    actor Attacker as Unprivileged Local User

    participant Syscall as bpf() Syscall Handler

    participant Verifier as Kernel Verifier (Abstract Interpretation)

    participant JIT as Kernel JIT Compiler

    participant Ring0 as Kernel Memory (struct cred)

    Note over Attacker,Syscall: Step 1: Program Submission

    Attacker->>Syscall: bpf(BPF_PROG_LOAD, insns, map_fds)

    Syscall->>Verifier: Handover raw bytecode for safety simulation

    Note over Verifier: Step 2: Abstract Interpretation & Bounds Tracking

    Verifier->>Verifier: Simulate registers (bpf_reg_state)

    Note over Verifier: Flaw: Verifier concludes Register R2 is constant 0
Branch pruning drops alternate state evaluation Verifier->>JIT: Certification Passed: Program marked SAFE Note over JIT,Ring0: Step 3: Native Compilation & Execution JIT->>Ring0: Compile bytecode to native x86_64 instructions Attacker->>Syscall: Trigger eBPF program execution via socket/tracepoint Note over Ring0: Runtime: Register R2 evaluates to non-zero offset
Calculates pointer outside allocated eBPF array map buffer Ring0->>Ring0: Out-of-Bounds Write: Corrupt task_struct->real_cred (UID 0) Note over Attacker: Step 4: Shell Spawned with Root Privileges Attack Anatomy & Verifier Logic Flaws The core vulnerability in eBPF privilege escalation lies in range tracking desynchronization. In kernel/bpf/verifier.c, the verifier tracks the state of each virtual register ($R_0$ through $R_{10}$) using struct bpf_reg_state. For scalar values (untyped integers), the verifier maintains two parallel representations of uncertainty:
  1. Numeric Interval Bounds: Both 64-bit and 32-bit (ALU32) intervals tracking minimum and maximum signed and unsigned values: smin_value, smax_value, umin_value, umax_value, s32_min_value, s32_max_value, u32_min_value, and u32_max_value.
  2. Tristate Numbers (tnums): A bitwise mask representing which bits are known zeros, known ones, or unknown (var_off).
flowchart TD

    A[Raw eBPF Bytecode Ingestion] --> B[Verifier: Depth-First CFG Traversal]

    B --> C{State Pruning Check}

    C -->|Equivalent State Found| D[Prune Branch: Assume Safe]

    C -->|New State| E[Evaluate ALU / Bitwise Instruction]

    E --> F[Update bpf_reg_state Bounds: smin/smax & umin/umax & tnum]

    F --> G{Bounds Desynchronization Bug?}

    G -->|Yes: Math / ALU32 / Truncation Flaw| H[Verifier Believes Register is Bound to Safe Range]

    G -->|No: Correct Tracking| I[Verifier Enforces Strict Array Map Bounds]

    H --> J[JIT Compiles Bytecode to Native Machine Code]

    J --> K[Hardware Execution: Register Holds Unexpected Value]

    K --> L[OOB Map Buffer Pointer Arithmetic]

    L --> M[Arbitrary Kernel Read / Write Primitive]

    M --> N[Overwrite Current Task Credentials -> Root]
The Mechanism of Bounds Desynchronization
When an eBPF program executes an ALU or bitwise operation, the verifier must update both the interval bounds and the tnum. If the verifier's mathematical logic for updating these bounds contains an error—such as incorrect sign extension, flawed 32-bit sub-register truncation, or imprecise bitwise logic—the verifier's simulated bounds diverge from the actual value calculated by the hardware CPU.

Consider a simplified representation of an ALU32 tracking flaw (such as CVE-2021-3490):

  • An eBPF program initializes register $R_1$ with an unknown 64-bit scalar loaded from an eBPF array map.
  • A conditional jump constrains $R_1$ to a known range (e.g., $1 \le R_1 \le 10$).
  • The program executes a 32-bit bitwise operation (e.g., BPF_ALU32_REG(BPF_AND, R1, R2)).
  • The verifier updates u32_min_value and u32_max_value, but fails to correctly propagate the updated bounds to the full 64-bit bounds (umin_value, umax_value).
  • Consequently, the verifier assumes that $R_1$ is guaranteed to be $0$ within a specific conditional branch. Because the verifier believes $R_1 == 0$, it allows $R_1$ to be added to an eBPF map pointer as an offset: offset = R1 * 8. The verifier computes max_offset = 0, verifying that the dereference does not exceed the map's boundary.
  • At runtime, the CPU executes the JIT-compiled native assembly where $R_1 e 0$. The pointer calculation accesses memory beyond the bounds of the eBPF array map buffer. Historic Verifier Vulnerability Comparison The table below contrasts key historical verifier flaws that illustrate this pattern across kernel releases:

Vulnerability Linux Kernel Range Subsystem / Function Root Cause Mechanism Architectural Impact CVE-2020-8835 5.5.0 – 5.5.11 adjust_reg_min_max_vals() 32-bit to 64-bit sub-register truncation logic error in __reg_bound_offset() Arbitrary kernel read/write via OOB array map access; local root escalation CVE-2021-3490 5.7.0 – 5.12.4 adjust_scalar_min_max_vals() Improper bounds propagation for 32-bit bitwise AND/OR/XOR operations Out-of-bounds memory access; kernel heap corruption and credential overwrite CVE-2023-2163 5.4.0 – 6.2.0 check_cond_jmp_op() Flawed register precision tracking during branch path pruning Incorrect assumption of register safety; bypass of runtime safety checks Escalation from OOB Map Access to Kernel Arbitrary Write Once an adversary achieves an out-of-bounds pointer calculation on an eBPF map:

  1. Locating Kernel Objects: An adversary allocates multiple eBPF array maps sequentially in kernel memory. By reading past the end of one map buffer, the program can inspect adjacent kernel heap objects, leak pointers to the kernel code segment (defeating KASLR), and locate the bpf_map structure's function pointers (bpf_map_ops).
  2. Arbitrary Primitive Construction: By overwriting the ops pointer of an eBPF map with a forged table or manipulating memory descriptor structures, the program turns relative out-of-bounds access into an arbitrary read and write primitive across all virtual memory.
  3. Privilege Escalation (struct cred): The adversary iterates through the kernel's active task list or reads the current task_struct address via leaked kernel structures. Inside task_struct, the real_cred and cred pointers reference the process security context:
struct cred {
    atomic_t usage;
    kuid_t   uid;    /* 0 for root */
    kgid_t   gid;    /* 0 for root */
    kuid_t   suid;
    kgid_t   sgid;
    kuid_t   euid;
    ...
    kernel_cap_t cap_inheritable;
    kernel_cap_t cap_permitted;
    kernel_cap_t cap_effective; /* Full 64-bit capability bitmask */
};

Overwriting uid, gid, euid, and cap_effective with zeroes and full capability bitmasks instantly promotes the calling unprivileged process to root with CAP_SYS_ADMIN. Defensive Hardening & Runtime Telemetry Securing enterprise Linux environments against eBPF verifier bypasses requires a defense-in-depth posture: restricting userspace exposure to the BPF subsystem, hardening the JIT compiler, and instrumenting kernel telemetry to detect abnormal execution.

  1. Host Kernel Hardening & Sysctl Configuration The most effective protection against unprivileged verifier exploitation is entirely disabling unprivileged access to the bpf() syscall. When kernel.unprivileged_bpf_disabled is set to 2, unprivileged eBPF execution is disabled permanently until the next system reboot, preventing runtime tampering even if administrative privileges are momentarily acquired.

The following script applies recommended production sysctl hardening across enterprise Linux systems:

#!/usr/bin/env bash
## Fast Cyber Defense - Linux Kernel eBPF Subsystem Hardening Runbook
## Applies persistent sysctl directives to eliminate unprivileged eBPF attack surfaces.
set -euo pipefail
SYSCTL_HARDENING_FILE="/etc/sysctl.d/99-ebpf-hardening.conf"
echo "[*] Configuring eBPF kernel parameters..."
cat << 'SETTINGS' > "${SYSCTL_HARDENING_FILE}"
## Disable unprivileged eBPF program loading and permanently lock the setting
kernel.unprivileged_bpf_disabled = 2
## Enable JIT compiler constant blinding to mitigate JIT spraying attacks
net.core.bpf_jit_harden = 2
## Restrict kernel pointer exposure in /proc/kallsyms to root only
kernel.kptr_restrict = 2
## Restrict access to performance monitoring and perf events
kernel.perf_event_paranoid = 3
SETTINGS
## Apply the parameters immediately to running kernel
sysctl -p "${SYSCTL_HARDENING_FILE}"
## Verify active settings
echo "[+] Active eBPF Hardening Status:"
sysctl kernel.unprivileged_bpf_disabled net.core.bpf_jit_harden kernel.kptr_restrict kernel.perf_event_paranoid
2. Container Security & Seccomp Syscall Filtering
In containerized environments (Kubernetes, Docker, Podman), containers must not possess the ability to invoke the bpf() syscall (syscall 321 on x86_64). Container runtimes should enforce a default seccomp profile that drops CAP_SYS_ADMIN and CAP_BPF, and blocks the bpf syscall entirely.
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "architectures": [
    "SCMP_ARCH_X86_64",
    "SCMP_ARCH_AARCH64"
  ],
  "syscalls": [
    {
      "names": [
        "bpf",
        "perf_event_open",
        "process_vm_readv",
        "process_vm_writev"
      ],
      "action": "SCMP_ACT_ERRNO",
      "args": []
    }
  ]
}
3. Detection Engineering: Linux Auditd & Sigma Rules
Security monitoring systems should track any invocation of the bpf() syscall by unexpected users or binaries, as well as tracking binary loads via bpftool.
Below is a production-grade Sigma rule designed to detect attempts to load eBPF programs or manipulate eBPF maps by non-root users or abnormal parent processes:
```yaml
title: Suspicious eBPF Program or Map Load by Unprivileged User
id: a82df789-94cf-4d92-b432-841f3e792c31
status: production
description: |
  Detects invocation of the bpf() syscall (sys_enter_bpf) or bpftool execution
  originating from non-root user accounts or unexpected interactive binaries.
  This activity frequently precedes kernel privilege escalation via verifier bypass.
references:
  - https://www.thezdi.com/blog/2020/4/8/cve-2020-8835-linux-kernel-privilege-escalation-via-improper-ebpf-program-verification
  - https://nvd.nist.gov/vuln/detail/cve-2021-3490
  - https://docs.kernel.org/bpf/verifier.html
logsource:
  product: linux
  service: auditd
detection:
  selection_syscall:
    syscall:
      - 'bpf'
      - '321'
    success: 'yes'
  filter_root:
    auid: '0'
  filter_system_daemons:
    exe:
      - '/usr/sbin/cilium-agent'
      - '/usr/bin/systemd'
      - '/usr/sbin/falco'
      - '/usr/bin/bpftool'
  condition: selection_syscall and not 1 of filter_*
fields:
  - pid
  - exe
  - auid
  - comm
  - syscall
falsepositives:
  - Custom observability agents running under dedicated unprivileged system users with CAP_BPF
level: high
tags:
  - attack.privilege_escalation
  - attack.t1068
  - attack.defense_evasion
4. Continuous eBPF Telemetry Inspection Script
Security engineers can audit running eBPF programs and map allocations using a Python monitoring utility interfacing with bpftool and /proc:
```python
#!/usr/bin/env python3
## Fast Cyber Defense - eBPF Program & Map Security Auditor
## Queries running eBPF programs and flags unprivileged or unverified binaries.
import json
import subprocess
import sys
from typing import Dict, List, Optional
class eBPFAuditor:
    def __init__(self):
        self.bpftool_path = "/usr/sbin/bpftool"
    def verify_kernel_parameters(self) -> Dict[str, str]:
        params = [
            "kernel.unprivileged_bpf_disabled",
            "net.core.bpf_jit_harden",
            "kernel.kptr_restrict"
        ]
        results = {}
        for param in params:
            try:
                out = subprocess.check_output(["sysctl", "-n", param], text=True).strip()
                results[param] = out
            except Exception:
                results[param] = "UNKNOWN"
        return results
    def get_loaded_programs(self) -> List[Dict]:
        try:
            output = subprocess.check_output(
                [self.bpftool_path, "prog", "show", "--json"],
                stderr=subprocess.DEVNULL,
                text=True
            )
            return json.loads(output)
        except (subprocess.CalledProcessError, FileNotFoundError):
            return []
    def audit(self) -> None:
        print("[*] Running eBPF Subsystem Security Audit...")
        params = self.verify_kernel_parameters()
        # Check unprivileged bpf parameter
        unpriv = params.get("kernel.unprivileged_bpf_disabled", "0")
        if unpriv == "2":
            print("[+] PASS: kernel.unprivileged_bpf_disabled is permanently locked (value=2).")
        elif unpriv == "1":
            print("[!] WARN: kernel.unprivileged_bpf_disabled is enabled (value=1), but can be reset by root.")
        else:
            print("[-] CRITICAL: kernel.unprivileged_bpf_disabled is DISABLED (value=0). Unprivileged users can load eBPF!")
        jit_harden = params.get("net.core.bpf_jit_harden", "0")
        if jit_harden == "2":
            print("[+] PASS: JIT constant blinding is enforced for all programs (value=2).")
        else:
            print(f"[-] WARN: JIT constant blinding is not fully enforced (value={jit_harden}).")
        progs = self.get_loaded_programs()
        print(f"[*] Total loaded eBPF programs in kernel: {len(progs)}")
        for p in progs:
            prog_id = p.get("id")
            prog_type = p.get("type", "unknown")
            prog_name = p.get("name", "unnamed")
            tag = p.get("tag", "unknown")
            print(f"    - Program ID {prog_id}: Type={prog_type} | Name={prog_name} | Tag={tag}")
if __name__ == "__main__":
    auditor = eBPFAuditor()
    auditor.audit()
Fast Cyber Defense Key Takeaways
* Static Verification is Imperfect: The eBPF verifier is an abstract interpretation engine attempting to solve undecidable program properties. Mathematical range tracking desynchronization in kernel/bpf/verifier.c remains a recurring source of local privilege escalation.
* Lock Unprivileged BPF Permanently: Setting kernel.unprivileged_bpf_disabled = 2 locks the configuration until reboot. This single sysctl eliminates unprivileged attack paths on multi-tenant and shared Linux hosts.
* Enforce JIT Constant Blinding: Setting net.core.bpf_jit_harden = 2 ensures constant values in eBPF bytecode are masked with randomized keys, preventing attackers from synthesizing executable shellcode inside JIT memory buffers.
* Drop Syscall Capabilities in Containers: Container workloads rarely require bpf() syscall access. Prevent container breakout vectors by denying CAP_BPF and filtering __NR_bpf via default Seccomp profiles.
* Monitor Syscall Activity via Auditd: Instrument Linux Auditd rules on syscall 321 (sys_enter_bpf) to establish visibility over unexpected processes loading eBPF bytecode.
References & Further Reading
* Linux Kernel Documentation - The eBPF Verifier
* NIST NVD - CVE-2021-3490 Detail (eBPF ALU32 Bounds Tracking)
* Zero Day Initiative - CVE-2020-8835: Linux Kernel Privilege Escalation via Improper eBPF Program Verification
* Google Bug Hunters - A Deep Dive into CVE-2023-2163 eBPF Kernel Vulnerability
* SigmaHQ Rule Repository - Linux Auditd eBPF Syscall Activity

Comments