レガシーガジェット研究所

気になったことのあれこれ。

CVE-2019-5736に関して

概要

当該脆弱性を悪用して細工したコンテナをユーザが実行した場合、ホスト上のruncバイナリが上書きされ、コンテナが起動しているホスト上でroot権限でコマンドが実行される恐れがある。

ここからは以下のドキュメントを雑に訳したものとなる(間違いがあれば指摘いただけると・・・

https://blog.dragonsector.pl/2019/02/cve-2019-5736-escape-from-docker-and.html

ゴール及び結果

目的はデフォルト若しくはハーデニングされた環境(制限された権限とシステムコール)でホスト環境を汚染することである。以下のアタックベクタが考えられる。

  • 悪意あるDockerイメージ
  • コンテナ内の悪性あるプロセス(Docker内でルートとして動作しているサービス)

最終的にホスト上で、全ての権限がある状態でのコード実行が成功した(ルート権限)。これは以下のいずれかによって引き起こされる。

  • 汚染されたDockerコンテナ上でホストからdocker execを実行する
  • 悪意あるDockerイメージを実行する

Dockerのデフォルト設定

Dockerはサンドボックスとして知られている訳ではないが、デフォルトの設定ではホストのリソースはコンテナ内で動作しているプロセスのアクセスからは守られているとされている。

しかしDockerコンテナのinitプロセスはルートとして動作し、いくつかのメカニズムから限られた最小権限しかない状態に遷移する。

明示的に全てのメカニズムを無効にすることや一部の機能を指定して使用することは可能である。それらの機能を無効にすることで簡単にコンテナからエスケープすることは容易である。よって今回はデフォルトの設定で動作しているコンテナを見ていく。

失敗したアプローチ

当該脆弱性を見つけるまでに様々なアプローチを行なったが、それらのほとんどは制限に用いられているseccompのフィルターや制限された権限によって回避された。

新しいプロセスが既存の名前空間で起動する際に何が起こるのかを調査した(docker exec)。名前空間に新たに参加したプロセスがホストのリソースにアクセスできるのか、特に使用している名前空間に参加する前にそのプロセスにアクセスできる方法がないかを確認した。

処理は以下のように進行する。

もしプロセスが見えた段階でptraceできれば、残りの名前空間への参加を回避しホストのファイルシステムなどにアクセスが可能となる。

ptraceするために必要とされる権限を保持しないことは、コンテナのinitプロセスで行われるユーザ名前空間unshareによってバイパスすることが可能(よって新たな名前空間で全ての権限を保持する)。そしてdocker exec/proc/pid/ns/を通して新たな名前空間に参加する、これはptraceでトレース可能である(seccompの制限は以前適応されている) runCでは必要な名前空間に参加した後、forkしていることがわかった、これはこのアタックベクタを回避するものである。加えてDockerのデフォルト設定では名前空間に関連するシステムコールは全て無効になっている。

次に目を付けたのはprocfsである。これは特別で頻繁に名前空間の境界をまたぐ。興味深いのは以下の点である。

  • /proc/pid/mem 充分に情報は取得できず既に悪性あるプロセスと同じPID名前空間にいる必要がある。

  • /proc/pid/cwd,/proc/pid/root プロセスが完全にコンテナに参加する前(名前空間に入った後だがルートディレクトリやカレントディレクトリを切り替える前)、これらのファイルはホストのファイルシステムを指している。これはアクセスの可能性があると考えたがrunCのプロセスではダンプが不可能だったため、これらのファイルは使用できなかった。

  • /proc/pid/exe それ自体では使用できなかったが、最終的なエクスプロイトでは使用する方法を発見した。

  • /proc/pid/fd/  いくつかのファイルディスクリプタは以前の名前空間(特にmount名前空間)の情報リークもしくは親プロセスと子プロセスのやりとりを妨害できる可能性を考えたが、ローカルソケットとの同期は終了しており(再利用は不可)興味深いものは特に発見できなかった。

  • /proc/pid/map_files/ とても興味深いベクタである。runCが対象のバイナリを実行する前(当該プロセスは見えておりPID名前空間には属している)の段階では、全てのエントリはホストファイルシステムを参照していた(当該プロセスはホスト名前空間で生成されたため)が、SYS_ADMIN権限無しでは当該プロセス内からでさえも、それらリンクを参照することが不可能であることがわかった。

サイドノート1

以下のコマンド実行する場合、/proc/self/exeld-linux-x86-64.so.2を指している(/bin/lsではない)

/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /bin/ls -al /proc/self/exe

攻撃のアイディアとしてはコンテナ内で実行形式ファイルを実行させるため、ホストからダイナミックローダを使用してdocker execの実行を強制することだった(本来実行される/bin/bashを1行目に#!/proc/self/map_files/address-in-memory-of-ld.so /evil_binaryの記述があるテキストファイルに置き換える)。そのバイナリは/proc/self/exeを書き換え、それ故ホストのld.soをも書き換える。当該アプローチは前述のSYS_ADMIN権限が無く失敗に終わった。

サイドノート2

上記の実験をしている際にカーネル内でデッドロックが生じることを発見した。通常プロセスが/proc/self/map_files/any-existing-entryを実行しようとするとデッドロックが起こる。(他のプロセスから/proc/that-process-pid/mapsを開くとハングする。おそらくロックが取得されているため)

成功したアプローチ

最終的に成功した試みは前述の/proc/self/map_filesのアイディアに類似している。まだコードインジェクトションが可能な間に/proc/self/exe(ホストのdocker-runcバイナリ)を実行する(libc.soなどの共有ライブラリを変更し、libc_start_mainやグローバルコンストラクタ内で用意したコードを実行する)。これは/proc/self/exe(ホストのdocker-runCバイナリ)を書き換えることを可能とし、次にホスト上でdocker-runcを実行する時に全ての権限を保持したルートアクセスが可能となる。

攻撃の詳細

汚染されたイメージの作成または動作中のコンテナへの汚染。

  • エントリポイントとなるバイナリ(エントリポイントとしてユーザにオーバーライドされるようなバイナリ、もしくはdocker execの一部)を/proc/self/exeのシンポリックリンクにする。
  • docker-runcが使用する共有ライブラリをグローバルコンストラクタを持った共有ライブラリで置き換える。その関数は/proc/self/exe(docker-runc)を読み取りモードで開く(当該バイナリが実行中であるため書き込みモードでは開くことができない)。次にその関数は/proc/self/fd/3を用いて当該ディスクリプタに対応したファイルを書き込みモードで開き他のバイナリを実行する、これはdocker-runcがもう実行されていないため成功する。その実行したバイナリのコードはホスト上のdocker-runcを書き換え、書き換えたニセのdocker-runcを選択しグローバルコンストラクタから任意のコードを実行する。

それ故ホストユーザが汚染されたイメージの実行若しくは汚染されたコンテナに対してdocker execを実行した時に以下の事象が起こる。

  • /proc/self/exe(ホスト上ではdocker-runcを指している)にシンボリックリンクを貼ったエントリポイントのでるバイナリがコンテナ内で実行を始める(execveがダンプ可能フラグをセットするためプロセスのダンプが可能となる)。これはコンテナ内で動作している新たなdocker-runcの中でもう一度オリジナルのdocker-runcを実行させる(ホストのバイナリは使用していない)。
  • docker-runcの二度目の実行でコンテナ内の共有ライブラリ(ファイルシステムがホストのものでは無いため)をロードする(当該ライブラリの内容はコントロールしている)
  • 悪意あるグローバルコンストラクタ関数が呼び出され、/proc/self/exeを読み取りモードで開き(ファイルディスクリプタの3番を通して)execve()で攻撃者の管理下にあるバイナリ( ここではevilと呼ぶ)を実行する。
  • evilは悪意あるdocker-runcで(追加されたグローバルコンストラクタを用いて)ホストファイルシステム上のdocker-runcを書き換える(書き込みモードでファイルディスクリプタの3番を開く)。
  • コンテナの起動若しくはexecが完了すると、攻撃者が用意したニセのdocker-runcがホストのファイルシステム上で全ての権限を持ったルートとして実行される(このバイナリは権限を破棄し名前空間に入る必要があるため、初期の段階では全てのパーミッションを持っている)。

上記の攻撃手法はruncの挙動のみを妨害する、よって当該攻撃手法はDocker若しくはcri-oを用いているかに関わらずkubernetesにおいても成功する(内部でruncを使用していると思われるため)。

当該攻撃手法はAWSGCPにも影響を与える。

修正案

却下された案

  • 実行対象の実行形式と/proc/self/exeのinodeをfstatを用いて比較し同一のものであった場合にexit()し、そうでなければ実行対象のファイルディスクリプタを用いてexecveatを呼び出す。

    実行対象バイナリが/proc/self/exeのシンボリックであることは検出できるが、なぜexecveatなのか。execを実行し比較した際に他のプロセスが"/proc/self/exe."のシンボリックリンクとなっているバイナリをリプレイスすることによって起こるレースコンディションを回避するためである。しかし攻撃者がシンボリックリンクを使用せず、ローダが/proc/self/exeを指している(例えば1行目に#!/proc/self/exeなどの記述があるテキストファイルである)場合にバイパスできてしまう。

  • コンテナで実行するプロセスにライブラリを静的リンクさせる。

    コンテナ内で悪意ある共有ライブラリを通じて行うコード実行を回避する(静的リンクされたバイナリは共有ライブラリをロードしないため)案だが、共有ライブラリの置き換えはこのエクスプロイトにおいて決して必要ではない。/proc/self/exe(docker-runc)の再実行後、他のプロセスが/proc/<pid-of-docker-runc>/exeを開くことが可能である(execve()の際にダンプ可能フラグがセットされるため)。再度実行が完了しruncのプロセスが存在しているタイミングに合わせる必要があるためエクスプロイトの作成が困難になる。しかし実際はタイミングの幅はかなり大きく先ほどのシナリオで100%成功するエクスプロイトを開発した。しかしこれは"汚染されたイメージの実行"というアタックベクタを排除するにとどまった。`

採用された案

最終的に以下の修正が当該脆弱性を軽減させるために採用された。

  • memfd(file descriptor)の作成(メモリ上のみに存在する特別なファイル)
  • 上記のfdにオリジナルのruncバイナリをコピー
  • 名前空間に入る前にそのfdからruncを再度実行する

この修正はもし攻撃者が/proc/self/exeで指されているバイナリを書き換えても、それはメモリ上にコピーされたものであるためにホスト上にはなんの影響も出ないことを保証する。

軽減策

パッチが適応されていないruncではいくつかの軽減策がある。

  • DockerコンテナをSELinux(--selinux-enabled)を有効にした状態で使用する。これはコンテナ内のプロセスからホスト上のdocker-runcバイナリの書き換えを不可能にする。
  • 読み取り専用のファイルシステムを使用する(少なくともdocker-runcを保持しているものに対して)
  • コンテナ内の低い権限を保持しているユーザを使用する、若しくはuid0がそのユーザマップされた新しい名前空間を使用する(それ故そのユーザはホスト上のdocker-runcに対して書き込み権限を保持していない)。

参考文献