Windowsコンテナのリバース エンジニアリングから分かったこと

https://unit42.paloaltonetworks.com/wp-content/uploads/2019/12/PANW_-windows-containers_6.png

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

概要

ここ数年、コンテナの人気はますます高まっています。数年前にこのことを認識したMicrosoftは、Microsoft Windows向けコンテナ ソリューションを提供するためにDockerと提携しました。

その間、Linuxのコンテナで検出された重大な脆弱性の数から判断して、Windowsコンテナにも多少の脆弱性が存在することが考えられます。しかし、Linuxと違って、Windowsはオープンソースではなく、特にコンテナ機能に関するドキュメントはほとんど存在しないので、そのような脆弱性を見つけるのははるかに困難です。現在、Windowsのコンテナ機能の内部実装に関する情報はほとんどありません。Microsoftのコンテナの実装について理解を深めるには、カーネルのリバース エンジニアリングを行う必要がありました。

その結果、ジョブ オブジェクトがLinuxのコントロール グループ(cgroup)と同じような方法で使用されていることと、サーバー サイロ オブジェクトがカーネルの名前空間サポートの代わりに使用されていることがわかりました。また、Windowsは、カーネル空間でシステム コールをフィルタリングすることによって、コンテナ プロセスが自身の権限を昇格させてコンテナをエスケープするのを防いでいることもわかりました。本稿では、Windowsコンテナのリバース エンジニアリングから多くのことを学習できることを明らかにします。さらにリサーチを進めれば、Windowsコンテナの脅威モデルについてもっとよく理解できるでしょう。

過去の経緯

MicrosoftがDockerと提携するまで、Windowsには、コンテナが正常に動作するために必要ないくつかの中核機能、主として名前空間、コントロール グループ(cgroup)、およびレイヤー機能が欠けていました。Microsoftは、開発に2年半、Windows Insider Programでのベータ テストに1年をかけた後、2016年9月に、まもなく発表されるWindows Server 2016でコンテナがサポートされることを発表しました。

2002年に導入された名前空間は、もともとはプロセスがアクセスできるリソースを制御する方法を提供するためのLinuxに固有の機能でした。この機能は、プロセスがリソースの存在を認識しないという点で、アクセス制御とはまったく異なります。これを示す単純な例として、プロセス リストがあります。サーバー上で100個のプロセスが動作していても、ある名前空間内で動作しているプロセスが認識するのはそのうち10個だけである可能性があります。もう1つの例として、プロセスはルート ディレクトリを読み込んでいるつもりでも、実際にはそれは仮想化されているディレクトリである可能性があります。2006年には、プロセスをグループ化して共通のリソース制御を行うcgroupという機能のサポートがLinuxカーネルに追加されました。そして、cgroupと名前空間を組み合わせたものが、今日のコンテナの基盤となりました。

Microsoftにとって幸運なことに、Windowsにはすでにジョブ オブジェクトというコントロール グループに似た機能が存在していました。ジョブ オブジェクトを使用すると、プロセスのグループを1つの単位として管理できます。管理の例として、ワーキング セット サイズやプロセス優先度などの制限を適用することや、特定のジョブに関連付けられているすべてのプロセスを終了することなどがあります。しかし、Microsoftには名前空間のような機能はなかったので、「サイロ」と呼ばれるカーネル オブジェクトが導入されました。

結果

Microsoftは、図1に示す2種類のコンテナ アーキテクチャを導入しました。

  • アプリケーションが完全に分離されたHyper-V仮想マシンにデプロイされるアーキテクチャ。このアーキテクチャは、Windows 10とWindows Serverの両方でサポートされています。
  • アプリケーションが、軽量のサイロ コンテナにデプロイされるアーキテクチャ。こちらのアーキテクチャは、現在はWindows Serverでのみサポートされています。

1つ目のアーキテクチャは仮想マシン(VM)を使用しており、コンテナごとに個別のカーネルが存在します。このソリューションには、以下の2つのサブソリューションがあります。

  • Moby VM内のLinuxコンテナ: これはフルサイズのVMです。このモデルの場合、DockerはWindowsのデスクトップ上で動作し、Linux VM上のDockerデーモンと通信します。
  • Hyper-Vによる分離: Microsoftではこれを「Linux containers on Windows (LCOW)」と呼んでいます。このモデルの場合、Linux VM内部にはコンテナの実行に不可欠なごく基本的な内部機能だけが存在します。Moby VMのモデルとは対照的に、各Linuxコンテナに個別のカーネルと個別のVMサンドボックスが存在します。

本稿では、2つ目のサイロ コンテナ ソリューションについてのみ説明します。このソリューションは、ホストのWindowsカーネルを使用します。

図1: サーバー サイロ コンテナとHyper-V

図1: サーバー サイロ コンテナとHyper-V

実装の詳細

仮想ファイルシステムとレジストリ

Windowsファイル システム実装の詳細はさておき、Windowsの場合、すべてのファイル アクセスをシンボリック リンク経由で行っています。たとえばユーザーがC:\secret.txtを指定してCreateFile (ファイルへのハンドルを取得するAPI関数)を呼び出す場合、C:の部分は\Device\HarddiskVolumeXXX\のようなパスへの単純なシンボリック リンクです。コンテナ内のプロセスがC:\secret.txtにアクセスしようとすると、そのパスは上記のシンボリック リンクの代わりに\Device\VhdHardDisk{123651}\のようなパスに変換されます。レジストリ キーの場合もまったく同じ処理が行われます。このメカニズムについては、本稿後半で説明します。

図2: C:と表示されているが単なるシンボリック リンクであるWinObj

図2: C:と表示されているが単なるシンボリック リンクであるWinObj

ジョブ

ジョブ(別名ジョブ オブジェクト)は、新しい機能ではなく、Windows XPのService Pack 3アップデートで導入されたものです。Microsoftによれば、ジョブ オブジェクトを使用すると、プロセスのグループを1つの単位として管理できます。ジョブ オブジェクトは、名前を付けること、セキュリティで保護すること、および共有することが可能なオブジェクトであり、関連付けられているプロセスの属性を制御します。ジョブ オブジェクトに対して実行する操作は、そのジョブ オブジェクトに関連付けられているすべてのプロセスに影響を及ぼします。今回のケースでは、ジョブ オブジェクトの主なジョブは、コンテナのリソースを制限することです。ジョブ オブジェクトは、コンテナ以外にも多くの機能から利用されています。このためコンテナが登場するずっと前に導入されていました。ジョブ オブジェクトを使用してプロセス グループに対して適用できる主な制限の一部を以下に示します。

  • アクティブなプロセスの最大数: ジョブに同時に存在するプロセスの数を制限します。コンテナで使用できるプロセスの数を制限するために使用できます。この制限を超えて新しいプロセスを作成することはできません。
  • ジョブ全体のCPU制限: ジョブの全プロセスの総CPU時間を制限するために使用します。あるプロセスがこの制限を超えた場合、ジョブの全プロセスを終了します。
  • プロセスごとのCPU制限: 各プロセスのCPU時間を個別に制限するために使用します。あるプロセスがこの制限を超えた場合、そのプロセスを終了します。
  • プロセスとジョブの仮想メモリ制限: 1つのプロセスまたはジョブ全体のどちらかに割り当て可能な仮想メモリの量を制限するために使用します。
  • ネットワーク帯域幅レート制御: ジョブ全体の最大送信帯域幅を設定します。最大送信帯域幅に達した後は、スロットリングが有効になります。
  • ディスクI/O帯域幅レート制御: ディスクI/Oに対してネットワーク帯域幅レート制御とまったく同じように動作します。

図3: ProcessExplorerでのコンテナ内プロセスのジョブの情報表示

図3: ProcessExplorerでのコンテナ内プロセスのジョブの情報表示

サーバー サイロ

サーバー サイロは、Windowsで完全なコンテナ ソリューションに十分な分離を実現するためのメイン機能です。サーバー サイロについて理解を深めるには、まずルート ディレクトリ オブジェクトについて説明する必要があります。ここではそのメカニズムの詳細は説明しませんが、Windowsの名前付きオブジェクト(ファイル、レジストリ、シンボリック リンク、パイプなど)のほとんどは、ルート ディレクトリ オブジェクトと呼ばれるルート名前空間の下に存在します。サイロが作成されると、そのサイロに新たにルート ディレクトリ オブジェクトが作成されます。それ以降は、どの名前付きオブジェクトにも別の値(例えばシンボリック リンクの「C:」)が存在します。

図4: サイロのルート ディレクトリ オブジェクトを表示するWinObj

図4: サイロのルート ディレクトリ オブジェクトを表示するWinObj

リバース エンジニアリング

ここまでで説明した情報は、Microsoftの公式ドキュメントや一般公開されているソースにも記載されています。しかし、Windowsコンテナの実装に関するいくつかの疑問点については、それらのソースでは答えが得られません。そこで、Windowsコンテナの構築方法について理解を深めるために、該当する部分をリバース エンジニアリングすることにしました。今回は主に、以下の疑問点について調査しました。

  • カーネルは、コンテナ化されたプロセスと通常のプロセスをどのように区別しているか
  • カーネルは、ホストではなく、コンテナ内のファイルとレジストリへのアクセスをどのように提供しているか
  • カーネルは、コンテナがカーネル ドライバのロードなどの特定のシステム コールにアクセスするのをどのように防いでいるか

これらの疑問点のいくつかは、前のセクションで簡単な答えを示しました。ここではもっと詳しく説明して、カーネルの実際のコードをいくつか示します。

カーネルはコンテナ プロセスをどのように区別しているか

Windowsカーネルには、コンテナ内のプロセスやスレッドとコンテナ外のプロセスやスレッドを区別するために使用される関数が多数存在します。PsIsCurrentThreadInServerSiloやPsGetCurrentSiloはその一部です。注目したいのは、それらの関数がどこから呼び出されるのかという点です。コードでそれらの関数を呼び出している場所が、カーネルがコンテナ化されたプロセスと通常のプロセスを区別している場所である可能性があります。

図5: プロセスがコンテナ内に存在するかどうかをチェックするカーネル関数の例

図5: プロセスがコンテナ内に存在するかどうかをチェックするカーネル関数の例

カーネルはコンテナからのファイル アクセスをどのように認識しているか

コンテナ化されたプロセスの識別に関するチェックは、ユーザー モードでは実行されません。カーネルが、ファイル アクセスを含む、通常のプロセスとコンテナ プロセスの操作を区別しています。

まず、Windowsのシステム コールについて簡単に説明する必要があります。Linuxと異なり、アプリケーションは、システム コールを直接使用するのではなく、内部DLLを通じて提供されるAPI関数を呼び出します。このようなDLLの一部はドキュメントに記載されていません。呼び出された関数は他のAPI関数を呼び出し、それらがさらに他のAPI関数を呼び出すということを何度か繰り返して、最終的にシステム コールが実行されます。

API関数の呼び出しからカーネルまでのどこかでコンテナ/サイロのチェックが処理されるのだと思う人もいるかもしれませんが、そうではありません。はっきり言います。ホストでhost.exeというプロセスがCreateFile ("C:\secret.txt")を実行し、コンテナからcontainer.exeというプロセスがCreateFile ("C:\secret.txt")を実行する場合、どちらもまったく同じパラメータでカーネルのNtCreateFileを呼び出します。

ファイル アクセスの際にカーネルで何が行われるのか

カーネル ランドに到達したとき、最初に実行するのはNtCreateFileです。この関数自体はほとんど何もしません。単にIopCreateFileを呼び出すだけです。IopCreateFileで、カーネルはサイロの処理を開始します。まず、現在のスレッドがサイロに接続されているかどうかをチェックします。そのために、PsGetCurrentSilo関数を呼び出します。この関数は、呼び出し元スレッドの現在のサイロ、すなわちESILOオブジェクトへのポインタを返します。スレッドがサイロ内のプロセスに接続されていない場合はNullを返します。

図6: IopCreateFileのコードでカーネルがサイロを処理している部分

図6: IopCreateFileのコードでカーネルがサイロを処理している部分

ESILOオブジェクトへのポインタは、ObOpenObjectByNameExを経由してObpLookupObjectNameに渡されます。ObpLookupObjectNameで最も重要なのが、ObpRootDirectoryObjectを選択する部分です。

図7: RootDirectoryObjectを選択する部分

図7: RootDirectoryObjectを選択する部分

最後に示した部分によって、動作の仕組みをほぼ理解できます。本稿の最初に、シンボリック リンクをルート ディレクトリ オブジェクトから解析する方法について説明しました。そして、たったいま、I/Oリクエストのたびにルート ディレクトリ オブジェクトを選択する方法を説明しました。これで、すべてがつながりました。

カーネルはコンテナから特定のシステム コールへのアクセスをどのように防いでいるか

多くの危険なシステム コールや複数のカーネル関数で、呼び出し元のプロセスまたはスレッドがサイロ内に存在するかどうかを判断しています。コンテナ内でドライバをロードするという特定のケースでは、Windowsがカーネル内で十分なチェックを実行していることがわかりました。これは、他の多くのシステム コールにも関連しています。このケースでは、NtLoadDriverが実際にカーネル ドライバ イメージをロードするために呼び出すIopLoadDriverImageが、呼び出し元プロセスがサイロ内に存在するかどうかを示す値を返しています。

図8: スレッドがサイロ内に存在するかどうかを返すIopLoadDriverImage 図8: スレッドがサイロ内に存在するかどうかを返すIopLoadDriverImage

図8: スレッドがサイロ内に存在するかどうかを返すIopLoadDriverImage

セキュリティの懸念

Windowsのコンテナの調査を開始したとき、Microsoftがファイル システムや危険なシステム コールへのアクセスの処理方法をどのように選択したのか、非常に興味がありました。コンテナ内のユーザーは通常は管理者であり、実行できるシステム コールに制限はありません。たとえば、悪意のあるコンテナがカーネル ドライバをロードするのを防いでいるのは何なのか、疑問に思っていました。チェックはシステム コールごとに実装されるので、特定の実装を1つずつ調査して、セキュリティの問題につながる可能性がある処理ミスを監査してみるのも興味深いでしょう。

最後に

本稿で提示した情報は、氷山の一角にすぎません。本稿をきっかけにして、多くの人がWindowsのコンテナについて学習し、記事を書いてくれることを願っています。