Hooked on Linux: Rootkit Taxonomy, Hooking Techniques and Tradecraft

In this first part of a two-part series, we explore Linux rootkit taxonomy, trace their evolution from userland shared object hijacking and kernel-space loadable kernel module hooking to modern eBPF- and io_uring-powered techniques.

25 min readMalware Analysis
Hooked on Linux: Rootkit Taxonomy, Hooking Techniques and Tradecraft

Introduction

This is part one of a two-part series on Linux rootkits. In this first installment, we focus on the theory behind how rootkits work: their taxonomy, evolution, and the hooking techniques they use to subvert the kernel. In part two, we shift to the defensive side and dive into detection engineering, covering practical approaches to identifying and responding to these threats in production environments.

What Are Rootkits?

Rootkits are stealthy malware designed to conceal malicious activity, such as files, processes, network connections, kernel modules, or accounts. Their primary purposes are persistence and evasion, allowing attackers to maintain long-term access to high-value targets like servers, infrastructure, and enterprise systems. Unlike other forms of malware, rootkits focus on remaining undetected rather than immediately pursuing objectives.

How Do Rootkits Work?

Rootkits manipulate the operating system to alter how it presents information to users and security tools. They operate in user space or within the kernel. User-space rootkits modify user-level processes using techniques such as LD_PRELOAD or library hijacking. Kernel-space rootkits run with the highest privileges, modifying kernel structures, intercepting syscalls, or loading malicious modules. This deep integration gives them powerful evasion capabilities but increases operational risk.

Why Are Rootkits Difficult to Detect?

Kernel-space rootkits can manipulate core OS functions, subverting security tools and obscuring artifacts from userland visibility. They often leave minimal traces of their presence in the system, avoiding obvious indicators such as new processes or files, making traditional detection difficult. Identifying rootkits often requires memory forensics, kernel integrity checks, or telemetry below the OS level.

Why Rootkits Are a Double-Edged Sword for Attackers

While rootkits offer stealth and control, they carry operational risks. Kernel rootkits must be precisely tailored to kernel versions and environments. Mistakes, such as mishandling memory or incorrectly hooking syscalls, can cause system crashes (kernel panics), immediately exposing the attacker. At the very least, these failures draw unwanted attention to the system—a scenario the attacker is actively trying to avoid to maintain their foothold.

Kernel updates also present challenges: changes to APIs, memory structures, or syscalls can break rootkit functionality, making persistence vulnerable. Detection of suspicious modules or hooks typically triggers deep forensic investigation, as rootkits strongly indicate targeted, high-skill attacks. For attackers, rootkits are high-risk, high-reward tools; for defenders, this fragility offers opportunities for detection through low-level monitoring.

Windows vs Linux Rootkits

The Windows Rootkit Ecosystem

Windows is the primary focus for rootkit development. Attackers exploit kernel hooks, drivers, and undocumented syscalls for hiding malware, stealing credentials, and persistence. A mature research community and widespread usage in enterprise environments drive ongoing innovation, including techniques like DKOM, PatchGuard bypasses, and bootkits.

Robust security tools and Microsoft’s hardening efforts push attackers toward increasingly sophisticated methods. Windows remains attractive due to its dominance on enterprise endpoints and consumer devices.

The Linux Rootkit Ecosystem

Linux rootkits have historically received less attention. Fragmentation across distributions and kernel versions complicates detection and development. While academic research exists, much tooling is outdated, and production Linux environments often lack specialized monitoring.

However, Linux’s role in cloud, containers, IoT, and High Performance Computing has made it a growing target. Real-world Linux rootkits have been observed in attacks on cloud providers, telecoms, and governments. Key challenges for attackers include:

  • Diverse kernels hinder cross-distribution compatibility.
  • Long uptimes prolong kernel mismatches.
  • Security features like SELinux, AppArmor, and module signing increase difficulty.

Unique Linux threats include:

  • Containers & Kubernetes: new persistence vectors via container escape.
  • IoT devices: outdated kernels with minimal monitoring.
  • Production servers: headless systems lacking user interaction, reducing visibility.

With Linux dominating modern infrastructure, rootkits represent an under-monitored yet escalating threat. Improving detection, tooling, and research into Linux-specific techniques is increasingly urgent.

Evolution of Linux Rootkit Implementation Models

Over the past two decades, Linux rootkits have evolved from basic userland techniques to advanced, kernel-resident implants leveraging modern kernel interfaces like eBPF and io_uring. Each stage in this evolution reflects both attacker innovation and defender response, pushing rootkit designs toward greater stealth, flexibility, and resilience.

This section outlines that progression, including key characteristics, historical context, and real-world examples.

Early 2000s: Shared Object (SO) Userland Rootkits

The earliest Linux rootkits operated entirely in user space without requiring kernel modification, relying on techniques like LD_PRELOAD or the manipulation of shell profiles to inject malicious shared objects into legitimate binaries. By intercepting standard libc functions such as opendir, readdir, and fopen, these rootkits could manipulate the output of diagnostic tools like ps, ls, and netstat. While this approach made them easier to deploy, their reliance on userland hooks meant they were limited in stealth and scope compared to kernel-level implants; they were easily disrupted by simple reboots or configuration resets. Prominent examples include the Jynx rootkit (2009), which hooked libc functions to hide files and connections, and Azazel (2013), which combined shared object injection with optional kernel-mode features. The foundational techniques for this dynamic linker abuse were famously detailed in Phrack Magazine #61 back in 2003.

Mid-2000s-2010s: Loadable Kernel Module (LKM) Rootkits

As defenders became adept at spotting userland manipulations, attackers migrated into the kernel space via Loadable Kernel Modules (LKMs). Although LKMs are legitimate extensions, malicious actors utilize them to operate with full privileges, hooking the sys_call_table, manipulating ftrace, or altering internal linked lists to hide processes, files, sockets, and even the rootkit itself. While LKMs offer deep control and powerful concealment capabilities, they face significant scrutiny in hardened environments. They are detectable via tainted kernel states, listings in /proc/modules, or specialized LKM scanners, and are increasingly hindered by modern defenses like Secure Boot, module signing, and Linux Security Modules (LSMs). Classic examples of this era include Adore-ng (2004+), a syscall-hooking LKM capable of hiding itself; Diamorphine (2016), a popular hooker that remains functional on many distributions; and Reptile (2020), a modern variant featuring backdoor capabilities.

Late 2010s: eBPF-Based Rootkits

To evade the growing detection of LKM-based threats, attackers began abusing eBPF, a subsystem originally built for safe packet filtering and kernel tracing. Since Linux 4.8+, eBPF has evolved into a programmable in-kernel virtual machine capable of attaching code to syscall hooks, kprobes, tracepoints, or Linux Security Module events. These implants run in kernel space but avoid traditional module loading, allowing them to bypass standard LKM scanners like rkhunter and chkrootkit, as well as Secure Boot restrictions. Because they do not appear in /proc/modules and are essentially invisible to typical module audit mechanisms, they require CAP_BPF or CAP_SYS_ADMIN (or rare unprivileged BPF access) to deploy. This era is defined by tools like Triple Cross (2022), a proof-of-concept that injects eBPF programs to hook syscalls like execve, and Boopkit (2022), which implements a covert C2 channel entirely via eBPF, alongside numerous Defcon presentations exploring the topic.

2025s and Beyond: io_uring-Based Rootkits (Emerging)

The most recent evolution capitalizes on io_uring, a high-performance asynchronous I/O interface introduced in Linux 5.1 (2019) that allows processes to batch system operations via shared memory rings. While designed to reduce syscall overhead for performance, red teamers have demonstrated that io_uring can be abused to create stealthy userland agents or kernel-context rootkits that evade syscall-based EDRs. By using io_uring_enter to batch file, network, and process operations, these rootkits produce far fewer observable syscall events, frustrating traditional detection mechanisms and avoiding the restrictions placed on LKMs and eBPF. Although still experimental, examples like RingReaper (2025), which uses io_uring to stealthily replace common syscalls like read, write, connect, and unlink, and research by ARMO highlight this as a highly promising vector for future rootkit development that is hard to trace without custom instrumentation.

The Linux rootkit design has consistently adapted in response to better defenses. As LKM loading becomes more difficult and syscall auditing becomes more advanced, attackers have turned to alternative interfaces such as eBPF and io_uring. With this evolution, the battle is no longer just about detection, but about understanding the mechanisms rootkits use to blend into the system’s core, starting with their hooking strategies and internal architecture.

##Rootkit Internals and Hooking Techniques

Understanding the architecture of Linux rootkits is essential for detection and defense. Most rootkits follow a modular design with two main components:

  • Loader: Installs or injects the rootkit and may establish persistence. While not strictly necessary, a separate loader component is often seen in malware infection chains that deploy rootkits.
  • Payload: Performs malicious actions such as hiding files, intercepting syscalls, or covert communications.

Payloads rely heavily on hooking techniques to alter execution flow and achieve stealth.

Rootkit Loader Component

The loader is the component responsible for transferring the rootkit into memory, initializing its execution, and in many cases, establishing persistence or escalating privileges. Its role is to bridge the gap between initial access (e.g., via exploit, phishing, or misconfiguration) and full rootkit deployment.

Depending on the rootkit model, the loader may operate entirely in user space, interact with the kernel through standard system interfaces, or bypass operating system protections altogether. Broadly, loaders can be categorized into three classes: malware-based droppers, userland rootkit initializers, and custom kernel-space loaders. Additionally, rootkits may be loaded manually by an attacker through userspace tooling such as insmod.

Malware-Based Droppers

Malware droppers are lightweight programs, often deployed after initial access, whose sole purpose is to download or unpack a rootkit payload and execute it. These droppers typically operate in user space but escalate privileges and interact with kernel-level features.

Common techniques include:

  • Module injection: Writing a malicious .ko file to disk and invoking insmod or modprobe to load it as a kernel module.
  • Syscall wrapper: Using a wrapper around init_module() or finit_module() to load an LKM directly through syscalls.
  • In-memory injection: Leveraging interfaces such as ptrace or memfd_create, often avoiding disk artifacts.
  • BPF-based loading: Using utilities like bpftool, tc, or direct bpf() syscalls to load and attach eBPF programs to kernel tracepoints or LSM hooks.

Userland Loaders

In the case of Shared Object rootkits, the loader may be limited to modifying user configuration or environment settings:

  • Dynamic linker abuse: Setting LD_PRELOAD=/path/to/rootkit.so allows the malicious shared object to override libc functions when the target binary executes.
  • Persistence via profile modification: Inserting preload configurations into .bashrc, .profile, or global files such as /etc/profile ensures continued execution across sessions.

While these loaders are trivial in implementation, they remain effective in weakly defended environments or as part of multi-stage infection chains.

Custom Kernel Loaders

Advanced rootkits may include custom kernel loaders designed to bypass standard module loading paths entirely. These loaders interact directly with low-level kernel interfaces or memory devices to write the rootkit into memory, often evading kernel audit logs or module signature verification.

For example, Reptile includes a userspace binary as a loader, allowing it to load the rootkit without invoking insmod or modprobe; however, it still relies on the init_mod syscall for loading the module into memory.

Additional Loader Capabilities

The malware loader often assumes an expanded role beyond simple initialization, becoming a multifunctional component of the attack chain. A key step for these advanced loaders is Elevating Privileges, in which they seek root access before loading the primary payload, often by exploiting local kernel vulnerabilities, a common tactic exemplified by the "Dirty Pipe" vulnerability (CVE-2022-0847). Once privileges are secured, the loader is then tasked with covering tracks. This involves a process of wiping evidence of execution by clearing entries from critical files like bash_history, kernel logs, audit logs, or the system's main syslog. Finally, to guarantee re-execution upon system restart, the loader ensures persistence by installing mechanisms such as systemd units, cron jobs, udev rules, or modifications to initialization scripts. These multifunctional behaviors often blur the distinction between a mere "loader" and full-fledged malware, especially in complex, multi-stage infections.

Payload Component

The payload delivers core functionality: stealth, control, and persistence. There are several primary methods an attacker might use. User-space payloads, often referred to as SO rootkits, operate by hijacking standard C library functions like readdir or fopen via the dynamic linker. This allows them to manipulate the output of common system tools such as ls, netstat, and ps. While they are generally easier to deploy, their operational scope is limited.

In contrast, kernel-space payloads operate with full system privileges. They can hide files and processes directly from /proc, manipulate the networking stack, and modify kernel structures. A more modern approach involves eBPF-based rootkits, which leverage in-kernel bytecode attached to syscall tracepoints or Linux Security Module (LSM) hooks. These kits offer stealth without requiring out-of-tree modules, making them particularly effective in environments with Secure Boot or module signing policies. Tools like bpftool simplify their loading, thereby complicating detection. Finally, io_uring-based payloads exploit asynchronous I/O batching via io_uring_enter (available in Linux 5.1 and later) to bypass traditional syscall monitoring. This allows for stealthy file, network, and process operations while minimizing telemetry exposure.

Linux Rootkits – Hooking Techniques

Building on that essential foundation, we now turn to the core of most rootkit functionality: hooking. At its essence, hooking involves intercepting and altering the execution of functions or system calls to conceal malicious activity or inject new behaviors. By diverting the normal flow of code, rootkits can hide files and processes, filter out security events, or secretly monitor the system, often without leaving obvious clues. Hooking can be implemented in both userland and kernel space, and over the years, attackers have devised numerous hooking techniques, from legacy methods to modern evasive maneuvers. In this part, we will provide a deep dive into common hooking techniques used by Linux rootkits, illustrating each method with examples and real-world rootkit samples (such as Reptile, Diamorphine, PUMAKIT, and, more recently, FlipSwitch) to understand how they work and how kernel evolution has challenged them.

The Concept of Hooking

At a high level, hooking is the practice of intercepting a function or system call invocation and redirecting it to malicious code. By doing so, a rootkit can modify the returned data or behavior to hide its presence or tamper with system operations. For example, a rootkit might hook the syscall that lists files in a directory (getdents), making it skip over any filenames that match the rootkit’s own files, thus making those files “invisible” to user commands like ls.

Hooking is not confined to kernel internals; it can also occur in user space. Early Linux rootkits operated entirely in userland by injecting malicious shared objects into processes. Techniques like using the dynamic linker’s LD_PRELOAD environment variable allow a rootkit to override standard C library functions (e.g., getdents, readdir, and fopen) in user programs. This means when a user runs a tool like ps or netstat, the rootkit’s injected code intercepts calls to list processes or network connections and filters out the malicious ones. These userland hooks require no kernel privileges and are relatively simple to implement.

Notable examples include JynxKit (2012) and Azazel (2014), user-mode rootkits that hook dozens of libc functions to hide processes, files, network ports, and even enable backdoors. However, userland hooking has significant limitations: it’s easier to detect and remove, and it lacks the deep control that kernel-level hooks have. As a result, most modern and “in the wild” Linux rootkits have shifted to kernel space hooking, despite the higher complexity and risk, because kernel hooks can comprehensively trick the operating system and security tools at a low level.

In the kernel, hooking typically means altering kernel data structures or code so that when the kernel tries to execute a particular operation (say, open a file or make a system call), it invokes the rootkit’s code instead of (or in addition to) the legitimate code. Over the years, Linux kernel developers have introduced stronger protections to guard against unauthorized modifications, but attackers have responded with increasingly sophisticated hooking methods. Below, we’ll examine the major hooking techniques in kernel space, starting from older methods (now largely obsolete) and progressing to modern techniques that attempt to bypass contemporary kernel defenses. Each subsection will explain the technique, show a simplified code example, and discuss its usage in known rootkits and its limitations given today’s Linux safeguards.

Hooking Techniques in the Kernel

Interrupt Descriptor Table (IDT) Hooking

One of the earliest kernel hooking tricks on Linux was to target the Interrupt Descriptor Table (IDT). On 32-bit x86 Linux, system calls used to be invoked via a software interrupt (int 0x80). The IDT is a table that maps interrupt numbers to handler addresses. By modifying the IDT entry for 0x80, a rootkit could hijack the system call entry point before the kernel’s own system call dispatcher gets control. In other words, when any program triggered a syscall via int 0x80, the CPU would jump to the rootkit’s custom handler first, allowing the rootkit to filter or redirect calls at the very lowest level. Below is a simplified code example of IDT hooking (for illustration purposes):

// Install the IDT hook
static int install_idt_hook(void) {
    // Get pointer to IDT table
    idt_table = get_idt_table();

    // Save original syscall handler (int 0x80 = entry 128)
    original_syscall_entry = idt_table[0x80];

    // Calculate original handler address
    original_syscall_handler = (void*)(
        (original_syscall_entry.offset_high << 16) |
        original_syscall_entry.offset_low
    );

    // Install our hook
    idt_table[0x80].offset_low = (unsigned long)custom_int80_handler & 0xFFFF;
    idt_table[0x80].offset_high =
        ((unsigned long)custom_int80_handler >> 16)
        & 0xFFFF;

    // Keep same selector and attributes as original
    // idt_table[0x80].selector and type_attr remain unchanged

    printk(KERN_INFO "IDT hook installed at 0x80\n");
    return 0;
}
IDT hijacking code example

The above code sets a new handler for interrupt 0x80, redirecting execution flow to the rootkit’s handler before any syscall handling occurs. This allows the rootkit to intercept or modify syscall behavior entirely below the level of the syscall table. IDT hooking is used by educational and older rootkits such as SuckIT.

IDT hooking is mostly a historical technique now. It only worked on older Linux systems that use the int 0x80 mechanism (32-bit x86 kernels before Linux 2.6). Modern 64-bit Linux uses the sysenter/syscall instructions instead of the software interrupt, so the IDT entry for 0x80 is no longer used for system calls. Additionally, IDT hooking is highly architecture-specific (x86 only) and is not effective on modern kernels with x86_64 or other architectures.

Syscall Table Hooking

Syscall table hooking is a classic rootkit technique that involves modifying the kernel's system call dispatch table, known as the sys_call_table. This table is an array of function pointers where each entry corresponds to a specific syscall number. By overwriting a pointer in this table, an attacker can redirect a legitimate syscall, such as getdents64, kill, or read, to a malicious handler. An example is displayed below.

asmlinkage int (*original_getdents64)(
    unsigned int,
    struct linux_dirent64 __user *,
    unsigned int);

asmlinkage int hacked_getdents64(
    unsigned int fd,
    struct linux_dirent64 __user *dirp,
    unsigned int count)
{
    int ret = original_getdents64(fd, dirp, count);
    // Filter hidden entries from dirp
    return ret;
}

write_cr0(read_cr0() & ~0x10000); // Disable write protection
sys_call_table[__NR_getdents64] = hacked_getdents64;
write_cr0(read_cr0() | 0x10000); // Re-enable write protection
Syscall table hijacking code example

In the example, to modify the table, a kernel module would first need to disable write protection on the memory page where the table resides. The following assembly code (as seen in Diamorphine) demonstrates how the 20th bit (Write Protect) of the CR0 control register can be cleared, even though the write_cr0 function is no longer exported to modules:

static inline void
write_cr0_forced(unsigned long val)
{
    unsigned long __force_order;

    asm volatile(
        "mov %0, %%cr0"
        : "+r"(val), "+m"(__force_order));
}
Control register (cr0) clearing code example

Once write protection is disabled, the address of a syscall in the table can be replaced with the address of a malicious function. After the modification, write protection is re-enabled. Notable examples of rootkits that used this technique include Diamorphine, Knark, and Reveng_rtkit. Syscall table hooking has several limitations:

  • Kernel hardening (since 2.6.25) hides sys_call_table.
  • Kernel memory pages were made read-only (CONFIG_STRICT_KERNEL_RWX).
  • Security features like Secure Boot and the kernel lockdown mechanism can hinder modifications to CR0.

The most definitive mitigation came with Linux kernel 6.9, which fundamentally changed how syscalls are dispatched on the x86-64 architecture. Before version 6.9, the kernel executed syscalls by directly looking up the handler in the sys_call_table array:

// Pre-v6.9 Syscall Dispatch
asmlinkage const sys_call_ptr_t sys_call_table[] = {
    #include <asm/syscalls_64.h>
};
Syscall execution in Linux kernels before version 6.9

Starting with kernel 6.9, the syscall number is used in a switch statement to find and execute the appropriate handler. The sys_call_table still exists but is only populated for compatibility with tracing tools and is no longer used in the syscall execution path.

// Kernel v6.9+ Syscall Dispatch
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
    switch (nr) {
    #include <asm/syscalls_64.h>
    default: return __x64_sys_ni_syscall(regs);
    }
};
Syscall execution in Linux kernels after version 6.9

As a result of this architectural change, overwriting function pointers in the sys_call_table on kernels 6.9 and newer does not affect syscall execution, rendering the technique entirely ineffective. While this led us to assume that syscall table patching was no longer viable, we recently published the FlipSwitch technique, which demonstrates that this vector is far from dead. This method leverages specific register manipulation gadgets to momentarily disable kernel write-protection mechanisms, effectively allowing an attacker to bypass the "immutability" of the modern syscall path and reintroduce hooks even within these hardened environments.

Instead of targeting the data-based sys_call_table, FlipSwitch focuses on the compiled machine code of the kernel's new syscall dispatcher function, x64_sys_call. Because the kernel now uses a massive switch-case statement to execute syscalls, each syscall has a hardcoded call instruction within the dispatcher's binary. FlipSwitch scans the memory of the x64_sys_call function to locate the specific "signature" of a target syscall, typically an 0xe8 opcode (the CALL instruction) followed by a 4-byte relative offset that points to the original, legitimate handler.

Once this call site is identified within the dispatcher, the rootkit uses gadgets to clear the Write Protect (WP) bit in the CR0 control register, granting temporary write access to the kernel's executable code segments. The original relative offset is then overwritten with a new offset pointing to a malicious, adversary-controlled function. This effectively "flips the switch" at the point of dispatch, ensuring that whenever the kernel attempts to execute the target syscall through its modern switch-statement path, it is redirected to the rootkit instead. This enables reliable, precise syscall interception that persists despite the 6.9 kernel’s architectural hardening.

Inline Hooking / Function Prologue Patching

Inline hooking is an alternative to hooking via pointer tables. Instead of modifying a pointer in a table, inline hooking patches the code of the target function itself. The rootkit writes a jump instruction at the start (prologue) of a kernel function, which diverts execution to the rootkit’s own code. This technique is akin to function hot-patching or the way user-mode hooks on Windows work (e.g., modifying the first bytes of a function to jump to a detour).

For example, a rootkit might target a kernel function like do_sys_open (which is part of the open file syscall handling). By overwriting the first few bytes of do_sys_open with an x86 JMP instruction to malicious code, the rootkit ensures that whenever do_sys_open is called, it jumps into the rootkit’s routine instead. The malicious routine can then execute whatever it wants (e.g., check if the filename to open is on a hidden list and deny access), and optionally call the original do_sys_open to proceed with normal behavior for non-hidden files.

unsigned char *target = (unsigned char *)kallsyms_lookup_name("do_sys_open");
unsigned long hook = (unsigned long)&malicious_function;
int offset = (int)(hook - ((unsigned long)target + 5));
unsigned char jmp[5] = {0xE9};
memcpy(&jmp[1], &offset, 4);

// Memory protection omitted for brevity
memcpy(target, jmp, 5);

asmlinkage long malicious_function(
    const char __user *filename,
    int flags, umode_t mode) {
    printk(KERN_INFO "do_sys_open hooked!\n");
    return -EPERM;
}
Inline hooking code example

This code overwrites the beginning of do_sys_open() with a JMP instruction that redirects execution to malicious code. The open-source rootkit Reptile extensively uses inline function patching via a custom framework called KHOOK (which we will discuss shortly).

Reptile’s inline hooks target functions like sys_kill and others, enabling backdoor commands (e.g., sending a specific signal to a process triggers the rootkit to elevate privileges or hide the process). Another example is Suterusu, which also applied inline patching for some of its hooks.

Inline hooking is fragile and high-risk: overwriting a function’s prologue is sensitive to kernel version and compiler differences (so hooks often need per-build patches or runtime disassembly), it can easily crash the system if instructions or concurrent execution aren’t handled correctly, and it requires bypassing modern memory protections (W^X, CR0 WP, module signing/lockdown) or exploiting vulnerabilities to make kernel text writable.

Virtual Filesystem Hooking

The Virtual Filesystem (VFS) layer in Linux provides an abstraction for file operations. For example, when you read a directory (like ls /proc), the kernel will eventually call a function to iterate over directory entries. File systems define their own file_operations with function pointers for actions like iterate_shared (to list directory contents) or read/write for file I/O. VFS hooking involves replacing these function pointers with rootkit-provided functions to manipulate how the filesystem presents data.

In essence, a rootkit can hook into the VFS to hide files or directories by filtering them out of directory listings. A common trick: hook the function that iterates directory entries, and make it skip any file names that match a certain pattern. The file_operations structure for directories (particularly in /proc or /sys) is a frequent target, since hiding malicious processes often involves hiding entries under /proc/<pid>.

Consider this example hook for a directory listing function:

static iterate_dir_t original_iterate;

static int malicious_filldir(
    struct dir_context *ctx,
    const char *name, int namelen,
    loff_t offset, u64 ino,
    unsigned int d_type)
{
    if (!strcmp(name, "hidden_file"))
        return 0; // Skip hidden_file
    return ctx->actor(ctx, name, namelen, offset, ino, d_type);
}

static int malicious_iterate(struct file *file, struct dir_context *ctx)
{
    struct dir_context new_ctx = *ctx;
    new_ctx.actor = malicious_filldir;
    return original_iterate(file, &new_ctx);
}

// Hook installation
file->f_op->iterate = malicious_iterate;
VFS hooking code example

This replacement function filters out hidden files during directory listing operations. By hooking at the VFS level, the rootkit doesn’t need to tamper with system call tables or low-level assembly; it simply piggybacks on the filesystem interface. Adore-NG, a once-popular Linux rootkit, employed VFS hooking to hide files and processes. It patched the function pointers for directory iteration to conceal entries for specific PIDs and filenames. Many other kernel rootkits have similar code to hide themselves or their artifacts via VFS hooks.

VFS hooking is still widely used, but it has limitations due to changes in kernel structure offsets between versions, which can lead to hooks breaking.

Ftrace-Based Hooking

Modern Linux kernels include a powerful tracing framework called ftrace (function tracer). Ftrace is intended for debugging and performance analysis, allowing one to attach hooks (callbacks) to almost any kernel function entry or exit without modifying the kernel code directly. It works by dynamically modifying kernel code at runtime in a controlled manner (often by patching in a lightweight trampoline that calls the tracing handler). Importantly, ftrace provides an API for kernel modules to register trace handlers, as long as certain conditions are met (like having the kernel built with ftrace support and the debugfs interface available).

Rootkits have started abusing ftrace to implement hooks in a less obvious way. Instead of manually writing a JMP into a function, a rootkit can ask the kernel’s ftrace machinery to do it on its behalf; essentially “legitimizing” the hook. This means the rootkit doesn’t have to find the function’s address or modify page protections; it simply registers a callback for the function name it wants to intercept, and the kernel installs the hook.

Here’s a simplified example of using ftrace to hook the mkdir system call handler:

static int __init hook_init(void) {
    target_addr = kallsyms_lookup_name(SYSCALL_NAME("sys_mkdir"));
    if (!target_addr) return -ENOENT;
    real_mkdir = (void *)target_addr;

    ops.func = ftrace_thunk;
    ops.flags = FTRACE_OPS_FL_SAVE_REGS
        | FTRACE_OPS_FL_RECURSION_SAFE
        | FTRACE_OPS_FL_IPMODIFY;

    if (ftrace_set_filter_ip(&ops, target_addr, 0, 0)) return -EINVAL;
    return register_ftrace_function(&ops);
}
Ftrace hooking code example

This hook intercepts the sys_mkdir function and reroutes it through a malicious handler. Recent rootkits such as KoviD, Singularity, and Umbra have utilized ftrace-based hooks. These rootkits register ftrace callbacks on various kernel functions (including syscalls) to either monitor or manipulate them.

The main advantage of ftrace hooking is that it leaves no obvious footprints in global tables or patched code. The hooking is done via legitimate kernel interfaces. To an untrained eye, everything looks normal; sys_call_table is intact, function prologues are not manually overwritten by the rootkit (they are overwritten by the ftrace mechanism, but that is a common and allowed occurrence in a kernel with tracing enabled). Also, ftrace hooks can often be enabled/disabled on the fly and are inherently less intrusive than manual patching.

While ftrace hooking is powerful, it’s constrained by environment and privilege boundaries (if used from outside the kernel). It requires access to the tracing interface (debugfs) and CAP_SYS_ADMIN privileges, which may be unavailable on hardened or containerized systems where even UID 0 is restricted by namespaces, LSMs, or Secure Boot lockdown policies. Debugfs may also be unmounted or read-only in production for security reasons. Thus, while a fully privileged root user can typically use ftrace, modern defenses often disable or limit these capabilities, reducing the practicality of ftrace-based hooks in highly hardened environments.

Kprobes Hooking

Kprobes is another kernel feature intended for debugging and instrumentation, which attackers have repurposed for rootkit hooking. Kprobes allow one to dynamically break into almost any kernel routine at runtime by registering a probe handler. When the specified instruction is about to execute, the kprobe infrastructure saves state and transfers control to the custom handler. After the handler runs (you can even alter registers or the instruction pointer), the kernel resumes normal execution of the original code. In simpler terms, kprobes let you attach a custom callback to an arbitrary point in kernel code (function entry, specific instruction, etc.), somewhat like a breakpoint with a handler.
Using kprobes for malicious hooking usually involves intercepting a function to either prevent it from doing something or to grab some info. A common use in modern rootkits: since many important symbols (like sys_call_table or kallsyms_lookup_name) are no longer exported, a rootkit can deploy a kprobe on a function that does have access to that symbol and steal it. A kprobe structure and registration are shown below.

// Declare a kprobe targeting the symbol "kallsyms_lookup_name"
static struct kprobe kp = {
    .symbol_name = "kallsyms_lookup_name"
};

// Function pointer type matching kallsyms_lookup_name
typedef unsigned long
    (*kallsyms_lookup_name_t)(const char *name);

// Global pointer to the resolved kallsyms_lookup_name
kallsyms_lookup_name_t kallsyms_lookup_name;

// Register the kprobe; kernel resolves kp.addr
// to the address of the symbol
register_kprobe(&kp);

// Assign resolved address to our function pointer
kallsyms_lookup_name =
    (kallsyms_lookup_name_t) kp.addr;

// Unregister the kprobe (only needed it once)
unregister_kprobe(&kp);
Kprobes hooking code example

This probe is used to retrieve the symbol name for kallsyms_lookup_name, typically a precursor to syscall table hooking. Although not present in the initial commits, a recent update to Diamorphine used this technique. It places a kprobe to grab the pointer of kallsyms_lookup_name itself (or uses a kprobe on a known function to indirectly get what it needs). Similarly, other rootkits use a temporary kprobe to locate symbols, then unregister it once done, moving on to perform hooks via other means. Kprobes can also be used to directly hook behavior (not just find addresses). Or a jprobe (a specialized kprobe) can redirect a function entirely. However, using kprobes to fully replace functionality is tricky and not commonly done, because it’s simpler to either patch or use ftrace if you want to consistently hijack a function. Kprobes are often used for intermittent or auxiliary hooking.

Kprobes are useful but limited: they add runtime overhead and can destabilize systems if placed on very hot or restricted low-level functions (recursive probes are suppressed), so attackers must pick probe points carefully; they’re also auditable and can trigger kernel warnings or be logged by system auditing, and active probes are viewable under /sys/kernel/debug/kprobes/list (so unexpected entries are suspicious); some kernels may be built without kprobe/debug support.

Kernel Hook Framework

As mentioned earlier, with the Reptile rootkit, attackers sometimes create higher-level frameworks to manage their hooks. Kernel Hook (KHOOK) is one such framework (developed by the author of Reptile) that abstracts away the dirty work of inline patching and provides a cleaner interface for rootkit developers. Essentially, KHOOK is a library that allows you to specify a function to hook and your replacement, and it handles modifying the kernel code while providing a trampoline to call the original function safely. To illustrate, here’s an example of how one might use a KHOOK-like macro (based on Reptile’s usage) to hook the kill syscall:

// Creates a replacement for sys_kill:
// long sys_kill(long pid, long sig)
KHOOK_EXT(long, sys_kill, long, long);

static long khook_sys_kill(long pid, long sig) {
    // Signal 0 is used to check if a process
    // exists (without sending a signal)
    if (sig == 0) {
        // If the target is invisible (hidden by
        // a rootkit), pretend it doesn't exist
        if (is_proc_invisible(pid)) {
            return -ESRCH; // No such process
        }
    }

    // Otherwise, forward the call to the original sys_kill syscall
    return KHOOK_ORIGIN(sys_kill, pid, sig);
}
KHOOK code example

KHOOK operates via inline function patching, overwriting function prologues with a jump to attacker-controlled handlers. The example above illustrates how sys_kill() is redirected to a malicious handler if the kill signal is 0.

Although KHOOK simplifies inline patching, it still inherits all its drawbacks: it modifies kernel text to insert jump stubs, so protections like kernel lockdown, Secure Boot, or W^X can block it. They are also architecture- and version-dependent (commonly limited to x86 and fails on kernel 5.x+), making them fragile across builds.

Hooking Techniques in Userspace

Userspace hooking is a technique that targets the libc layer, or other shared libraries accessed via the dynamic linker, to intercept common API calls used by user tools. Examples of these calls include readdir, getdents, open, fopen, fgets, and connect. By interposing replacement functions, an attacker can manipulate ordinary userland tools like ps, ls, lsof, and netstat to return altered or "sanitized" views. This is used to conceal processes, files, sockets, or hide evidence of malicious code.

The common methods for implementing this mirror how the dynamic linker resolves symbols or involve modifying process memory. These methods include using the LD_PRELOAD environment variable or LD_AUDIT to force an early load of a malicious shared object (.so) file, modifying ELF DT_* entries or library search paths to prioritize a hostile library, or performing runtime GOT/PLT overwrites within a process. Overwriting the GOT/PLT typically involves changing memory protection settings (mprotect), writing the new code (write), and then restoring the original settings (restore) after injection.

A hooked function usually calls the real libc symbol using dlsym(RTLD_NEXT, ...) for its normal operation. It then filters or alters the results only for targets it intends to hide. A basic example of a LD_PRELOAD filter for the readdir() function is shown below.

#define _GNU_SOURCE       // GNU extensions (RTLD_NEXT)
#include <dlfcn.h>        // dlsym(), RTLD_NEXT
#include <dirent.h>       // DIR, struct dirent, readdir()
#include <string.h>       // strstr()

// Pointer to the original readdir()
static struct dirent *(*real_readdir)(DIR *d);

struct dirent *readdir(DIR *d) {
    if (!real_readdir) // resolve original once
        real_readdir =
            dlsym(RTLD_NEXT, "readdir");
    struct dirent *ent;
    // Fetch next dir entry from real readdir
    while ((ent = real_readdir(d)) != NULL) {
        // If name contains the secret marker,
        // skip this entry (hide it)
        if (strstr(ent->d_name, ".secret"))
            continue;
        return ent; // return visible entry
    }
    return NULL; // no more entries
}

This example replaces readdir() in-process by providing a library resolved before the real libc, effectively hiding filenames that match a filter. Historic user-mode hiding tools and lightweight “rootkits” have used LD_PRELOAD or GOT/PLT patching to hide processes, files, and sockets. Attackers also inject shared objects into specific services to achieve targeted stealth without needing kernel modules.

Userspace interposition affects only processes that load the malicious library (or are injected into). It’s fragile for system-wide persistence (service/unit files, sanitized environments, setuid/static binaries complicate it). Detection is straightforward relative to kernel hooks: check for suspicious LD_PRELOAD/LD_AUDIT entries, unexpected mapped shared objects in /proc/<pid>/maps, mismatches between on-disk libraries and in-memory imports, or altered GOT entries. Integrity tools, service supervisors (systemd), and simple process memory inspection will usually expose this technique.

Hooking Techniques Using eBPF

A more recent rootkit implementation model involves the abuse of eBPF (extended Berkeley Packet Filter). eBPF is a subsystem in Linux that allows privileged users to load bytecode programs into the kernel. While often described as a "sandboxed VM," its security actually relies on a static verifier that ensures the bytecode is safe (no infinite loops, no illegal memory access) before it is JIT-compiled into native machine code for near-zero-latency execution.

Instead of inserting an LKM to modify kernel behavior, an attacker can load one or more eBPF programs that attach to sensitive kernel events. For instance, one can write an eBPF program that attaches to the system call entry for execve (via a kprobe or tracepoint), allowing it to monitor or manipulate process execution. Similarly, eBPF can hook at the LSM layer (like program execution notifications) to prevent certain actions or hide them. An example is displayed below.

// Attach this eBPF program to the tracepoint for sys_enter_execve
SEC("tp/syscalls/sys_enter_execve")
int tp_sys_enter_execve(struct sys_execve_enter_ctx *ctx) {
    // Get the current process's PID and TID as a 64-bit value
    // Upper 32 bits = PID, Lower 32 bits = TID
    __u64 pid_tgid = bpf_get_current_pid_tgid();

    // Delegate handling logic to a helper function
    return handle_tp_sys_enter_execve(ctx, pid_tgid);
}
eBPF hooking code example

Two prominent public examples are TripleCross and Boopkit. TripleCross demonstrated a rootkit that used eBPF to hook syscalls like execve for persistence and hiding. Boopkit used eBPF as a covert communication channel and backdoor, by attaching eBPF programs that could manipulate socket buffers (allowing a remote party to communicate with the rootkit through crafted packets). These are proof-of-concept projects, but they proved the viability of eBPF in rootkit development.

Main advantages are that eBPF hooking does not require an LKM to be loaded and is compatible with modern kernel protections. For eBPF-supported kernels, this is a strong technique. But although they are powerful, they are also constrained. They need elevated privileges to load, are limited by the verifier’s safety checks, are ephemeral across reboots (requiring separate persistence), and are increasingly discoverable by auditing/forensic tools. The usage of eBPF will especially be visible on systems that typically do not use eBPF tooling.

Evasion Techniques Using io_uring

While io_uring is not used for hooking, it deserves a honarable mention as a recent addition to the EDR evasion techniques used by rootkits. io_uring is an asynchronous, ring-buffer-based I/O API that lets processes submit batches of I/O requests (SQEs) and reap completions (CQEs) with minimal syscall overhead. It is not a hooking framework, but its design changes the syscall/visibility surface and exposes powerful kernel-facing primitives (registered buffers, fixed files, mapped rings) that attackers can abuse for stealthy I/O, syscall-evading workflows, or, when combined with a vulnerability, as an exploit primitive that leads to installing hooks at a lower layer.

Attack patterns fall into two classes: (1) evasion/performance abuse: a malicious process uses io_uring to perform lots of reads/writes/metadata ops in large batches so traditional per-syscall detectors see fewer events or atypical patterns; and (2) exploit enabling: bugs in io_uring surfaces (ring mappings, registered resources) have historically been the vector for privilege escalation, after which an attacker can install kernel hooks by more traditional means. io_uring also bypasses some libc wrappers if code submits operations directly, so userland hooking that intercepts libc calls may be circumvented. A simple submit/reap flow is illustrated below:

// Minimal io_uring usage (error handling omitted)

// io_uring context (SQ/CQ rings shared with kernel)
struct io_uring ring;

// Initialize ring with space for 16 SQEs
io_uring_queue_init(16, &ring, 0);

// Grab a free submission entry (or NULL if full)
struct io_uring_sqe *sqe =
    io_uring_get_sqe(&ring);

// Prepare SQE as a read(fd, buf, len, offset=0)
io_uring_prep_read(sqe, fd, buf, len, 0);

// Submit pending SQEs to the kernel (non-blocking)
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
// Block until a completion is available
io_uring_wait_cqe(&ring, &cqe);
// Mark the completion as handled (free slot)
io_uring_cqe_seen(&ring, cqe);

The example above shows a submission queue feeding many file ops into the kernel with a single or a few io_uring_enter syscalls, reducing per-operation syscall telemetry.

Adversaries interested in stealthy data collection or high-throughput exfiltration may switch to io_uring to reduce syscall noise. io_uring does not inherently install global hooks or change other processes’ behavior; it is process-local unless combined with privilege escalation. Detection is possible by instrumenting the io_uring syscalls (io_uring_enter, io_uring_register) and watching for anomalous patterns: unusually large batches, many registered files/buffers, or processes that perform heavy batched metadata operations. Kernel version differences also matter: io_uring features evolve quickly, so attacker techniques may be version-dependent. Finally, because io_uring requires a running malicious process, defenders can often interrupt it and inspect its rings, registered files, and memory mappings to uncover misuse.

Conclusion

Hooking techniques in Linux have come a long way from simply overwriting a pointer in a table. We now see attackers exploiting legitimate kernel instrumentation frameworks (ftrace, kprobes, eBPF) to implant hooks that are harder to detect. Each method, from IDT and syscall table patches to inline hooks and dynamic probes, has its own trade-offs in stealth and stability. Defenders need to be aware of all these possible vectors. In practice, modern rootkits often combine multiple hooking techniques to achieve their goals. For example, PUMAKIT uses a direct syscall table hook and ftrace hooks, and Diamorphine uses syscall hooks plus a kprobe to get around symbol hiding. This layered approach means detection tools must check many facets of the system: IDT entries, syscall tables, model-specific registers (for sysenter hooks), integrity of function prologues, contents of critical function pointers in structures (VFS, etc.), active ftrace ops, registered kprobes, and loaded eBPF programs.

In part two of this series, we move from theory to practice. Armed with the understanding of rootkit taxonomy and hooking techniques covered here, we will focus on detection engineering, building and applying practical detection strategies to identify these threats in real-world Linux environments.

Share this article