eBPF rootkits & forensics: from blinding telemetry to memory detection

Abstract

eBPF are an amazing feature of the Linux kernel but also a dangerous one! Often overlooked, eBPF make it possible to create malicious programs that can hide themselves. At the end of the article, I propose a memory analysis method that exploits the structure of eBPF rootkits to detect them.

In this article we are going to see:

  • how eBPF work
  • the theoretical architecture of an eBPF malware
  • blocking telemetry through the use of an eBPF rootkit
  • forensics: finding our rootkit in a memory dump

I wish you a great read!

eBPF

Introducing eBPF

eBPF are a feature that was added to the Linux kernel in 2014 and whose implementation is still evolving today. The revolutionary idea behind this system is to allow changing the kernel’s behavior through an interface other than the one we used historically: driver development. And on top of that eBPF have a gorgeous logo ;)

image.png

More concretely, we are going to install hooks in the Linux kernel in order to observe and modify certain behaviors. For this we can use what is called a hook. A hook is simply the modification of a program to divert its flow toward a secondary function.

There are many types of hook in eBPF, notably:

kprobes: kernel probes, lets you hook almost any instruction in the Linux kernel. There is however a blacklist of addresses. For this we use register_kprobe() and unregister_*probes().

Return probes: lets you hook the return of a function by replacing its return address with what the docs call a “trampoline”.

Tracepoint: static instrumentation points in the kernel, inserted ahead of time by the kernel developers. Much less costly in terms of performance.

The cgroup hook: attaches an eBPF to a cgroup (v2): the program then runs for all the processes of that group. This makes it possible to observe or filter behaviors at the granularity of a container or a service rather than for the whole system. For example on network packets (cgroup/skb), socket creation (cgroup/sock), connect/bind addresses (cgroup/sockaddr), sysctl or device access (cgroup/device).

Note: To manage our eBPF, a dedicated syscall was added to the Linux kernel: the eBPF syscall.

image.png

eBPF hook ( gotta have a laugh )

Examples of eBPF usage

Many observability projects have built on eBPF technology to strengthen the security of our systems: we can mention Cilium and Falco in particular. Like Sysmon for Linux, they observe what happens in the kernel in order to detect suspicious behaviors.

image.png

Using eBPF for observability, ref : https://hub.docker.com/r/cilium/docker-plugin

One of the great advantages of this system is that it fits very well into modern containerization architectures, from docker to kubernetes. Indeed, since the kernel is shared between the containers, monitoring the host amounts to monitoring all the guests.

eBPF are also used to modify the kernel’s behaviors. For example Katran, developed by Meta, is a very telling example: it is a high-performance L4 load-balancer based on XDP/eBPF, capable of processing packets very early in the network path. In the same family, LoxiLB offers a cloud-native load-balancer based on Go and eBPF.

XDP eBPF

To keep going with the obscure names, let’s take an introductory detour to talk about XDP eBPF. XDP are a type of eBPF that we will use further down and that specifically let you work on the network layer.

Very powerful, they can even be loaded directly onto the network card. This is ideal for implementing load-balancing or firewall tools.

The problem

Oh yes! It is the return of the big bad guy ( introduced in the previous article: https://nobisd.fr/posts/dfir-graphes-threat-hunting/ ). Obviously, if our eBPF can be used to directly modify the kernel, the bad guys can do it too. It is a flexible and relatively discreet way to persist and hide one’s processes, malicious communications, etc…

This class of malware lodged in the kernel is what we call a rootkit. The threat scenario is that of an attacker who has managed to gain root on the machine and who now wants to hide. Access to the kernel often serves to mask the rest of their malicious activity.

Anatomy of an eBPF rootkit

Here a theoretical presentation of how a minimal eBPF rootkit is structured.

image.png

As presented above, our rootkit will have to be structured in two parts: one in user space ( user-space ) and another in kernel space ( kernel-space ).

User space

Client process

Our rootkit will need two important elements: a process that will communicate with the eBPF loaded in the kernel, and the maps which are a space shared with our eBPF programs.

The client program could be driven remotely by an attacker ( through a command and control channel ) or could simply have the goal of loading the eBPF into the kernel and giving them orders. Indeed, to load our malicious eBPF we will have to go through the eBPF syscall so that the kernel checks our eBPF in order to avoid them posing risks of crashing the system. If they pass the tests they will be loaded into the eBPF virtual machine and attached to a kprobe. To be able to load an eBPF a program will already have to be running as root.

The maps

Maps are a shared memory space that allows data to be exchanged between a user space program and eBPF. There are different types of map depending on the data you are trying to exchange but ring buffers generally cover the client-server cases.

We distinguish two ways of using maps: pinned-map or not-pinned. If the map is attached ( pinned :D ), it has a file descriptor open in /sys/fs/bpf/ which can make the rootkit more visible. However, if the map is not attached, as soon as the client eBPF or the program that loaded them is unloaded, the map disappears. If the rootkit developer wants to favor stability, they have every interest in keeping their maps attached.

image.png

Pinned map

Kernel space

This is where everything happens, by inserting eBPF at strategic places the rootkit will be able to hide the malicious processes, hide its files and its network connections. By exchanging with the client through the maps, each eBPF takes its instructions to know what to hide, what to modify, etc…

We can mention a few important kprobes ( also present in the diagram above ):

__x64_sys_bpf : this is the function executed by the bpf syscall, here we can modify its behavior to hide the malicious eBPF

sys_getdents : function executed to list the files of a directory (and the processes); diverting it would allow hiding the malicious maps or files, as well as the client process

commit_creds : in charge of managing the permissions of processes, it could be diverted to run all the attacker’s processes as root

tcp4_seq_show : could allow hiding the attacker’s tcp connections

Discovering new kernel symbols

To find new hookable symbols and list the ones of the kernel we can directly read as root the file /proc/kallsyms .

Ex :

theo@theo-rootkit:~$ sudo cat /proc/kallsyms | grep bpf | head
000000000002bac0 A bpf_raw_tp_nest_level
000000000002bae0 A bpf_raw_tp_regs
000000000002bd40 A bpf_misc_sds
000000000002c040 A bpf_pt_regs
000000000002c238 A bpf_event_output_nest_level
000000000002c23c A bpf_trace_nest_level
000000000002c240 A bpf_trace_sds
000000000002c550 A bpf_user_rnd_state
000000000002c560 A __bpf_map_cookie
000000000002c570 A bpf_prog_active 

Defending against eBPF rootkits

This is where things get complicated. As revolutionary as this feature is, it raises a certain number of problems.

Comparison with Windows

On Windows, reaching kernel execution is much more complicated: you have to go through a signed driver, most often by exploiting a legitimate but vulnerable driver (the BYOVD technique, Bring Your Own Vulnerable Driver). Not impossible, but really not obvious. On top of that, with protections like HVCI / VBS (code integrity guaranteed by the hypervisor) and DSE (mandatory driver signing), even a loaded driver does not have the same freedom in kernel space. In short, Linux exposes a very powerful kernel extension surface, whose security depends heavily on the configuration: capabilities, lockdown, LSM, unprivileged BPF, Secure Boot, bpftool visibility, etc.

The kernel-lockdown

A protection makes it possible to prevent loading eBPF into the kernel: kernel lockdown. Available in two modes: integrity or confidentiality, this feature is disabled by default. In integrity mode, the kernel prevents loading eBPF that modify the kernel’s behavior and unsigned drivers. In confidentiality mode, the kernel additionally restricts the eBPF capable of reading kernel memory (typically those attached to kprobes through the read helpers), in order to avoid any information leak from the kernel.

In confidentiality, what is left then is tracepoint type eBPF (for observability only) and XDP ( the network part ).

We can check the current mode like this:

cat /sys/kernel/security/lockdown
[none] integrity confidentiality

Above the small [] tell me that I am in none mode, without lockdown.

Note: most major distributions enable kernel lockdown in integrity when secure-boot is enabled in the UEFI. So it is a widespread feature.

The attacker’s workaround: disabling remote telemetry

As we saw earlier, despite kernel lockdown we should be able to load network type eBPF: the XDP, but also the programs attached to a cgroup (cgroup_skb), which are not covered by either of the two lockdown modes. It is this last type that we are going to use here. An attacker could use it for example to prevent the system from sending its logs to the central server, or to introduce a bit of entropy into them.

To test this model we are going to deploy a small range with ludus ( see my article on the subject https://nobisd.fr/posts/ludus-proxmox/ ). We will simply have a victim machine with falco deployed on it that sends its logs to a central elk server.

Let’s start by enabling kernel-lockdown in integrity:

sudo vim /etc/default/grub
# add this line GRUB_CMDLINE_LINUX_DEFAULT="quiet lockdown=integrity"
# Then 
sudo update-grub
sudo reboot

We then install falco and enable the service. Falco is a very powerful tool to get visibility on what is happening on a Linux system. We can compare it to auditd, of which it is a more modern version. Falco can run in several modes, including in eBPF to get its view on the kernel.

Here we can see that falco is indeed running in eBPF:

sudo systemctl cat falco | grep engine
ExecStart=/usr/bin/falco -o engine.kind=modern_ebpf

If we perform malicious actions for example cat /etc/passwd we see that we do receive the logs in our SIEM:

image.png

Loading a dedicated eBPF rootkit

This time we use a small program written especially for this and available here in the post’s lab: https://github.com/theophane-droid/dfir-ebpf-rootkit/.

Apart from the logic of exchanging data with the map, the bpf program handles blocking the flows here:

static __always_inline int handle_packet(struct __sk_buff *skb, __u8 direction)
{
				...
        if (!rule_matches(rule, dport, direction))
            continue;

        dropped = should_drop_flow(rule,
                                   ip.saddr,
                                   ip.daddr,
                                   sport,
                                   dport,
                                   ip.protocol,
                                   direction);

        emit_event(skb, &ip, sport, dport, idx, direction, rule, dropped);

        return dropped ? 0 : 1;
    }

    return 1;
}
SEC("cgroup_skb/ingress")
int on_ingress(struct __sk_buff *skb)
{
    return handle_packet(skb, DROP_DIR_INGRESS);
}

SEC("cgroup_skb/egress")
int on_egress(struct __sk_buff *skb)
{
    return handle_packet(skb, DROP_DIR_EGRESS);
}

In the loader (client) we will do this to attach the ebpf to the ingress egress of a specific cgroup. Here we use the default cgroup in order to only impact the host’s processes.

 skel->links.on_ingress = bpf_program__attach_cgroup(
        skel->progs.on_ingress,
        cgroup_fd
    );
skel->links.on_ingress = bpf_program__attach_cgroup(
        skel->progs.on_ingress,
        cgroup_fd
  );

We can run it to blind falco:

./loader
listening on cgroup: /sys/fs/cgroup
stats interval: 5s
configured drop rules:
  rule=0 port=50000 direction=out drop=100%
stats:
  rule=0 port=50000 direction=out configured_drop=100% matched=0 dropped=0 passed=0 observed_drop=0.0%
stats:
  rule=0 port=50000 direction=out configured_drop=100% matched=6 dropped=6 passed=0 observed_drop=100.0%

We can see here that falco’s outgoing connections are indeed blocked and our SIEM console no longer receives logs. Blocking the entire flow is deliberately crude here, for demonstration purposes. A mature SIEM could be alarmed by the sudden stop of the logs; we will see below how to refine this. And yet, kernel-lockdown is indeed enabled! We can now delete the falco logs if we want to do log tampering.

rm -rf /var/log/falco/* 

What this shows about kernel locking

As we saw in this section, the kernel may well have lockdown enabled. There are still ways to blind the telemetry. Since falco starts a web server that lets you check whether it is still alive, the central server can still think that the machine is working normally.

We could even go further: rather than blocking all the flows, we could filter the body of the messages and block everything that concerns alerts and let the rest of the content through. This is where eBPF can show themselves smarter at doing blinding than a filtering rule that would block the telemetry.

Forensics

Pretty neat! We have been able to see how an attacker could use eBPF to their advantage to blind us. How could we detect such an attack?

For eBPF rootkits we can make the following table about the reliability of the investigation:

ApproachReliable if kernel compromised?Interest
bpftool liveNoQuick triage
/sys/fs/bpfNoEasy artifacts
Falco/TetragonPartialDetection before blinding
AVML from the OSMediumPractical if lockdown allows
LiMEMediumPowerful but heavy
Hypervisor dumpYesBest choice lab/cloud
Volatility + exact symbolsYes if dump reliablePost-mortem analysis

So if we suspect a kernel compromise, the ideal is to take a memory dump from the hypervisor.

Taking the dump and generating the symbols

The dump is taken directly from the Proxmox hypervisor, without running anything on the potentially compromised machine (the lab runs on Proxmox, deployed with Ludus, see my dedicated article):

qm monitor <vmid>
qm> dump-guest-memory /var/lib/vz/dump/mem-<vmid>.elf
qm> quit

Volatility needs symbols matching exactly the kernel of the dump. So we first get its version:

strings memory.dump | grep -i "Linux version"

We then download the corresponding Debian dbgsym packages, then we generate the symbols file (ISF) with dwarf2json:

dwarf2json linux \
  --elf ./vmlinux-6.1.0-49-amd64 \
  --system-map ./System.map-6.1.0-49-amd64 \
  > linux-6.1.0-49-amd64.json

mkdir -p symbols/linux
mv linux-6.1.0-49-amd64.json symbols/linux/

All that is left is to point Volatility (and volshell) at this symbols folder, and run the analysis.

Detection strategy

As shown above, an ebpf rootkit is made up of many bricks between the kernel and user space. Rather than going to analyze each of the bricks, to know whether it is malicious, I explore here the strategy of spotting the links between each brick as shown here:

image.png

Since the client processes have to keep a map open and the eBPF too, this relationship can be exploited to know which process uses which map and so in the end which eBPF it is talking to.

Detection client - map

Indeed, user processes keep file descriptors open toward the maps they use even if these are not pinned. These file descriptors are visible in memory and can be explored with volatility 3 for example. Using the lsof function provided by the volatility API, we are going to be able to explore all the open files and, based on their type, select those that concern eBPF maps.

Detection eBPF - map

To find the links between our eBPF and the maps, we are going to be able to use the volatility module named linux.ebpf which lists all the eBPF loaded in the kernel. For each program loaded in memory, the kernel maintains a list of the open maps in a table. This table is found by traversing the two successive structures: prog->aux->used_maps .

Linking the two

Once we know the clients and their relations with maps, and the maps and their relation with eBPF, we can now trace the relation between the user programs and the eBPF. This will help us say whether the eBPF is legitimate or not, based on the program that uses it.

Automation

To automate this, I developed a script available on this lab’s repository here: https://github.com/theophane-droid/dfir-ebpf-rootkit/. We can simply use it like this:

volshell  -f mem-dump -s ./symbols/ -l --script find_evil_ebpf.py 
Volshell (Volatility 3 Framework) 2.28.0                                                                                                                                                                             
Readline imported successfully  Stacking attempts finished                                                
Running code from file:///home/theo/memory/analysis_script.py                                             
                                                                                                          
======================================================================                                    
 Table 1 : file descriptors pointing to eBPF maps                                                         
======================================================================                                    
[*] 17 fds pointing to eBPF maps found.                                                                   
                                                                                                          
    PID     TID  COMM               FD   MAP_ID  TYPE             NAME             ADDRESS                
------------------------------------------------------------------------------------------                
    407     407  falco              77       36  PROG_ARRAY       custom_sys_exit  0x88ce0691ba00         
    407     407  falco              78       37  PROG_ARRAY       syscall_exit_ta  0x88ce0333e000         
    407     407  falco              79       38  PROG_ARRAY       syscall_exit_ex  0x88ce0691b400         
    407     407  falco              80       39  ARRAY            interesting_sys  0x88ce0333a000         
    407     407  falco              81       40  ARRAY            capture_setting  0x88ce0691bc00         
    407     407  falco              82       41  ARRAY            iter_auxiliary_  0xcf02c4245000                                                                                                                    
    407     407  falco              83       42  ARRAY            iter_counters_m  0x88ce0691b200                                                                                                                    
    407     407  falco              84       43  ARRAY            auxiliary_maps   0xcf02c4267000         
    407     407  falco              85       44  ARRAY            counter_maps     0x88ce0283d800         
    407     407  falco              86       45  ARRAY_OF_MAPS    ringbuf_maps     0x88ce0691a000         
    407     407  falco              87       46  PROG_ARRAY       extra_sched_pro  0x88ce0691b800         
    407     407  falco              88       47  ARRAY            bpf_prob.rodata  0xcf02c42a9ef0         
    407     407  falco              89       48  ARRAY            bpf_prob.bss     0xcf02c4359ef0         
    407     407  falco              90       49  ARRAY            bpf_prob.data    0xcf02c435cef0         
    693     693  loader              4       51  RINGBUF          events           0x88ce0124f800
    693     693  loader              5       52  ARRAY            rule_count       0x88ce0124e600
    693     693  loader              6       53  ARRAY            drop_rules       0x88ce0ae22400

======================================================================
 Table 2 : eBPF programs and the maps they use
======================================================================
[*] 918 program/map associations found.

PROG_ID  PROG_NAME        PROG_TYPE          MAP_ID  MAP_TYPE         MAP_NAME
------------------------------------------------------------------------------------------
      3  sd_devices       CGROUP_DEVICE           -  -                
      4  sd_fw_egress     CGROUP_SKB              -  -                
      5  sd_fw_ingress    CGROUP_SKB              -  -                
      6  sd_fw_egress     CGROUP_SKB              -  -                
      7  sd_fw_ingress    CGROUP_SKB              -  -                
      8  sd_devices       CGROUP_DEVICE           -  -                
      ...
    237  on_ingress       CGROUP_SKB             53  ARRAY            drop_rules
    237  on_ingress       CGROUP_SKB             51  RINGBUF          events
    238  on_egress        CGROUP_SKB             52  ARRAY            rule_count
    238  on_egress        CGROUP_SKB             53  ARRAY            drop_rules
    238  on_egress        CGROUP_SKB             51  RINGBUF          events
    
 ======================================================================                                                                                                                                               
 Table 3 : user processes linked to eBPF programs via shared maps                                                                                                                                                    
======================================================================                                                                                                                                               
[*] 909 process <-> program links via shared maps.                                                                                                                                                                   
                                                                                                                                                                                                                     
    PID COMM              PROG_ID PROG_NAME        MAP_ID MAP_TYPE         SHARED_MAP_NAME                                                                                                                           
-----------------------------------------------------------------------------------------------                                                                                                                      
    407 falco                  36 sys_exit             36 PROG_ARRAY       custom_sys_exit                                                                                                                           
    407 falco                  36 sys_exit             37 PROG_ARRAY       syscall_exit_ta                                                                                                                           
    407 falco                 138 open_by_handle_      38 PROG_ARRAY       syscall_exit_ex                                                                                                                           
    407 falco                 207 vfork_x              38 PROG_ARRAY       syscall_exit_ex                                                                                                                           
 ...
    693 loader                237 on_ingress           51 RINGBUF          events
    693 loader                238 on_egress            51 RINGBUF          events
    693 loader                237 on_ingress           52 ARRAY            rule_count
    693 loader                238 on_egress            52 ARRAY            rule_count
    693 loader                237 on_ingress           53 ARRAY            drop_rules
    693 loader                238 on_egress            53 ARRAY            drop_rules

Above, we can see that it was possible to:

  • table 1: list all the programs that have an eBPF map open and each map used by an eBPF. This way, we were able to identify two user programs that use the eBPF: falco (pid 407) and loader (pid 693).
  • table 2: list all the maps held by eBPF programs
  • table 3: make the link between the user processes and the eBPF programs through their map

We can then investigate further on the two processes falco and loader, to find out in what context they were launched. This analysis would show that loader is indeed malicious and that all the eBPF that this program uses are therefore malicious.

Limit of this approach

It is entirely possible to imagine eBPF rootkits that would not use maps to communicate with a user program. Such an eBPF could be injected with parameters dynamically chosen according to the needs of the malicious operator.

Conclusion

In this article, we have been able to show that despite the kernel protections that can be put in place, the threat of eBPF rootkits is not extinguished. From this observation, we have been able to show a method to trace the link between user processes and eBPF to make the difference between a legitimate program and a rootkit.

I hope you enjoyed this article, I am exploring this format which aims to be halfway between the view of what an attacker can do and that of a forensic analyst. If you liked it do not hesitate to follow me and to give me feedback on linkedin: https://linkedin.com/in/theophane-dumas.

See you soon on nobisd for more forensic articles!


References :