FlipSwitch: una novedosa técnica de enganche de llamadas al sistema
El enganche de llamadas al sistema, particularmente al sobreescribir los punteros a los controladores de llamadas del sistema, fue una piedra angular de los rootkits de Linux como Diamorphine y PUMAKIT, lo que les permite ocultar su presencia y controlar el flujo de información. Si bien existen otros mecanismos de enganche, como ftrace y eBPF, cada uno tiene sus propios pros y contras, y la mayoría tiene algún tipo de limitación. Las sobrescrituras de punteros de función siguen siendo la forma más efectiva y sencilla de enganchar llamadas al sistema en el kernel.
Sin embargo, el kernel de Linux es un objetivo móvil. Con cada nueva versión, la comunidad introduce cambios que pueden hacer que clases enteras de malware queden obsoletas de la noche a la mañana. Esto es precisamente lo que sucedió con el lanzamiento del kernel de Linux 6.9, que introdujo un cambio fundamental en el mecanismo de envío de llamadas del sistema para la arquitectura x86-64, neutralizando efectivamente los métodos tradicionales de enganche de llamadas al sistema.
Las paredes se están cerrando: la muerte de una técnica tradicional de enganche
Para apreciar la importancia de los cambios en el kernel 6.9, primero revisemos el método tradicional de enlace de llamadas al sistema. Durante años, el kernel usó una matriz simple de punteros de función llamada sys_call_table para enviar llamadas al sistema. La lógica era maravillosamente simple, como se ve en el código fuente del kernel:
// Pre-6.9: Direct array lookup
sys_call_table[__NR_kill](regs);
Un rootkit podría ubicar esta tabla en la memoria, deshabilitar la protección contra escritura y sobreescribir la dirección de una llamada al sistema como kill o getdents64 con un puntero a su propia función controlada por el adversario. Esto permite a un rootkit filtrar la salida del comando ls para ocultar archivos maliciosos o evitar que se finalice un proceso específico, por ejemplo. Pero la franqueza de este mecanismo también fue su debilidad. Con el kernel de Linux 6.9, el juego cambió por completo cuando la búsqueda directa de matrices fue reemplazada por un mecanismo de despacho basado en declaraciones switch más eficiente y seguro:
// 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);
}
}
Este cambio, aunque aparentemente sutil, fue un golpe mortal para el enganche tradicional de syscall. El sys_call_table todavía existe para la compatibilidad con las herramientas de rastreo, pero ya no se usa para el envío real de llamadas al sistema. Cualquier modificación simplemente se ignora.
Encontrar una nueva forma de entrar: la técnica FlipSwitch
Sabíamos que el kernel todavía tenía que llamar a las funciones syscall originales de alguna manera. La lógica seguía ahí, solo que oculta detrás de una nueva capa de indirección. Esto llevó al desarrollo de FlipSwitch, una técnica que omite la implementación de la nueva instrucción switch al parchear directamente el código de máquina compilado del syscall dispatcher del kernel.
Aquí hay un desglose de cómo funciona:
El primer paso es encontrar la dirección de la función syscall original que queremos enganchar. Irónicamente, el ahora desaparecido sys_call_table es la herramienta perfecta para esto. Todavía podemos buscar la dirección de sys_kill en esta tabla para obtener un avanzado confiable a la función original.
Un método común para localizar símbolos de kernel es la función kallsyms_lookup_name . Esta función proporciona una forma programática de encontrar la dirección de cualquier símbolo de kernel exportado por su nombre. Por ejemplo, podemos usar kallsyms_lookup_name("sys_kill") para obtener la dirección de la función sys_kill , proporcionando una forma flexible y confiable de obtener punteros de función incluso cuando el sys_call_table no se puede usar directamente para el envío.
Es importante tener en cuenta que kallsyms_lookup_name generalmente no se exporta de forma predeterminada, lo que significa que no es directamente accesible para los módulos del kernel cargables. Esta restricción mejora la seguridad del kernel. Sin embargo, una técnica común para acceder indirectamente a kallsyms_lookup_name es mediante el uso de un kprobe. Al colocar un kprobe en una función del kernel conocida, un módulo puede usar la estructura interna del kprobe para derivar la dirección de la función original sondeada. A partir de esto, a menudo se puede obtener un puntero de función a kallsyms_lookup_name mediante un análisis cuidadoso del diseño de memoria del kernel, como examinar las regiones de memoria cercanas en relación con la dirección de la función sondeada.
/**
* 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;
}
Luego de encontrar la dirección de kallsyms_lookup_name, podemos usarla para encontrar punteros a los símbolos que necesitamos para continuar el proceso de colocar un gancho.
Con la dirección de destino en la mano, dirigimos nuestra atención a la función x64_sys_call , el nuevo hogar de la lógica de envío de llamadas al sistema. Comenzamos a escanear su código de máquina sin procesar, byte por byte, en busca de una instrucción de llamada. En x86-64, la instrucción de llamada tiene un código de operación específico de un byte: 0xe8. Este byte es seguido por un desplazamiento relativo de 4 bytes que le dice a la CPU a dónde saltar.
Aquí es donde ocurre la magia. No solo estamos buscando cualquier instrucción de llamada. Estamos buscando una instrucción de llamada que, cuando se combina con su desplazamiento de 4 bytes, apunte directamente a la dirección de la función sys_kill original que encontramos anteriormente. Esta combinación del código de operación 0xe8 y el desplazamiento específico es una firma única dentro de la función x64_sys_call . Solo hay una instrucción que coincide con este patrón.
/* 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);
Una vez que localizamos esta instrucción única, encontramos nuestro punto de inserción. Pero antes de que podamos modificar el código del kernel, debemos omitir sus protecciones de memoria. Dado que ya estamos ejecutando dentro del kernel (anillo 0), podemos usar una técnica tradicional y poderosa: deshabilitar la protección contra escritura volteando un bit en el registro CR0 . El registro CR0 controla las funciones básicas del procesador y su bit 16 (protección contra escritura) evita que la CPU escriba en páginas de solo lectura. Al borrar temporalmente este bit, nos permitimos modificar cualquier parte de la memoria del kernel.
/**
* 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);
}
Con la protección contra escritura deshabilitada, sobreescribir el desplazamiento de 4 bytes de la instrucción de llamada con un nuevo desplazamiento que apunta a nuestra propia función fake_kill . De hecho, hemos "accionado el interruptor" dentro del propio despachador del kernel, redirigiendo una sola llamada al sistema a nuestro código malicioso mientras dejamos intacto el resto del sistema.
Esta técnica es precisa y confiable. Y, significativamente, todos los cambios se revierten por completo cuando se descarga el módulo del kernel, sin dejar rastro de su presencia.
El desarrollo de FlipSwitch es un testimonio del juego del gato y el mouse en curso entre atacantes y defensores. A medida que los desarrolladores del kernel continúen fortaleciendo el kernel de Linux, los atacantes continuarán encontrando formas nuevas y creativas de eludir estas defensas. Esperamos que al compartir esta investigación, podamos ayudar a la comunidad de seguridad a mantener un paso adelante.
Detección de malware
Detectar rootkits una vez que se cargaron en el kernel es excepcionalmente difícil, ya que están diseñados para operar sigilosamente y evadir la detección de las herramientas de seguridad. Sin embargo, desarrollamos una firma YARA para identificar la prueba de concepto de FlipSwitch. Esta firma se puede emplear para detectar la presencia del rootkit FlipSwitch en la memoria o en el disco.
YARA
Elastic Security creó reglas de YARA para identificar esta actividad. A continuación se muestran las reglas de YARA para identificar la prueba de concepto de Flipswitch.
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_*))
}
Referencias
A lo largo de la investigación anterior se hizo referencia a lo siguiente:
