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プロセスはルートとして動作し、いくつかのメカニズムから限られた最小権限しかない状態に遷移する。
Linux capabilities http://man7.org/linux/man-pages/man7/capabilities.7.html デフォルトではDockerコンテナは限られた権限しか保持しておらず、それ故コンテナ内のルートユーザは事実上の一般ユーザとなる。
seccomp http://man7.org/linux/man-pages/man2/seccomp.2.html ホストへの影響を制限するために、コンテナプロセスに対して一部のシステムコールをブロックしたり環境変数のフィルターなどを行う。
namespaces http://man7.org/linux/man-pages/man7/namespaces.7.html コンテナ化されたプロセスからホストのファイルシステムに対するアクセスを制限する。加えてホスト及びコンテナ間でのプロセスの参照を制限する。
cgroups http://man7.org/linux/man-pages/man7/cgroups.7.html プロセスグループのリソースに対する制限や管理を行う。
明示的に全てのメカニズムを無効にすることや一部の機能を指定して使用することは可能である。それらの機能を無効にすることで簡単にコンテナからエスケープすることは容易である。よって今回はデフォルトの設定で動作しているコンテナを見ていく。
失敗したアプローチ
当該脆弱性を見つけるまでに様々なアプローチを行なったが、それらのほとんどは制限に用いられている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/exe
はld-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を使用していると思われるため)。
修正案
却下された案
実行対象の実行形式と
/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に対して書き込み権限を保持していない)。