In this two part blog post we will take a deeper look at eBPF and some of its known vulnerabilities. After a quick introduction to eBPF, how it and its ecosystem works, common attacks, we will talk about how automation and fuzzing can help you to harden your eBPF applications.
eBPF is a Linux kernel subsystem that was built as a refactor of classic BPF (Berkeley Packet Filter), which was originally created to filter packets in the kernel. Now, modern eBPF serves as a technology that allows user mode applications to run code in the kernel without needing to load a kernel module. eBPF programs can be used to do all types of things, such as tracing, instrumentation, hooking, system calls, debugging, capturing or filtering packets. Of course, in recent times, we’ve also seen it used to build rootkits.
One of the many advantages of eBPF is that developers can write programs without extensive kernel development knowledge. This is much easier than compiling and maintaining a custom modified kernel. It also lends itself to user mode applications that adopt an asynchronous programming style.
eBPF programs can be written in high-level languages such as Java, Python, Go, Rust, or C. Using a toolchain for your preferred programming language, the programs are compiled into eBPF bytecode and benefit from significant performance benefits by running directly in the kernel.
The user-mode application loads the bytecode into the kernel through a system call. The eBPF verifier then checks the bytecode. If it passes the checks, the bytecode is either Just-In-Time (JIT) compiled into the native instruction set for the architecture or executed by the interpreter, depending on the kernel configuration. Next, the application attaches the loaded eBPF program to a hook point. Since eBPF uses event-based execution, the program runs when a related event triggers it at the hook point. The application can interact with the program using input and output through eBPF maps and helper functions.
Whether unprivileged users can run eBPF programs depends on a sysctl setting called “unprivileged_bpf_disabled.” Unprivileged users are restricted in terms of which eBPF hooks they can attach to, usually they are only allowed to hook to a socket they own. Additionally, there is a kernel configuration called “CONFIG_BPF_UNPRIV_DEFAULT” that can set the sysctl setting during runtime. Popular distributions like Ubuntu usually restrict unprivileged users by default.
CAP_BPF is another Linux capability that can be granted to users or containers to run eBPF programs. Users with CAP_BPF capabilities face fewer restrictions on the programs they can load.
eBPF includes a verifier that checks the bytecode before it is loaded. The eBPF verifier ensures that a program is safe to run in the kernel, making it arguably the most critical component for security. Bugs or undefined behavior in the verifier can determine whether unsafe programs are allowed to run and what consequences follow. Along with the JIT compiler and the eBPF runtime, the verifier plays a pivotal role in this process.
In the following section, we will explore examples of vulnerabilities affecting eBPF and its components.
The safety of the eBPF program is determined in two main steps. The first step is a DAG check to disallow loops and other CFG (Code flow graph) validation with a certain set of rules.
One of the things that the verifier does is keeping track of the expected values of scalars registered and those that are actually used by an eBPF program. It imposes the restriction that only scalars can be added to pointers that access the memory of shared maps. The verifier has to ensure that these scalar registers fall within the expected range and do not lead to out-of-bounds memory accesses. Errors in how these ranges are calculated do allow for manipulation of the verifier’s checks and then to kernel memory corruption.
An example of this kind of vulnerability is CVE-2020-27194. It is caused by the miscalculation of 32-bit ranges, which are derived by 64-bit registers, as you can see demonstrated in the following code snippet:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#define TARGET_ADDR 0xdeadbeef
#define PAYLOAD_SIZE 1024
void exploit() {
// Allocate memory with mmap
void *addr = mmap((void *)TARGET_ADDR, PAYLOAD_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_FIXED | MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (addr == MAP_FAILED) {
perror("mmap");
exit(EXIT_FAILURE);
}
// Prepare payload
char payload[PAYLOAD_SIZE];
memset(payload, 0x90, PAYLOAD_SIZE); // NOP sled
// Add shellcode or other malicious code here
strcpy(payload + PAYLOAD_SIZE - 16, "\x48\x31\xc0\xb0\x69\x0f\x05"); // Example shellcode
// Copy payload to target address
memcpy(addr, payload, PAYLOAD_SIZE);
// Trigger the vulnerability
// This part is highly dependent on the specific vulnerability and kernel version
// For illustration, we assume a function that miscalculates a 32-bit register
asm volatile (
"mov $0xFFFFFFFF, %eax\n"
"add $1, %eax\n" // This will cause an overflow if not handled correctly
"jmp *%0\n"
:
: "r"(TARGET_ADDR)
: "eax"
);
}
int main() {
exploit();
return 0;
}
Another type of vulnerability the verifier is prone to is branch prediction bugs. The verifier examines all potential code paths (CFG check) of a loaded program to ensure runtime behaviour adheres to its safety constraints. It removes instructions that will never be executed and skips analysing branches it deems already seen or technically safe. This approach is for performance reasons, as analysing every single code path can become exponentially complex. Bugs in the verifier’s branch prediction mechanisms can allow manipulation of the program state in ways the verifier does not expect. An example of this type of bug is CVE-2023-2163.
The JIT compiler converts eBPF bytecode to native assembly and one class of bugs that can occur are therefore code generation bugs. One specific type of bug in the JIT compiler involves the translation of legacy BPF. CVE-2021-44543 is an example of a bug.
As eBPF still supports classic BPF programs, which have a slightly different instruction set, translating them to modern eBPF in the JIT compiler can lead to issues.
Another example of vulnerabilities involves optimization during code generation, which can lead to branching miscalculations. Such miscalculation of branch displacements, depending on the architecture, can cause eBPF programs to branch to unexpected locations. This violates the verifier’s assumptions about program state made during static analysis, allowing attackers to manipulate the program in ways the verifier initially deemed impossible at runtime.
Lastly, vulnerabilities can occur in the eBPF runtime component. eBPF includes helper functions that interact with the eBPF program during runtime, running in the unsandboxed part of the kernel. An example for this is CVE-2021-41864, which involves an integer overflow and out-of-bound writes in the hashmap lookup function. This can be triggered when an application creates an eBPF hashmap and calls a helper function within the eBPF program to retrieve a stored value. A significant portion of the eBPF runtime code’s attack surface involves operating on shared maps and retrieving or storing their values.
eBPF is an impressive technology, but no solution is flawless. There are trade-offs when using eBPF, particularly from a security product standpoint. Therefore, it’s crucial to understand the risks and mitigation techniques before implementing eBPF-based security solutions.
Continuous security audits and enhancements in LSM hooks are essential to mitigate these risks and ensure the safe execution of eBPF programs within the kernel.
In the second part we will take a look at hardening eBPF, fuzzing and the role of security orchestration.