DIY Linux Kernel Rootkit Detection

Today I will teach you how to roll your own detection of a classic Linux kernel rootkit using shell scripting (for explanation) and osquery (for production).

Your chance of encountering a Linux kernel rootkit in the wild is slim due to compatibility and distribution challenges, so this explanation is geared toward the most paranoid folks in the audience. For this demonstration, we'll use reveng_rtkit – one of the more modern examples of a Linux rootkit.

As of February 2023, this reveng_rtkit runs well on Debian, so I used lima with limactl start template://debian to create my test environment.

How does one detect a rootkit anyways?

The trick to detecting a rootkit is to find cracks in the illusion they present. Most rootkits only hide their presence when probed with a specific syscall, but Linux has hundreds of syscalls to choose from. Most commonly, Linux rootkits will hide processes or directory entries by overriding getdents(2), but neglect stat(2). Some rootkits hide file contents by overriding read(2), but forget about mmap(2).

Collecting Evidence

Collecting the state of a system before and after a malware installation is an easy way to determine what attributes to alert on.

If you are only concerned about filesystem changes, many methods are available, such as Sleuthkit or hashdeep. Fewer choices exist if you are concerned about the larger overall system state.

Since I work on the [osquery-detection-kit,]((https://github.com/chainguard-dev/osquery-defense-kit project) I’ll use it to collect a subset of system information to a file. osquery-detection-kit does require extremely recent versions of Go and osquery to be installed, so here's how I installed the dependencies:

arch=$(uname -m | sed -e s/x86_64/amd64/g -e s/aarch64/arm64/g)
curl -L https://go.dev/dl/go1.20.1.linux-${arch}.tar.gz | sudo tar -C /usr/local -zxvf -
curl -LO https://pkg.osquery.io/deb/osquery_5.7.0-1.linux_${arch}.deb
sudo dpkg -i osquery_5.7.0-1.linux_${arch}.deb
export PATH=/usr/local/go/bin:$PATH

Here's how I collected the data:

git clone https://github.com/chainguard-dev/osquery-defense-kit
cd osquery-defense-kit
git pull
make collection

This will populate a subfolder named out/ with evidence, so that we may compare against it later.

Installing The Rootkit

I followed the instructions on https://github.com/reveng007/reveng_rtkit to install the rootkit. Here's how I loaded it:

sudo insmod reveng_rtkit.ko

Afterward, sudo dmesg shows:

[  382.584069] [+] reveng_rtkit: Created by @reveng007(Soumyanil)
[  382.584070] [+] reveng_rtkit: Loaded
[  382.584071] [*] reveng_rtkit: Hiding our rootkit LKM from `lsmod` cmd, `/proc/modules` file path and `/proc/kallsyms` file path

Finding System State Differences

Now I'm able to collect system state once again and diff the two:

cd $HOME/osquery-defense-kit
make collect
diff -ubR ./out/<old> ./out/<new>

The first thing that stood out was that one of the sysctl's changed:

-config_value: current_value:0 name:kernel.tainted oid: subsystem:kernel type:string
+config_value: current_value:12288 name:kernel.tainted oid: subsystem:kernel type:string

The rootkit uses kill -31 to hide processes, so I used kill -31 $$ to hide my shell process, ran the collection, and ... found no further differences. This means I'm going to have to improve our detection scripts.

For testing, I also started a sleep 7200 & process in the background from the hidden shell. This rootkit also hid this subprocess – not all are so consistent.

Detecting hidden pids

Kernel rootkits tend to do two things with varying levels of success: hide their kernel module, and hide a process ID. revenge_rtkit does this by hiding getdents() calls to /proc, but if you know the hidden process ID, you can stat it directly. Ironically, this is also how Linux hides lightweight threads from users.

For most of Linux's life, the maximum number of pids on a system was 32768, a relatively small area to stat. Most modern Linux distros have bumped this number up, so this process is going to be slow. To check your system's maximum pid number, run cat /proc/sys/kernel/pid_max.

Here's a shell script that iterates through all possible pid numbers, revealing any that were not found when listing /proc: [full source]

[[ $EUID != 0 ]] && echo "* WARNING: For accurate output, run $0 as uid 0"

declare -A visible
cd /proc || exit

start=$(date +%s)
for pid in *; do
    visible[$pid]=1
done

for i in $(seq 2 "$(cat /proc/sys/kernel/pid_max)"); do
    [[ ${visible[$i]} = 1 ]] && continue
    [[ ! -e /proc/$i/status ]] && continue
    [[ $(stat -c %Z /proc/$i) -ge $start ]] && continue

    #  pid is a kernel thread
    [[ $(awk '/Tgid/{ print $2 }' "/proc/${i}/status") != "${i}" ]] && continue

    exe=$(readlink "/proc/$i/exe")
    cmdline=$(tr '\000' ' ' <"/proc/$i/cmdline")
    echo "- hidden $(cat /proc/$i/comm)[${i}] is running ${exe}: ${cmdline}"
done

Here's the output of this script:

- hidden bash[1924] is running /usr/bin/bash: /bin/bash --login
- hidden sleep[18518] is running /usr/bin/sleep: sleep 7200

To use roughly the same logic with osquery, use the following within sudo osqueryi [full source]:

WITH RECURSIVE cnt(x) AS (
   SELECT 1
   UNION ALL
   SELECT x + 1
   FROM cnt
   LIMIT 4194304
)
SELECT p.*
FROM cnt
   JOIN processes p ON x = p.pid
WHERE x NOT IN (
       SELECT pid
       FROM processes
)
AND p.start_time < (strftime('%s', 'now') - 1)
AND (
       p.pgroup = p.pid
       OR (
           p.pid = p.parent
           AND p.threads = 1
       )
)

Detecting unusual kernel taints

Earlier we talked about the value of sysctl kernel.tainted changing from 0 to 12288. Let's use the following script to diagnose it [full source]:

declare -A table=(
    [0]="proprietary module was loaded"
    [1]="module was force loaded"
    [2]="kernel running on an out of specification system"
    [3]="module was force unloaded"
    [4]="processor reported a Machine Check Exception (MCE)"
    [5]="bad page referenced or some unexpected page flags"
    [6]="taint requested by userspace application"
    [7]="kernel died recently, i.e. there was an OOPS or BUG"
    [8]="ACPI table overridden by user"
    [9]="kernel issued warning"
    [10]="staging driver was loaded"
    [11]="workaround for bug in platform firmware applied"
    [12]="externally-built (out-of-tree) module was loaded"
    [13]="unsigned module was loaded"
    [14]="soft lockup occurred"
    [15]="kernel has been live patched"
    [16]="auxiliary taint, defined for and used by distros"
    [17]="kernel was built with the struct randomization plugin"
    [18]="an in-kernel test has been run"
)


taint=$(cat /proc/sys/kernel/tainted)
[[ $taint == 0 ]] && exit

echo "kernel taint value: ${taint}"
for i in $(seq 18); do
    bit=$(($i-1))
    match=$(($taint >> $bit &1))
    [[ $match == 0 ]] && continue
    echo "* matches bit $bit: ${table[$bit]}"
done

echo ""
echo "dmesg:"
dmesg | grep taint

Here's the output of that script when this rootkit is loaded:

kernel taint value: 12288
* matches bit 12: externally-built (out-of-tree) module was loaded
* matches bit 13: unsigned module was loaded

dmesg:
[  368.765518] reveng_rtkit: loading out-of-tree module taints kernel.
[  368.777600] reveng_rtkit: module verification failed: signature and/or required key missing - tainting kernel

Here's the osquery query I generated for alerting on this kind of taint [full source]:

SELECT current_value AS value,
    current_value & 65536 AS is_aux,
    current_value & 8192 is_unsigned,
    current_value & 4096 AS out_of_tree,
    current_value & 512 AS kernel_warning,
    current_value & 614 AS requested_by_userspace,
    current_value & 8 AS force_unloaded,
    current_value & 4 AS out_of_spec,
    current_value & 2 AS force_loaded,
    current_value & 1 AS proprietary
FROM system_controls
WHERE name = "kernel.tainted"
    AND current_value NOT IN (0, 512, 12289, 4097)

Detecting unusual /dev entries

We noted an unusual device earlier, which is used to communicate to the rootkit:

crw-------  1 root root 247,   0 Feb 23 15:53 etx_device

The “247” in the output there is the major device number, which generally maps to a kernel module. To find unexpected devices like this, you can either whitelist expected device names, or expected major numbers. We'll do it both ways here.

To use major number logic, you'll want to refer to https://www.kernel.org/doc/Documentation/admin-guide/devices.txt. Armed with this information, there are two data sources to check, /proc/devices, and /dev. The first one is interesting, as it's a map of major numbers to drivers:

Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
…
189 usb_device
226 drm
247 etx_Dev
248 aux

One major flaw in my plan is that there are sections of dynamically generated major numbers, for instance, our suspicious 247 major device lands squarely in this section:

240-254 block LOCAL/EXPERIMENTAL USE

So, I went with a hybrid approach to discover devices that are commonly found on UNIX systems, by major number when possible, and the device name when it's dynamic [full source]:


declare -A expected_major=(
    [1]="memory"
    [2]="pty master"
    [3]="pty slave"
    [4]="tty"
    [5]="alt tty"
    [6]="parallel"
    [7]="vcs"
    # [8]="scsi tape"
    [9]="md"
    [10]="misc"
    [13]="input"
    [21]="scsi"
    [29]="fb"
...
)

declare -A expected_low=(
    ["bsg/"]=1
    ["dma_heap/system"]=1
...
)

declare -A expected_high=(
    ["drm_dp_aux"]=1
    ["iiodevice"]=1
)

for path in $(find /dev -type c); do
    hex=$(stat -c '%t' $path)
    major=$(( 16#${hex} ))
    pattern=$(echo $path | cut -d/ -f3- | tr -d '[:0-9]')

    # Unix98 PTY Slaves
    (( major >= 136 && major <= 143 )) && continue
    [[ ${expected_major[$major]} != "" ]] && continue

    class="UNKNOWN"
    (( major >= 60 && major <= 63 )) && class="LOCAL/EXPERIMENTAL"
    (( major >= 120 && major <= 127 )) && class="LOCAL/EXPERIMENTAL"
    if (( major >= 234 && major <= 254 )); then
        class="low dynamic"
        [[ ${expected_low[$pattern]} == 1 ]] && continue
    fi

    if (( major >= 384 && major <= 511 )); then
        class="high dynamic"
        [[ ${expected_high[$pattern]} == 1 ]] && continue
    fi

    echo "${class} major device ${pattern}[${major}]"
    echo "* $(ls -lad $path)"
    echo "* /proc/devices: $(sed -n '/Block devices:/q;p' /proc/devices | grep -e "^ *${major}")"
    echo ""
done

Here is the output of this script on a system with reveng_rtkit installed:

low dynamic major device etx_device[247]
* crw------- 1 root root 247, 0 Mar  3 19:48 /dev/etx_device
* /proc/devices: 247 etx_Dev

In osquery, there is no reliable way to determine a major number, as it depends on a local magic database. There we'll rely on a simple whitelist of device names [full source]:

SELECT *
FROM
 file
WHERE
 (
   path LIKE '/dev/%'
   OR directory LIKE '/dev/%'
 )
 AND path_expr NOT IN (
   '/dev/acpi_thermal_rel',
   '/dev/autofs',
   '/dev/block/',
   '/dev/block/:',
   '/dev/bsg/',
   '/dev/bsg/:::',
…
)

In Closing

In a future episode, we'll explore what it takes to detect an eBPF rootkit. The general philosophy is the same: know what to expect from your system and detect half-hearted illusions. I hope you enjoyed this article!

If you have questions, find me at @tstromberg.