This is a module-loading lab, not a rootkit. Target is a throwaway VM (kernel 5.4, unsigned modules allowed because Secure Boot is off on this box — I note that as a finding). Goal: insmod a toy LKM that registers a kprobe on do_sys_openat2 and prints the filename to dmesg, then rmmod. The module does not hide files, does not unlink itself from lsmod, does not patch the syscall table, and does not touch /etc/passwd. Hooking as a concept is discussed against this log-only probe.

1Figure 1. Legitimate observability and a rootkit share insmod. The difference is what the module does next.
2insmod logopen.ko -> kprobe do_sys_openat2 -> dmesg file=
3rmmod -> lsmod empty; kprobes/list empty

Lab layout

1labs/lkm_lab/
2  logopen.c
3  Makefile
4  VM: debian-10, 2 vCPU, no production mounts
 1/* logopen.c — lab only, logs, does not hide */
 2#include <linux/module.h>
 3#include <linux/kprobes.h>
 4#include <linux/uaccess.h>
 5
 6static struct kprobe kp;
 7
 8static int pre(struct kprobe *p, struct pt_regs *regs)
 9{
10    /* filename pointer is architecture-specific; we only log a truncated copy */
11    char buf[32];
12    const char __user *fn = (const char __user *)regs->di; /* x86_64 arg0-ish; see note */
13    long n = strncpy_from_user(buf, fn, sizeof(buf) - 1);
14    if (n > 0) {
15        buf[n] = 0;
16        pr_info("logopen: pid=%d comm=%s file=%s\n",
17                current->pid, current->comm, buf);
18    }
19    return 0;
20}
21
22static int __init logopen_init(void)
23{
24    kp.symbol_name = "do_sys_openat2";
25    kp.pre_handler = pre;
26    if (register_kprobe(&kp)) {
27        pr_err("logopen: register_kprobe failed\n");
28        return -EINVAL;
29    }
30    pr_info("logopen: loaded at %px kprobe on %s\n", (void *)pre, kp.symbol_name);
31    return 0;
32}
33
34static void __exit logopen_exit(void)
35{
36    unregister_kprobe(&kp);
37    pr_info("logopen: unloaded\n");
38}
39
40MODULE_LICENSE("GPL");
41MODULE_DESCRIPTION("lab: log openat, do not hide");
42module_init(logopen_init);
43module_exit(logopen_exit);

Register ABI note: regs->di is a lab shortcut for x86_64 System V. I confirm do_sys_openat2 signature against the headers on this kernel. If the probe prints garbage, I stop and fix the arg; I do not “just patch sys_call_table”.

1obj-m += logopen.o
2KDIR ?= /lib/modules/$(shell uname -r)/build
3all:
4	$(MAKE) -C $(KDIR) M=$(PWD) modules

Artifact: insmod on the VM

 1$ uname -r
 25.4.0-0.lab-amd64
 3$ cat /proc/sys/kernel/modules_disabled
 40
 5$ mokutil --sb-state
 6SecureBoot disabled
 7
 8$ make
 9$ sudo insmod ./logopen.ko
10$ lsmod | grep logopen
11logopen                16384  0
12$ dmesg | tail -5
13[  441.200012] logopen: loaded at 0000000000000000 kprobe on do_sys_openat2
14# address in the real dump was a kernel VA; I replaced it:
15[  441.200012] logopen: loaded at 0xffff[REDACTED] kprobe on do_sys_openat2

Then a userland open:

1$ cat /etc/hostname
2labvm
3$ dmesg | tail -3
4[  448.017441] logopen: pid=5501 comm=cat file=/etc/hostname
5[  448.017502] logopen: pid=5501 comm=cat file=/etc/hostname

That is a hook as observability. Same insmod path a rootkit would use; opposite intent. I rmmod when the lab ends.

1$ sudo rmmod logopen
2$ dmesg | tail -1
3[  501.000001] logopen: unloaded
4$ lsmod | grep logopen
5# empty

What “syscall table hooking” means, without doing it

Classically: a module locates sys_call_table, clears CR0 WP, replaces sys_call_table[__NR_openat] with its function, restores WP. The table is often read-only now (CONFIG_STRICT_KERNEL_RWX, kptr_restrict, randomized base). Rootkits moved to ftrace, kprobes, kretprobe, inline trampolines. I do not include a table-overwrite snippet. The kprobe above is the allowed demonstration: a supported API that can also be abused.

Defender-visible differences I actually look for:

 1$ cat /proc/sys/kernel/kptr_restrict
 21
 3$ grep -i "sys_call_table" /boot/System.map-$(uname -r)
 4# ffffffff[REDACTED] R sys_call_table     ← 'R' read-only on this build
 5
 6$ cat /sys/kernel/debug/kprobes/list
 7ffff[REDACTED]  do_sys_openat2+0x0  [logopen]
 8
 9$ cat /proc/modules
10logopen 16384 0 - Live 0xffff[REDACTED]

A hidden module would be missing from /proc/modules and still have a kprobe. This lab module is present in both. If lsmod and /sys/kernel/debug/kprobes/list disagree, that is the hunting lead. I do not write the hide.

Sanitized reproduction (load fail / crash only)

Unsigned module with lockdown / Secure Boot (I flipped the lab VM once):

1$ sudo insmod ./logopen.ko
2insmod: ERROR: could not insert module ./logopen.ko: Operation not permitted
3$ dmesg | tail -1
4[  900.1] Lockdown: insmod: unsigned module loading is restricted; see man kernel_lockdown.7

That deny is a successful control, not a bug.

ASAN does not run in kernel. The analog I keep is a deliberate bug in an earlier draft that used a 16-byte stack buffer for the filename. I rebuilt with KASAN on a kbuild I do not ship:

1[  120.4] BUG: KASAN: stack-out-of-bounds in pre+0x8c/0x120 [logopen]
2[  120.4] Write of size 17 at addr ffff[REDACTED]
3[  120.4]  strncpy_from_user
4[  120.4]  pre [logopen]
5# I shrunk the copy to 31 bytes and the KASAN report went away.

Failed “auth”: insmod as non-root:

1$ insmod ./logopen.ko
2insmod: ERROR: could not insert module ./logopen.ko: Operation not permitted
3# CAP_SYS_MODULE missing; expected

Integrity checks I run after rmmod

The lab module is supposed to disappear cleanly. After rmmod:

1$ lsmod | grep logopen
2$ sudo cat /sys/kernel/debug/kprobes/list
3# empty, or other unrelated probes
4$ grep logopen /proc/kallsyms
5# empty
6$ sudo ausearch -k lkm | grep delete_module
7type=SYSCALL comm=rmmod success=yes

If kprobes/list still shows do_sys_openat2 with a module name that lsmod lacks, that is the hide class. I do not write a module that does that. I file “mismatch” and rebuild the VM from a known kernel package (dpkg -V linux-image-$(uname -r) on Debian).

tainted flags after a forced oops:

1$ cat /proc/sys/kernel/tainted
20
3# non-zero after a bad module; decode with kernel/tainted docs

The lab kprobe should leave tainted at 0 on a distro kernel. Out-of-tree unsigned modules often set the O / E bits. That is an inventory signal, not proof of a rootkit.

Mitigation

 1# one-way, after boot-needed modules are loaded
 2sysctl -w kernel.modules_disabled=1
 3
 4# Secure Boot + signed modules
 5mokutil --sb-state
 6# SecureBoot enabled
 7
 8# lockdown
 9dmesg | grep -i lockdown
10# Kernel lockdown: integrity mode
  • Inventory: lsmod baseline, find /lib/modules -name '*.ko' vs what loaded.
  • kernel.kptr_restrict=2, kernel.dmesg_restrict=1 on production.
  • Do not debugfs-export kprobe lists to untrusted users; still collect them with a privileged agent.
  • EDR that only watches userland ptrace will miss this lab. Watch finit_module / init_module syscalls (audit: auditctl -a always,exit -F arch=b64 -S finit_module -S init_module).
  • Compare modules.builtin plus the distro package list to lsmod. An extra live module with a random name is a page, even if it only logs. modinfo logopen should show my MODULE_DESCRIPTION string; a blank description on a live module is another inventory flag. Description in this lab is lab: log openat, do not hide.
1$ sudo auditctl -l | grep module
2-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k lkm
3# ausearch -k lkm  after the lab insmod:
4type=SYSCALL comm=insmod exe="/usr/bin/insmod" key="lkm" uid=0
5  a0=[REDACTED]  success=yes

What I file after this lab

  • VM: 5.4.0, SecureBoot disabled (finding), modules_disabled=0
  • logopen.ko: kprobe on do_sys_openat2, logs pid/comm/file, visible in lsmod and kprobes/list
  • cat /etc/hostname → dmesg file=/etc/hostname
  • rmmod → unloaded line, empty lsmod
  • KASAN stack-out-of-bounds on the 16-byte draft; fixed copy length 31
  • Fix: module signing, lockdown, modules_disabled after boot, audit finit_module
  • Out of scope: hiding files, syscall table patch, DKOM of module list

Commands appendix

1make && sudo insmod ./logopen.ko
2lsmod | grep logopen
3sudo cat /sys/kernel/debug/kprobes/list
4dmesg | grep logopen
5sudo rmmod logopen
6sudo ausearch -k lkm | tail