FlipSwitch: uma nova técnica de conexão de chamadas de sistema
O syscall hooking, principalmente por meio da substituição de ponteiros para manipuladores de syscall, tem sido a base de rootkits Linux como Diamorphine e PUMAKIT, permitindo que eles ocultem sua presença e controlem o fluxo de informações. Embora existam outros mecanismos de interceptação, como ftrace e eBPF, cada um tem seus prós e contras, e a maioria tem alguma forma de limitação. Substituições de ponteiros de função continuam sendo a maneira mais eficaz e simples de conectar chamadas de sistema no kernel.
No entanto, o kernel do Linux é um alvo móvel. Com cada novo lançamento, a comunidade introduz mudanças que podem tornar classes inteiras de malware obsoletas da noite para o dia. Foi precisamente isso que aconteceu com o lançamento do kernel Linux 6.9, que introduziu uma mudança fundamental no mecanismo de despacho de syscall para a arquitetura x86-64, neutralizando efetivamente os métodos tradicionais de interceptação de syscall.
As paredes estão se fechando: a morte de uma técnica clássica de prostituição
Para entender a importância das mudanças no kernel 6.9, vamos primeiro revisitar o método clássico de interceptação de chamadas de sistema. Durante anos, o kernel usou uma matriz simples de ponteiros de função chamada sys_call_table para despachar chamadas de sistema. A lógica era muito simples, como visto no código-fonte do kernel:
// Pre-6.9: Direct array lookup
sys_call_table[__NR_kill](regs);
Um rootkit poderia localizar essa tabela na memória, desabilitar a proteção contra gravação e substituir o endereço de uma chamada de sistema como kill ou getdents64 com um ponteiro para sua própria função controlada pelo adversário. Isso permite que um rootkit filtre a saída do comando ls para ocultar arquivos maliciosos ou impedir que um processo específico seja encerrado, por exemplo. Mas a franqueza desse mecanismo também era sua fraqueza. Com o kernel Linux 6.9, o jogo mudou completamente quando a pesquisa direta de array foi substituída por um mecanismo de despacho baseado em instruções switch mais eficiente e 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);
}
}
Essa mudança, embora aparentemente sutil, foi um golpe mortal para o syscall hooking tradicional. O sys_call_table ainda existe para compatibilidade com ferramentas de rastreamento, mas não é mais usado para o despacho real de syscalls. Quaisquer modificações são simplesmente ignoradas.
Encontrando uma nova maneira de entrar: a técnica FlipSwitch
Sabíamos que o kernel ainda precisava chamar as funções syscall originais de alguma forma. A lógica ainda estava lá, apenas escondida atrás de uma nova camada de indireção. Isso levou ao desenvolvimento do FlipSwitch, uma técnica que ignora a nova implementação da instrução switch ao corrigir diretamente o código de máquina compilado do despachante de chamada de sistema do kernel.
Veja aqui uma análise de como isso funciona:
O primeiro passo é encontrar o endereço da função syscall original que queremos conectar. Ironicamente, o extinto sys_call_table é a ferramenta perfeita para isso. Ainda podemos procurar o endereço de sys_kill nesta tabela para obter um ponteiro confiável para a função original.
Um método comum para localizar símbolos do kernel é a função kallsyms_lookup_name . Esta função fornece uma maneira programática de encontrar o endereço de qualquer símbolo de kernel exportado pelo seu nome. Por exemplo, podemos usar kallsyms_lookup_name("sys_kill") para obter o endereço da função sys_kill , fornecendo uma maneira flexível e confiável de obter ponteiros de função mesmo quando sys_call_table não pode ser usado diretamente para despacho.
É importante observar que kallsyms_lookup_name geralmente não é exportado por padrão, o que significa que não é diretamente acessível aos módulos carregáveis do kernel. Essa restrição aumenta a segurança do kernel. Entretanto, uma técnica comum para acessar indiretamente kallsyms_lookup_name é usar um kprobe. Ao colocar um kprobe em uma função de kernel conhecida, um módulo pode então usar a estrutura interna do kprobe para derivar o endereço da função original sondada. A partir disso, um ponteiro de função para kallsyms_lookup_name pode frequentemente ser obtido por meio de uma análise cuidadosa do layout de memória do kernel, como examinando regiões de memória próximas em relação ao endereço da função sondada.
/**
* 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;
}
Depois de encontrar o endereço de kallsyms_lookup_name, podemos usá-lo para encontrar ponteiros para os símbolos que precisamos para continuar o processo de colocação de um gancho.
Com o endereço de destino em mãos, voltamos nossa atenção para a função x64_sys_call , o novo lar da lógica de despacho da chamada de sistema. Começamos a escanear seu código de máquina bruto, byte por byte, procurando por uma instrução de chamada. Em x86-64, a instrução de chamada tem um opcode específico de um byte: 0xe8. Este byte é seguido por um deslocamento relativo de 4 bytes que informa à CPU para onde saltar.
É aqui que a mágica acontece. Não estamos procurando apenas qualquer instrução de chamada. Estamos procurando uma instrução de chamada que, quando combinada com seu deslocamento de 4 bytes, aponte diretamente para o endereço da função sys_kill original que encontramos anteriormente. Esta combinação do opcode 0xe8 e do deslocamento específico é uma assinatura única dentro da função x64_sys_call . Há apenas uma instrução que corresponde a esse padrão.
/* 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);
Depois de localizar essa instrução exclusiva, encontramos nosso ponto de inserção. Mas antes de podermos modificar o código do kernel, precisamos ignorar suas proteções de memória. Como já estamos executando dentro do kernel (anel 0), podemos usar uma técnica clássica e poderosa: desabilitar a proteção contra gravação invertendo um bit no registrador CR0 . O registro CR0 controla funções básicas do processador, e seu 16º bit (Write Protect) impede que a CPU grave em páginas somente leitura. Ao limpar temporariamente esse bit, permitimos que você modifique qualquer parte da memória do 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);
}
Com a proteção contra gravação desabilitada, substituímos o deslocamento de 4 bytes da instrução de chamada por um novo deslocamento que aponta para nossa própria função fake_kill . Na verdade, "acionamos o interruptor" dentro do próprio despachante do kernel, redirecionando uma única chamada de sistema para nosso código malicioso, enquanto deixamos o resto do sistema intocado.
Esta técnica é precisa e confiável. E, significativamente, todas as alterações são totalmente revertidas quando o módulo do kernel é descarregado, não deixando nenhum vestígio de sua presença.
O desenvolvimento do FlipSwitch é uma prova do jogo de gato e rato entre atacantes e defensores. À medida que os desenvolvedores do kernel continuam a fortalecer o kernel do Linux, os invasores continuarão a encontrar maneiras novas e criativas de contornar essas defesas. Esperamos que, ao compartilhar esta pesquisa, possamos ajudar a comunidade de segurança a ficar um passo à frente.
Detectando malware
Detectar rootkits depois que eles são carregados no kernel é excepcionalmente difícil, pois eles são projetados para operar furtivamente e evitar a detecção por ferramentas de segurança. No entanto, desenvolvemos uma assinatura YARA para identificar a prova de conceito do FlipSwitch. Esta assinatura pode ser usada para detectar a presença do rootkit FlipSwitch na memória ou no disco.
YARA
O Elastic Security criou regras YARA para identificar essa atividade. Abaixo estão as regras YARA para identificar a prova de conceito do 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_*))
}
Referências
Os seguintes itens foram referenciados ao longo da pesquisa acima:
