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:
lsmodbaseline,find /lib/modules -name '*.ko'vs what loaded. kernel.kptr_restrict=2,kernel.dmesg_restrict=1on production.- Do not debugfs-export kprobe lists to untrusted users; still collect them with a privileged agent.
- EDR that only watches userland
ptracewill miss this lab. Watchfinit_module/init_modulesyscalls (audit:auditctl -a always,exit -F arch=b64 -S finit_module -S init_module). - Compare
modules.builtinplus the distro package list tolsmod. An extra live module with a random name is a page, even if it only logs.modinfo logopenshould show myMODULE_DESCRIPTIONstring; a blank description on a live module is another inventory flag. Description in this lab islab: 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 ondo_sys_openat2, logspid/comm/file, visible inlsmodand kprobes/listcat /etc/hostname→ dmesgfile=/etc/hostnamermmod→ unloaded line, emptylsmod- KASAN stack-out-of-bounds on the 16-byte draft; fixed copy length 31
- Fix: module signing, lockdown,
modules_disabledafter boot, auditfinit_module - Out of scope: hiding files, syscall table patch, DKOM of
modulelist
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