FlipSwitch: a Novel Syscall Hooking Technique

FlipSwitch offers a fresh look at bypassing Linux kernel defenses, revealing a new technique in the ongoing battle between cyber attackers and defenders.

10분 읽기공격 패턴
FlipSwitch: a Novel Syscall Hooking Technique

FlipSwitch: a Novel Syscall Hooking Technique

Syscall hooking, particularly by overwriting pointers to syscall handlers, has been a cornerstone of Linux rootkits like Diamorphine and PUMAKIT, enabling them to hide their presence and control the flow of information. While other hooking mechanisms exist, such as ftrace and eBPF, each has its own pros and cons, and most have some form of limitation. Function pointer overwrites remain the most effective and simple way of hooking syscalls in the kernel.

However, the Linux kernel is a moving target. With each new release, the community introduces changes that can render entire classes of malware obsolete overnight. This is precisely what happened with the release of Linux kernel 6.9, which introduced a fundamental change to the syscall dispatch mechanism for x86-64 architecture, effectively neutralizing traditional syscall hooking methods.

The Walls Are Closing In: The Death of a Classic Hooking Technique

To appreciate the significance of the changes in kernel 6.9, let's first revisit the classic method of syscall hooking. For years, the kernel used a simple array of function pointers called the sys_call_table to dispatch syscalls. The logic was beautifully simple, as seen in the kernel source:

// Pre-6.9: Direct array lookup
sys_call_table[__NR_kill](regs);

A rootkit could locate this table in memory, disable write protection, and overwrite the address of a syscall like kill or getdents64 with a pointer to its own adversary-controlled function. This empowers a rootkit to filter the output of the ls command to hide malicious files or prevent a specific process from being terminated, for example. But the directness of this mechanism was also its weakness. With Linux kernel 6.9, the game changed completely when the direct array lookup was replaced with a more efficient and secure switch statement-based dispatch mechanism:

// Kernel 6.9+: Switch-statement dispatch
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
    switch (nr) {
    #include <asm/syscalls_64.h> // Expands to case statements
    default: return __x64_sys_ni_syscall(regs);
    }
}

This change, while seemingly subtle, was a death blow to traditional syscall hooking. The sys_call_table still exists for compatibility with tracing tools, but it is no longer used for the actual dispatch of syscalls. Any modifications to it are simply ignored.

Finding a New Way In: The FlipSwitch Technique

We knew that the kernel still had to call the original syscall functions somehow. The logic was still there, just hidden behind a new layer of indirection. This led to the development of FlipSwitch, a technique that bypasses the new switch statement implementation by directly patching the compiled machine code of the kernel's syscall dispatcher.

Here's a breakdown of how it works:

The first step is to find the address of the original syscall function we want to hook. Ironically, the now-defunct sys_call_table is the perfect tool for this. We can still look up the address of sys_kill in this table to get a reliable pointer to the original function.

A common method to locate kernel symbols is the kallsyms_lookup_name function. This function provides a programmatic way to find the address of any exported kernel symbol by its name. For instance, we can use kallsyms_lookup_name("sys_kill") to obtain the address of the sys_kill function, providing a flexible and reliable way to obtain function pointers even when the sys_call_table is not directly usable for dispatch.

It's important to note that kallsyms_lookup_name is generally not exported by default, meaning it's not directly accessible to loadable kernel modules. This restriction enhances kernel security. However, a common technique to indirectly access kallsyms_lookup_name is by using a kprobe. By placing a kprobe on a known kernel function, a module can then use the kprobe's internal structure to derive the address of the original, probed function. From this, a function pointer to kallsyms_lookup_name can often be obtained through careful analysis of the kernel's memory layout, such as by examining nearby memory regions relative to the probed function's address.

/**
 * Find the address of kallsyms_lookup_name using kprobes
 * @return Pointer to kallsyms_lookup_name function or NULL on failure
 */
void *find_kallsyms_lookup_name(void)
{
    struct kprobe *kp;
    void *addr;

    kp = kzalloc(sizeof(*kp), GFP_KERNEL);
    if (!kp)
        return NULL;

    kp->symbol_name = O_STRING("kallsyms_lookup_name");
    if (register_kprobe(kp) != 0) {
        kfree(kp);
        return NULL;
    }

    addr = kp->addr;
    unregister_kprobe(kp);
    kfree(kp);

    return addr;
}

After finding the address of kallsyms_lookup_name, we can use it to find pointers to the symbols that we need to continue the process of placing a hook.

With the target address in hand, we then turn our attention to the x64_sys_call function, the new home of the syscall dispatch logic. We begin to scan its raw machine code, byte by byte, looking for a call instruction. On x86-64, the call instruction has a specific one-byte opcode: 0xe8. This byte is followed by a 4-byte relative offset that tells the CPU where to jump to.

This is where the magic happens. We're not just looking for any call instruction. We're looking for a call instruction that, when combined with its 4-byte offset, points directly to the address of the original sys_kill function we found previously. This combination of the 0xe8 opcode and the specific offset is a unique signature within the x64_sys_call function. There is only one instruction that matches this pattern.

/* Search for call instruction to sys_kill in x64_sys_call */
    for (size_t i = 0; i < DUMP_SIZE - 4; ++i) {
        if (func_ptr[i] == 0xe8) { /* Found a call instruction */
            int32_t rel = *(int32_t *)(func_ptr + i + 1);
            void *call_addr = (void *)((uintptr_t)x64_sys_call + i + 5 + rel);
            
            if (call_addr == (void *)sys_call_table[__NR_kill]) {
                debug_printk("Found call to sys_kill at offset %zu\n", i);

Once we've located this unique instruction, we've found our insertion point. But before we can modify the kernel's code, we must bypass its memory protections. Since we are already executing within the kernel (ring 0), we can use a classic, powerful technique: disabling write protection by flipping a bit in the CR0 register. The CR0 register controls basic processor functions, and its 16th bit (Write Protect) prevents the CPU from writing to read-only pages. By temporarily clearing this bit, we permit ourselves to modify any part of the kernel's memory.

/**
 * Force write to CR0 register bypassing compiler optimizations
 * @param val Value to write to CR0
 */
static inline void write_cr0_forced(unsigned long val)
{
    unsigned long order;

    asm volatile("mov %0, %%cr0" 
        : "+r"(val), "+m"(order));
}

/**
 * Enable write protection (set WP bit in CR0)
 */
static inline void enable_write_protection(void)
{
    unsigned long cr0 = read_cr0();
    set_bit(16, &cr0);
    write_cr0_forced(cr0);
}

/**
 * Disable write protection (clear WP bit in CR0)
 */
static inline void disable_write_protection(void)
{
    unsigned long cr0 = read_cr0();
    clear_bit(16, &cr0);
    write_cr0_forced(cr0);
}

With write protection disabled, we overwrite the 4-byte offset of the call instruction with a new offset that points to our own fake_kill function. We have, in effect, "flipped the switch" inside the kernel's own dispatcher, redirecting a single syscall to our malicious code while leaving the rest of the system untouched.

This technique is both precise and reliable. And, significantly, all changes are fully reverted when the kernel module is unloaded, leaving no trace of its presence.

The development of FlipSwitch is a testament to the ongoing cat-and-mouse game between attackers and defenders. As kernel developers continue to harden the Linux kernel, attackers will continue to find new and creative ways to bypass these defenses. We hope that by sharing this research, we can help the security community stay one step ahead.

멀웨어 탐지

Detecting rootkits once they have been loaded into the kernel is exceptionally difficult, as they are designed to operate stealthily and evade detection by security tools. However, we have developed a YARA signature to identify the proof-of-concept for FlipSwitch. This signature can be used to detect the presence of the FlipSwitch rootkit in memory or on disk.

YARA

Elastic Security has created YARA rules to identify this activity. Below are YARA rules to identify the Flipswitch proof of concept.

rule Linux_Rootkit_Flipswitch_821f3c9e
{
	meta:
		author = "Elastic Security"
		description = "Yara rule to detect the FlipSwitch rootkit PoC"
		os = "Linux"
		arch = "x86"
		category_type = "Rootkit"
		family = "Flipswitch"
		threat_name = "Linux.Rootkit.Flipswitch"
		
	strings:
		$all_a = { FF FF 48 89 45 E8 F0 80 ?? ?? ?? 31 C0 48 89 45 F0 48 8B 45 E8 0F 22 C0 }
		$obf_b = { BA AA 00 00 00 BE 0D 00 00 00 48 C7 ?? ?? ?? ?? ?? 49 89 C4 E8 }
		$obf_c = { BA AA 00 00 00 BE 15 00 00 00 48 89 C3 E8 ?? ?? ?? ?? 48 89 DF 48 89 43 30 E8 ?? ?? ?? ?? 85 C0 74 0D 48 89 DF E8 }
		$main_b = { 41 54 53 E8 ?? ?? ?? ?? 48 C7 C7 ?? ?? ?? ?? 49 89 C4 E8 ?? ?? ?? ?? 4D 85 E4 74 2D 48 89 C3 48 85 }
		$main_c = { 48 85 C0 74 1F 48 C7 ?? ?? ?? ?? ?? ?? 48 89 C7 48 89 C3 E8 ?? ?? ?? ?? 85 C0 74 0D 48 89 DF E8 ?? ?? ?? ?? 45 31 E4 EB 14 }
		$debug_b = { 48 89 E5 41 54 53 48 85 C0 0F 84 ?? ?? 00 00 48 C7 }
		$debug_c = { 48 85 C0 74 45 48 C7 ?? ?? ?? ?? ?? ?? 48 89 C7 48 89 C3 E8 ?? ?? ?? ?? 85 C0 75 26 48 89 DF 4C 8B 63 28 E8 ?? ?? ?? ?? 48 89 DF E8 }

	condition:
		#all_a>=2 and (1 of ($obf_*) or 1 of ($main_*) or 1 of ($debug_*))
}

참고 자료

위의 조사에서 참조한 내용은 다음과 같습니다: