runCによるDockerコンテナブレークアウト: CVE-2019-5736の解説

This post is also available in: English (英語)

概要

先週(2019年2月11日)、runCの新たな脆弱性が保守管理者によって報告されました。これは、Adam Iwaniuk氏とBorys Poplawski氏によって発見されたものです。CVE-2019-5736と呼ばれるこの脆弱性は、デフォルト設定で実行されているDockerコンテナに影響を及ぼすことから、ホストに対するrootレベルのアクセス権を入手するために攻撃者が利用する可能性があります。
runCの保守管理者の1人であるAleksa Sarai氏は、LXCにも同様の根本的な欠陥が存在することを発見しました。ただし、Dockerとは対照的に、脆弱性があるのは特権LXCコンテナのみです。runCLXCの両方にパッチが適用され、新たなバージョンがリリースされました。

この脆弱性の影響は大きく、多数のテクノロジ サイトや営利企業は、これに特化した投稿で対処しました。ここTwistlockでは、CTOであるJohn Morelloが関連するあらゆる詳細情報とTwistlockプラットフォームから提供される軽減策を使用して、すばらしい記事を書き上げました。

当初、公式のエクスプロイト コードは2019年2月18日まで公開されない予定でした。これは、ユーザーが更新する前に、悪意のある一団がこのコードを兵器化するのを防ぐためでした。しかし、その後の数日間で、一部の人々が独自のエクスプロイト コードをリリースすることにしました。これにより、runCチームは最終的に予定よりも早く(2019年2月13日に)エクスプロイト コードをリリースすることになりました。彼らが言うことには、「秘密が漏れた」からです。

この投稿では、脆弱性とその多様なエクスプロイト方法に対する包括的な技術的考察を提供します。

runCとは?

runCは、元来、Dockerの一部として開発されたコンテナ ランタイムで、後に独立したオープン ソース ツールおよびライブラリとして抽出されました。「低レベル」のコンテナ ランタイムとして、runCは、主に「高レベル」のコンテナ ランタイム(Dockerなど)によって使用されます。ただし、スタンドアロン ツールとして使用することもできます。
Dockerなどの「高レベル」のコンテナ ランタイムは、通常、イメージの作成や管理などの機能を実装し、runCを使用して、実行中のコンテナに関連するタスク、つまり、コンテナの作成、既存のコンテナ(docker exec)へのプロセスの追加などのタスクを処理します。

procfs (プロセス ファイルシステム)

脆弱性を理解するには、procfsの基礎をいくつか学習する必要があります。proc filesystem (プロセス ファイルシステム)は、主にプロセスに関する情報(通常は/procにマウント)を示すLinuxの仮想ファイルシステムです。これは、ディスク上に存在しないという意味で仮想です。代わりに、カーネルによってメモリ内に作成されます。また、カーネルでファイルシステムとして公開されるシステム データに対するインターフェイスと考えることができます。各プロセスには、procfs内の/proc/[pid]:に固有のディレクトリがあります。

/proc/self は現在実行中のプロセス(ここではpid 177)のディレクトリへのシンボリックリンク
/proc/self は現在実行中のプロセス(ここではpid 177)のディレクトリへのシンボリックリンク

上図に示すように、/proc/selfは現在実行中のプロセス(ここではpid 177)のディレクトリへのシンボリック リンクです。各プロセスのディレクトリには、プロセスに関する情報を含む複数のファイルやディレクトリが含まれています。脆弱性に関連するものは、次のとおりです。

  • /proc/self/exe – プロセスが実行している実行可能ファイルへのシンボリック リンク。
  • /proc/self/fd – プロセスがオープンしているファイル記述子を保持するディレクトリ。

たとえば、ls /proc/selfを使用して/proc/self下にあるファイルをリスト表示すると、/proc/self/exeが「ls」実行可能ファイルを指していることがわかります。

/proc/self/exe は ls の実行可能ファイルを指している
/proc/self/exe は ls の実行可能ファイルを指している

/proc/selfにアクセスするのはシェルが生成した「ls」プロセスであるため、これは理にかなっています。

脆弱性

runCチームが提供する脆弱性の概要を見てみましょう。

この脆弱性により、悪意のあるコンテナは(最小限のユーザー操作で)ホストのrunCバイナリを上書きし、ホスト上でrootレベルのコードを実行できる権限を獲得できるようになります。このユーザー操作レベルでは、次のコンテキストのいずれかにおいて、コンテナ内でrootとして任意のコマンドを実行することができます。

  • 攻撃者によって制御されたイメージを使用して、新しいコンテナを作成する。
  • 攻撃者が以前書き込みアクセス権を持っていた既存のコンテナに(docker execを)追加する。

これら2つのシナリオは異なるように思われますが、どちらも、コンテナ内で新しいプロセスをスピン アップするようrunCに求めることに加え、同じような方法で実装されます。どちらの場合も、runCには、ユーザー定義のバイナリをコンテナ内で実行するタスクが課されます。Dockerでは、このバイナリは、新しいコンテナを起動する際のイメージのエントリ ポイント、または既存のコンテナに追加する際のdocker execの引数のいずれかです。

このユーザー バイナリが実行されるときは、コンテナ内ですでに特定および制限されている必要があります。そうしないと、ホストが危険にさらされる可能性があります。それを実現するために、runCは「runC init」サブプロセスを作成します。このサブプロセスは、必要なすべての制限(名前空間の入力や設定など)を自らに設定し、自らをコンテナ内に効率的に配置します。次に、現在コンテナ内にあるrunC initプロセスは、execveシステム コールを呼び出して、ユーザーがリクエストしたバイナリで自らを上書きします。

以下は、新しいコンテナを作成する場合と既存のコンテナにプロセスを追加する場合の両方でrunCが使用する方法です。

新しいコンテナを作成する場合と既存のコンテナにプロセスを追加する場合の両方でrunCが使用する方法
新しいコンテナを作成する場合と既存のコンテナにプロセスを追加する場合の両方でrunCが使用する方法

脆弱性を発見した研究者たちは、攻撃者が/proc/self/exeを実行するように求めることで、runCを騙して実行できることを発見しました。/proc/self/exeは、ホスト上のrunCバイナリへのシンボリック リンクです。

/proc/self/exeは、ホスト上のrunCバイナリへのシンボリック リンク
/proc/self/exeは、ホスト上のrunCバイナリへのシンボリック リンク

コンテナ内でrootアクセス権を持つ攻撃者は、/proc/[runc-pid]/exeをホスト上のrunCバイナリへの参照として使用して、runCバイナリを上書きすることができます。runCバイナリはrootに所有されているため、この攻撃を実行するには、コンテナのrootアクセス権が必要です。
次回runCが実行されると、攻撃者はホストでコードを実行できるようになります。runCは(たとえば、Dockerデーモンによって)通常rootとして実行されるため、攻撃者はホストでrootアクセス権を獲得します。

runC initでない理由

上図は、この脆弱性(runCを騙して実行する)が冗長であると誤解させる可能性があります。つまり、どうして攻撃者は、代わりに/proc/[runc-init-pid]/exeを単に上書きすることができないのでしょうか?
類似するrunC脆弱性CVE-2016-9962のパッチはこの種の攻撃を軽減します。
CVE-2016-9962では、runC initプロセスがホストからのオープン ファイル記述子を所有していることが明らかになりました。コンテナ内の攻撃者は、これらの記述子を使用して、ホストのファイルシステムを走査して、コンテナから脱出できます。この欠陥に対するパッチの一部は、コンテナに入る前に、runC initプロセスを「ダンプ不可」として設定していました。

CVE-2019-5736のコンテキストでは、「ダンプ不可」フラグは、その他のプロセスが/proc/[pid]/exeを逆参照するのを拒否し、したがって、/proc/[runc-init-pid]/exeを通じたrunCバイナリの上書きを軽減します[1]。ただし、execveを呼び出すと、このフラグはドロップされるため、新しいrunCプロセスの/proc/[runc-pid]/exeにアクセスできるようになります。

symlink (シンボリック リンク)の問題

この脆弱性はLinuxへのシンボリック リンクの実装方法と矛盾しているように見えるかもしれません。
シンボリック リンクは、単純に、ターゲットへのパスを維持します。runCプロセスでは、/proc/self/exeに/usr/sbin/runcなどが含まれている必要があります。
プロセスがsymlinkにアクセスすると、カーネルがリンク内のパスを使用して、アクセスしているプロセスのroot下でターゲットを検出します。
ここで質問です – コンテナ内のプロセスがrunCバイナリへのシンボリック リンクをオープンすると、カーネルがコンテナroot内でrunCパスを検索しないのはどうしてでしょうか?

その答えは、/proc/[pid]/exeがシンボリック リンクの通常のセマンティクスに従わないからです。技術的にはこれはPOSIX違反と見なされる可能性がありますが、前述したように、procfsは特殊なファイルシステムです。プロセスが/proc/[pid]/exeをオープンする場合、symlinkのコンテンツを読み取り、追従する通常の手順はありません。その代わり、カーネルは開いているファイル エントリに直接アクセスできるようにします。

エクスプロイト

脆弱性が報告されたすぐ後、POCがまだ公にリリースされていないときに、私は、この脆弱性に対処するLXCパッチで提供された脆弱性の詳細な説明に基づいて、独自のPOCを作成しようとしました。完全なPOCコードについては、こちらをご覧ください。

LXCの脆弱性に関する説明を詳しく見ていきましょう。

runCがコンテナに追加されると、攻撃者はrunCを騙して実行できます。これは、コンテナ内のターゲットのバイナリをrunCバイナリ自体を指すカスタムのバイナリに置き換えることで実行できます。たとえば、ターゲットのバイナリが/bin/bashである場合、インタープリターのパス#!/proc/self/exeを指定する実行可能スクリプトに置き換えることができます。

「#!」構文はshebangと呼ばれ、インタープリターを指定するためにスクリプトで使用されます。Linuxローダーがshebangに遭遇すると、実行可能スクリプトの代わりにインタープリターを実行します。

ビデオからわかるように、ローダーによって最終的に実行されるプログラムは、次のとおりです。
interpreter [optional-arg] executable-path

ユーザーがdocker exec container-name /bin/bashなどを実行すると、ローダーは変更されたバッシュでshebangを認識し、指定したインタープリター – /proc/self/exeを実行します。これは、runCバイナリに対するsymlinkです。
/proc/[runc-pid]/exeを通じて、コンテナ内の別のプロセスからrunCバイナリを上書きできます。

テキスト, スクリーンショット が含まれている画像 自動的に生成された説明

その後、攻撃者は、/proc/self/exeのターゲットへの書き込みに進み、ホスト上でrunCバイナリを上書きしようとします。ただし、通常、カーネルはrunCの実行中にバイナリを上書きすることを許可しないため、これは成功しません。

基本的に、runCバイナリは、プロセスで実行されているときには上書きできません。一方、runCプロセスが終了すると、/proc/[runc-pid]/exeが消えるため、runCバイナリへの参照が失われます。これを打破するには、プロセスで読み取るために/proc/[runc-pid]/exeを開きます。すると、ファイル記述子が/proc/[our-pid]/fd/3に作成されます。
次に、runCプロセスの終了を待ち、書き込みのために/proc/[our-pid]/fd/3を開いて、runCを上書きします。
次に、overwrite_runcのコードを示します。簡潔にするために短縮されています。

テキスト が含まれている画像 自動的に生成された説明

アクションをいくつか見てみましょう。エクスプロイトの出力には、runCを上書きするためのステップが示されています。runCプロセスがpid 20054として実行中であることがわかります。ビデオはこちらからも視聴できます。

ただし、この方法には1つ障害があります。つまり、攻撃者コードを実行するためのプロセスが追加で必要です。コンテナは1つのプロセス(Dockerのイメージ エントリ ポイント)でしか起動されないため、この方法は、実行時にホストを侵害する悪意のあるイメージの作成には使用できません。
同様の方法を実装する、目にしたことがあるかもしれないその他のPOCには、FrichettenfeexdのPOCがあります。

共有ライブラリを使用した方法

runCの保守管理者がリリースした公式POCには、異なるエクスプロイト方法が使用されています。これは、次の2つの方法を通じてホストに侵入するために実装できるので、私が作成したPOCやその類似品よりも優れています。

    1. ユーザーがコマンドを実行し、攻撃者によって制御されている既存のコンテナに侵入する
    2. ユーザーが悪意のあるイメージを実行する

以前のPOCですでに最初のシナリオは実証しているので、今度は悪意のあるイメージの作成について見ていきましょう。この方法のために私が記述したPOCは、主にq3kの POCに基づいています。これは、私の知る限りでは、最初に公開された悪意のあるイメージのPOCでした。完全なPOCコードについては、こちらをご覧ください。

悪意のあるイメージの作成に使用するDockerfileを見てみましょう。まず、イメージの実行時にrunCを騙して実行するために、イメージのエントリ ポイントを/proc/self/exeに設定します。

runCは、実行時に、複数の共有ライブラリに動的にリンクされます。これらのライブラリはlddコマンドを使用してリスト表示できます。

runCプロセスがコンテナで実行されると、これらのライブラリは動的リンカーによってrunCプロセスにロードされます。これらのライブラリの1つを悪意のあるバージョンと置き換えることができます。この悪意のあるバージョンは、runCプロセスにロードされると、runCバイナリを上書きします。
Dockerfileはlibseccompライブラリの悪意のあるバージョンを作成します。

Dockerfileはrun_at_link.cのコンテンツをlibsecompのソース ファイルの1つに追加します。その後、悪意のあるlibsecompが作成されます。

スクリーンショット が含まれている画像 自動的に生成された説明

コンストラクタ属性(GCC固有の構文)は、動的リンカーがlibseccompライブラリをrunCプロセスにロードした後で、run_at_link関数がlibseccompの初期化関数[2]として実行されることを示しています。run_at_linkはrunCプロセスによって実行されるため、/proc/self/exeでrunCバイナリにアクセスできます。
ただし、runCバイナリを書き込み可能にするために、runCプロセスを終了する必要があります。終了するために、run_at_linkはexecveシステム コールを呼び出して、overwrite_runcを実行します。

execveは、プロセスによってオープンされるファイル記述子に影響しないため、以前のPOCからの同じファイル記述子を騙して使用できます。

    1. runCプロセスはlibseccompライブラリをロードし、実行スクリプトをrun_at_link関数に転送します。
    2. run_at_linkは、/proc/self/exeを通じて読み取るためにrunCバイナリを開きます。これにより、ファイル記述子が/proc/self/fd/${runc_fd_read}に作成されます。
    3. run_at_linkはexecveを呼び出して、overwrite_runcを実行します。
    4. このプロセスでrunCバイナリを実行しなくなると、runCバイナリの書き込み、上書きのために、overwrite_runcが/proc/self/fd/runc_fd_readを開きます。

次のビデオでは、ポート2345でリバース シェルを生成する単純なスクリプトを使い、runCバイナリを上書きする悪意のあるイメージを作成しています。

docker runコマンドはrunCを2回実行します。初めてコンテナを作成して実行すると、POCが実行されてrunCが上書きされます。その後、runc delete[3]を使用してコンテナを停止します。
2回目にrunCを実行するときには、runCはすでに上書きされているので、リバース シェル スクリプトが代わりに実行されます。

修正プログラム

runCとLXCの両方に、同じ手法を使用してパッチが適用されました。これは、LXCパッチ コミットで明確に説明されています。

この攻撃を防ぐために、呼び出しバイナリが起動またはコンテナに追加されるときに、その一時コピーを作成するためのパッチがLXCに適用されました。これを実行するために、LXCは、memfd_create()システム コールを使用して匿名のメモリ内ファイルを作成し、システム コール自体をその一時メモリ内ファイルにコピーします。このファイルは、その後、これ以上変更されないように封印されます。LXCは、次に、元のディスク上のバイナリの代わりに、この封印されたメモリ内ファイルを実行します。特権コンテナからホストLXCバイナリを侵害する書き込み操作があると、ディスク上のホスト バイナリではなく、一時メモリ内バイナリに対して書き込みが行われ、ホストLXCバイナリの整合性は保持されます。また、この一時メモリ内LXCバイナリも封印されているため、これに対する書き込みも失敗します。

runCには、これと同じ方法を使用してパッチが適用されています。runCが起動またはコンテナに追加されると、それ自体の一時コピーから再実行されます。その結果、/proc/[runc-pid]/exeは一時ファイルを指すようになり、runCバイナリにコンテナ内からアクセスできなくなります。
また、この一時ファイルも封印されて書き込みが防止されます。ただし、このファイルを上書きしても、ホストが侵害されることはありません。

しかし、このパッチではいくつか問題が発生しました。runC initプロセスがコンテナのcgroupメモリ制限を自らに適用した後で、一時runCコピーがメモリ内に作成されます。比較的低いメモリ制限(10MBなど)で実行しているコンテナの場合、これにより、runC initプロセスがコンテナに追加されると、コンテナ内のプロセスがメモリ不足のためにカーネルによって強制終了される可能性があります。

ご興味がある場合は、この複雑な問題に関するイシュー(issue)が作成されているので、ご覧ください。これには、同様の問題を引き起こさないと思われる代替の修正プログラムについての議論が含まれています。

CVE-2019-5736と特権コンテナ

一般的な経験則として、(所定のコンテナ ランタイムの)特権コンテナは(同じランタイムの)非特権コンテナと比べて安全ではありません。
前述のように、この脆弱性は、すべてのDockerコンテナに影響しますが、LXCの場合は特権コンテナにしか影響しません。では、LXCの非特権コンテナは脆弱ではないのに、Dockerの非特権コンテナが脆弱なのはどうしてでしょうか? それは、LXCとDockerでは特権コンテナを定義する方法が異なるからです。実際、Dockerの非特権コンテナは、LXCの考え方に従えば、特権コンテナであると見なされます。

特権コンテナは、コンテナのuid 0がホストのuid 0にマッピングされる任意のコンテナとして定義されています。

主な違いは、LXCでは、デフォルトで、非特権コンテナを別のユーザー名前空間で実行しますが、Dockerでは実行しません。
ユーザー名前空間はコンテナのrootをホストのrootから分離するのに使用できるLinuxの機能です。コンテナ内のrootは、その他すべてのユーザーと同様に、ホストの非特権ユーザーにマッピングされます。つまり、プロセスにはコンテナ内の操作に関するrootアクセス権がありますが、コンテナ外の操作については特権はありません。詳細な説明については、LWNの一連の名前空間についての記事をご覧ください。

画像はKinvolk社より引用
画像はKinvolk社より引用

さて、ユーザー名前空間でコンテナを実行すると、この脆弱性はどのように緩和されるのでしょうか?
攻撃者はコンテナ内のrootですが、ホスト上の非特権ユーザーにマッピングされます。したがって、攻撃者が書き込みのためにホストのrunCバイナリを開こうとすると、カーネルによって拒否されます。

なぜDockerはデフォルトで別のユーザー名前空間でコンテナを実行しないのか、疑問に思うかもしれません。その理由は、ユーザー名前空間にはコンテナのコンテキストにいくつか欠陥があるからですが、これは、この投稿の範囲からは少々外れています。ご興味がある場合は、Dockerrkt (別のコンテナ ランタイム)のどちらのドキュメントにもユーザー名前空間で実行中のコンテナに関する制限が記載されているので、そちらをご覧ください。

おわりに

この投稿から、この脆弱性のさまざまな側面について少しでも洞察いただければ幸いです。runC、Docker、LXCのいずれかを使用している場合は、パッチを適用したバージョンへの更新を忘れないでください。
ご質問がある場合は、電子メールまたは@TwistlockLabsからお気軽にご連絡ください。


[1] 追記として、特権Dockerコンテナ(新しいパッチ適用前)は、runC initプロセスの/proc/pid/exeを使用して、runCバイナリを上書きすることができました。正確には、必要な特定の権限はSYS_CAP_PTRACEとAppArmoの無効化です。

[2] Windows DLLに精通している場合、これはDllMainに似ています。

[3] overwrite_runcはコンテナのinitプロセス(PID 1)として実行されたため、overwrite_runcが終了すると、コンテナは停止します。