FlipSwitch : une nouvelle technique d'accrochage de Syscall
Le crochetage de syscall, notamment par l'écrasement des pointeurs vers les gestionnaires de syscall, a été la pierre angulaire de rootkits Linux tels que Diamorphine et PUMAKIT, leur permettant de dissimuler leur présence et de contrôler le flux d'informations. Il existe d'autres mécanismes d'accrochage, tels que ftrace et eBPF, mais chacun présente ses propres avantages et inconvénients, et la plupart d'entre eux sont limités d'une manière ou d'une autre. L'écrasement de pointeurs de fonction reste le moyen le plus efficace et le plus simple d'accéder aux appels de service dans le noyau.
Cependant, le noyau Linux est une cible mouvante. À chaque nouvelle version, la communauté apporte des modifications qui peuvent rendre obsolètes des classes entières de logiciels malveillants du jour au lendemain. C'est précisément ce qui s'est passé avec la sortie du noyau Linux 6.9, qui a introduit un changement fondamental dans le mécanisme de distribution des appels de service pour l'architecture x86-64, neutralisant efficacement les méthodes traditionnelles de crochetage des appels de service.
Les murs se referment : La mort d'une technique de crochetage classique
Pour comprendre l'importance des changements apportés par le noyau 6.9, revenons tout d'abord sur la méthode classique du crochetage de syscall. Pendant des années, le noyau a utilisé un simple tableau de pointeurs de fonctions appelé sys_call_table pour envoyer les appels de service. La logique était magnifiquement simple, comme le montre la source du noyau :
// Pre-6.9: Direct array lookup
sys_call_table[__NR_kill](regs);
Un rootkit pourrait localiser cette table en mémoire, désactiver la protection en écriture et remplacer l'adresse d'un syscall tel que kill ou getdents64 par un pointeur vers sa propre fonction contrôlée par l'adversaire. Cela permet à un rootkit de filtrer la sortie de la commande ls afin de cacher les fichiers malveillants ou d'empêcher l'arrêt d'un processus spécifique, par exemple. Mais le caractère direct de ce mécanisme est aussi sa faiblesse. Avec le noyau Linux 6.9, le jeu a complètement changé lorsque la recherche directe dans les tableaux a été remplacée par un mécanisme de répartition basé sur les instructions de commutation, plus efficace et plus sûr :
// 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);
}
}
Ce changement, en apparence subtil, a porté un coup fatal au crochetage traditionnel de syscall. Le site sys_call_table existe toujours pour des raisons de compatibilité avec les outils de traçage, mais il n'est plus utilisé pour l'envoi proprement dit des appels syscall. Toute modification est simplement ignorée.
Trouver une nouvelle voie d'accès : La technique de l'interrupteur
Nous savions que le noyau devait toujours appeler les fonctions syscall d'origine d'une manière ou d'une autre. La logique était toujours là, mais cachée derrière une nouvelle couche d'indirection. Cela a conduit au développement de FlipSwitch, une technique qui contourne la nouvelle implémentation de l'instruction de commutation en corrigeant directement le code machine compilé du répartiteur de syscall du noyau.
Voici comment cela fonctionne :
La première étape consiste à trouver l'adresse de la fonction syscall originale que nous voulons accrocher. Ironiquement, le site sys_call_table, aujourd'hui disparu, est l'outil idéal pour cela. Nous pouvons toujours consulter l'adresse de sys_kill dans ce tableau pour obtenir un pointeur fiable vers la fonction d'origine.
Une méthode courante pour localiser les symboles du noyau est la fonction kallsyms_lookup_name. Cette fonction fournit un moyen programmatique de trouver l'adresse de n'importe quel symbole exporté du noyau par son nom. Par exemple, nous pouvons utiliser kallsyms_lookup_name("sys_kill") pour obtenir l'adresse de la fonction sys_kill, ce qui constitue un moyen souple et fiable d'obtenir des pointeurs de fonction même lorsque sys_call_table n'est pas directement utilisable pour l'envoi.
Il est important de noter que kallsyms_lookup_name n'est généralement pas exporté par défaut, ce qui signifie qu'il n'est pas directement accessible aux modules du noyau chargeables. Cette restriction renforce la sécurité du noyau. Toutefois, une technique courante pour accéder indirectement à kallsyms_lookup_name consiste à utiliser kprobe. En plaçant un kprobe sur une fonction noyau connue, un module peut alors utiliser la structure interne du kprobe pour dériver l'adresse de la fonction originale sondée. À partir de là, un pointeur de fonction vers kallsyms_lookup_name peut souvent être obtenu par une analyse minutieuse de la disposition de la mémoire du noyau, par exemple en examinant les régions de mémoire voisines par rapport à l'adresse de la fonction sondée.
/**
* 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;
}
Après avoir trouvé l'adresse de kallsyms_lookup_name, nous pouvons l'utiliser pour trouver des pointeurs vers les symboles dont nous avons besoin pour poursuivre le processus de placement d'un crochet.
Avec l'adresse cible en main, nous portons notre attention sur la fonction x64_sys_call, le nouveau siège de la logique d'envoi des appels de service. Nous commençons à analyser son code machine brut, octet par octet, à la recherche d'une instruction d'appel. Sur x86-64, l'instruction call a un opcode spécifique d'un octet : 0xe8. Cet octet est suivi d'un décalage relatif de 4 octets qui indique à l'unité centrale où sauter.
C'est là que la magie opère. Nous ne cherchons pas à obtenir n'importe quelle instruction d'appel. Nous recherchons une instruction d'appel qui, combinée à son décalage de 4 octets, pointe directement vers l'adresse de la fonction originale sys_kill que nous avons trouvée précédemment. Cette combinaison de l'opcode 0xe8 et du décalage spécifique constitue une signature unique au sein de la fonction x64_sys_call. Il n'y a qu'une seule instruction qui corresponde à ce modèle.
/* 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);
Une fois que nous avons localisé cette instruction unique, nous avons trouvé notre point d'insertion. Mais avant de pouvoir modifier le code du noyau, nous devons contourner ses protections de mémoire. Puisque nous exécutons déjà dans le noyau (ring 0), nous pouvons utiliser une technique classique et puissante : désactiver la protection contre l'écriture en retournant un bit dans le registre CR0. Le registre CR0 contrôle les fonctions de base du processeur, et son 16e bit (Write Protect) empêche le processeur d'écrire dans les pages en lecture seule. En effaçant temporairement ce bit, nous nous autorisons à modifier n'importe quelle partie de la mémoire du noyau.
/**
* 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);
}
La protection contre l'écriture étant désactivée, nous remplaçons le décalage de 4 octets de l'instruction d'appel par un nouveau décalage qui pointe vers notre propre fonction fake_kill. Nous avons, en effet, ", actionné l'interrupteur" à l'intérieur du répartiteur du noyau, redirigeant un seul appel de système vers notre code malveillant tout en laissant le reste du système intact.
Cette technique est à la fois précise et fiable. De plus, toutes les modifications sont entièrement annulées lorsque le module du noyau est déchargé, ce qui ne laisse aucune trace de sa présence.
Le développement de FlipSwitch témoigne du jeu du chat et de la souris auquel se livrent actuellement les attaquants et les défenseurs. Alors que les développeurs du noyau continuent à renforcer le noyau Linux, les attaquants continueront à trouver des moyens nouveaux et créatifs de contourner ces défenses. Nous espérons qu'en partageant cette recherche, nous pourrons aider la communauté de la sécurité à garder une longueur d'avance.
Détection des logiciels malveillants
Il est extrêmement difficile de détecter les rootkits une fois qu'ils ont été chargés dans le noyau, car ils sont conçus pour fonctionner de manière furtive et échapper à la détection par les outils de sécurité. Cependant, nous avons développé une signature YARA pour identifier la preuve de concept de FlipSwitch. Cette signature peut être utilisée pour détecter la présence du rootkit FlipSwitch dans la mémoire ou sur le disque.
YARA
Elastic Security a créé des règles YARA pour identifier cette activité. Vous trouverez ci-dessous les règles de YARA permettant d'identifier la preuve de concept 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_*))
}
Références
Les éléments suivants ont été référencés tout au long de la recherche ci-dessus :
