カーネル権限昇格:Kubernetesのコンテナ分離が権限昇格攻撃に与える影響
Kamil Potrec
2020年12月3日
0 分で読めます日中はTerraformのコードやKubernetesオブジェクトの設定ファイルを分析し、よくあるセキュリティ上の問題を見つけています。日が暮れるとフード付きパーカーを着て、Linux VMやデバッガーを起動し、クラウドネイティブエコシステムを構成する技術の内部を調べます。
この記事では、Kubernetesのコンテナ分離が権限昇格攻撃にどのような影響を与えるのかを探ります。一般的なカーネルエクスプロイト手法を使い、コンテナの抽象化レイヤーが、念願のrootシェルへの道をどのように阻むのかを見ていきます。
権限昇格とは?
権限昇格とは、リソースに対する権限をより多く取得するプロセスを指す用語です。カーネル権限昇格とは、複数あるカーネルのエントリーポイントのいずれか(攻撃ベクトルとも呼ばれます)の脆弱性を悪用して権限を取得するプロセスです。攻撃ベクトルとは、脆弱なコードにアクセスするための経路です。
ファイルシステムからの読み取り、デバイスファイルのオープン、システムコールの発行、ネットワークインターフェースを介したパケットの送信など、私たちはさまざまな方法でカーネルとやり取りします。これらの操作はすべて、カーネル空間で何らかの処理を必要とします。カーネルがユーザープロセスに代わって処理を行うとき、カーネルはプロセスコンテキストで動作していると言います。各プロセスは、カーネル内ではstruct task_struct構造体で表されます。これらは循環双方向リンクリストに格納されており、ユーザー空間からカーネル空間へのコンテキストスイッチが発生すると、x86-64アーキテクチャではPER_CPU変数からアクセスされます。
task_structにはstruct credsメンバーがあり、プロセスに関連付けられたユーザー識別子とケーパビリティが保持されています。カーネルはこの情報を使って、特定のシステムコールの実行が許可されているかなど、プロセスが操作を実行できるかどうかを判断します。カーネル権限昇格の一般的な目的は、認証情報構造体を置き換える、または更新して、より多くの権限を得ることです。
権限昇格の仕組み
カーネル空間で権限を昇格する最も一般的な手法は、カーネル関数[commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437)([prepare_kernel_cred(0)](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682))を組み合わせて利用することです。これを行うには、エクスプロイトが命令ポインター(RIP)を制御し、メモリアクセス制御とランダム化制御を突破している必要があります。[prepare_kernel_cred](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682)は既存の認証情報をもとに認証情報オブジェクトを生成できます。また、より簡単に、rootの全権限を持つデフォルトのオブジェクトを生成することもできます。[commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437)は、現在のプロセスのtask_structを新しい認証情報オブジェクトで更新します。
カーネルエクスプロイトは非常に広範な分野です。そのため、この記事ではカーネル権限昇格を大幅に単純化した例だけを取り上げます。カーネルには、エクスプロイトを困難にするためのセキュリティ制御が数多くあります。SMEP、SMAP、KASLR、KPTIはいずれも、ハードウェアまたはカーネルで実装されている仕組みであり、使用するディストリビューションやシステム管理者によって有効・無効が設定されます。Kubernetesからこれらの設定を直接制御する方法はないため、この記事では扱いません。
ここでは、af_packetの実装に存在し、CVE-2017-7308として報告された古い問題を使用します。この脆弱性の悪用にはrawソケットへのアクセスが必要で、CAP_NET_RAWケーパビリティが利用されます。脆弱性の詳細はこちらで詳しく解説されているため、ここでは説明しません。必要なケーパビリティはすべて、非特権ユーザー名前空間内で取得できます。Ubuntuディストリビューションでは、ユーザー名前空間へのアクセスはデフォルトでnot。
詳しく見ていきましょう
まずは、コンテナ化されていない環境での一連のプロセスを見てみましょう。
GNU Debugger(gdb)を仮想マシンのスタブに接続します。デバッガーを接続したら、適切な場所にブレークポイントを設定できます。ここではmlockシステムコールを使用します。これを使えば、実行中のプロセスの内部状態を確認したいときに、エクスプロイトから手動でブレークポイントを呼び出せます。gdbが停止するのは、実行中のプロセス名が「exploit」の場合だけです。これにより、システム上の別のプロセスでブレークポイントが作動するリスクを抑えられます。セットアップ作業は、. gdbコマンドファイルにまとめてスクリプト化しています。GDBを-xフラグ付きで実行すれば、一貫性があり、繰り返し可能な方法でセットアップできます。
ブレークポイントは、非特権ユーザー名前空間が作成される前、脆弱性を実行する直前、そしてroot認証情報を取得した後に作動します。適切なシステムコールを実行するだけで、この動作を実現できます。
では、エクスプロイトを実行してみましょう。
最初のブレークポイントが予想どおり作動しました。gdbのヘルパー関数$lx_currentを実行して、cred構造体を調べられます。現在のプロセスの実効UIDは1000で、予想どおり、現在の名前空間では実効ケーパビリティを持っていません。
2つ目のブレークポイントは、unshareの呼び出し後に作動し、プロセス用の新しいユーザー名前空間が作成されます。UIDは変わっていませんが、cap_effectiveとuser_nsの属性が変化していることに注目してください。ケーパビリティはビットマスクとして保存されており、16進数で表示すると読みやすくなります。
最後のブレークポイントは、脆弱性を悪用した後に作動します。UIDが0に設定され、ユーザー名前空間がホストのinitユーザー名前空間を表すinit_user_nsにリセットされていることが分かります。
シェルが戻り、ホスト上でrootの全権限を取得できました。
コンテナ内でのカーネルエクスプロイト
次に、同じエクスプロイトをPod内で実行してみます。非常にシンプルなPodオブジェクト定義を作成し、クラスターにデプロイしました。
デフォルト設定で何が起こるか見てみましょう。
デフォルトでroot
デモで使用したイメージでは非特権ユーザーが指定されておらず、KubernetesもデフォルトではUIDを強制しません。そのため、カーネルを悪用しなくてもrootアクセスが得られたように見えます。同じエクスプロイトを再実行し、脆弱な経路を実行する直前にカーネルを停止します。プロセスの実効ケーパビリティを見ると、いくつか不足していることが分かります。値は2818844155で、Dockerランタイムがデフォルトで付与するケーパビリティセットを表しています。
エクスプロイトの完了後、実効ケーパビリティセットには再びすべてのケーパビリティが含まれます。
今度はrunAsセキュリティコンテキスト属性を設定し、コンテナで非rootユーザーIDを強制します。
今回は、最初からroot権限があるわけではありません。しかし、エクスプロイトはまったく同じように動作し、最終結果に大きな違いが生じます。すべての権限を持っているように見えますが、システム上のすべてを確認できるわけではありません。
名前空間の檻
すべてのケーパビリティとroot UIDを取得できましたが、突破できたのはコンテナのケーパビリティの壁だけです。ホストのファイルシステムにはまだアクセスできないため、すべてのプロセスを確認したり、ホストのネットワークインターフェースを介して通信したりすることはできません。
この時点で、任意のカーネルモジュールをロードできます。しかし、それは痕跡が残り、(期待したいところですが)基本的な侵入検知システムの多くに検知されます。これを試すために、使われていないモジュールを削除してみましょう。Dockerイメージにモジュールパッケージがインストールされている必要があります。Debianイメージの場合は、kmodパッケージをインストールしてください。
代わりに、カーネルエクスプロイトを拡張し、現在のコンテキストにある[struct nsproxy](https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h#L31)オブジェクトを、任意の名前空間を指すように設定できます。名前空間はinodeで識別されますが、カーネルは[init_nsproxy](https://github.com/torvalds/linux/blob/master/kernel/nsproxy.c#L32)のアドレスを公開しています。これを使って、ホストのinit名前空間をコンテナにコピーできます。
sys_setnsシステムコールを使うと、プロセスコンテキストの名前空間を更新できます。権限昇格で移行したい主な名前空間は、PID、ネットワーク、マウントの3つです。まず、root名前空間への参照を取得する必要があります。これには、コンテナのPID 1をホストの名前空間に移動させます。次に、PID 1の/proc/ファイルシステムから任意の名前空間への参照を取得できます。最後に、現在のプロセスを必要な名前空間に移動します。
エクスプロイトの実行後は、興味深いシステムリソースすべてにアクセスできます。
ケーパビリティの活用
Kubernetesコンテナにデフォルトで割り当てられるケーパビリティ(Dockerランタイムの場合)には、コンテナのCAP_NET_RAWが含まれています。これは、非特権ユーザー名前空間が無効でも脆弱性を悪用できるということでしょうか?脆弱なコードに到達するために必要な実効ケーパビリティを設定するコードを追加しました。
ご覧のとおり、エクスプロイトは失敗します。なぜでしょうか?
これは継承可能なケーパビリティと、その実装方法に関係しています。コンテナランタイムがコンテナ内のプロセスにこれらのケーパビリティを付与していても、sys_capsetを使って実効ケーパビリティとして明示的に設定する必要があります。現時点では、実効ケーパビリティを設定できるのはUID 0のプロセスだけです。したがって、非rootユーザーとして実行しながら必要なケーパビリティも利用するには、実効ケーパビリティを設定するsuidバイナリをコンテナに含める必要があります。または、実行ファイルに必要なケーパビリティを設定して、コンテナのケーパビリティを削除することもできます。ファイルケーパビリティは、拡張属性をサポートするファイルシステムでのみ使用できます。
Seccompで防御する
次に、攻撃ベクトルに到達できるかどうかを考えてみましょう。このエクスプロイトが機能するのは、非特権ユーザーが非特権ユーザー名前空間内でCAP_NET_RAWケーパビリティを取得できるためです。先ほどケーパビリティについて説明した際に、これがエクスプロイトに与える影響を確認しました。攻撃を阻止するために、もう1つ使える対策があります。そう、Kubernetesから有効にできます。
Seccompはシステムコールをフィルタリングし、カーネルの攻撃対象領域を縮小するための仕組みです。残念ながら、Kubernetesはデフォルトではコンテナにseccompプロファイルを適用しません。そのため、すでに説明した権限チェックに従う限り、すべてのシステムコールが許可されます。この設定は、オブジェクト定義にアノテーションを追加する(v1.19より前の場合)か、Podのセキュリティコンテキストにseccompプロファイル属性を追加することで変更できます。
コンテナランタイム(ここではDocker)が提供するデフォルトプロファイルによって、エクスプロイトにどのような影響があるか見てみましょう。「Operation not permitted」エラーが表示されます。これは、デフォルトのseccompプロファイルがunshareシステムコールを許可していないためです。
Seccompは、不要なカーネルエントリーポイントを制限するのに有効です。unshareやuserfaultfdなどのシステムコールは、ほとんどのユースケースで安全に無効化でき、いくつかのエクスプロイト手法への対策として役立ちます。ただし、waitidなど、ブロックが難しいシステムコールもあります。こうした手法や、コンテナを悪用するその他の手法についてはこちらをご覧ください。
まとめ
デフォルトのseccompプロファイルを使って、このエクスプロイトを防ぐことができました。ご覧のとおり、オペレーティングシステムに脆弱性があっても、コンテナからエクスプロイトの実行経路には到達できません(今回は)。この手法を使えば、オペレーティングシステムを一刻も早くアップデートするための時間を確保できます。こうした対策は、多層防御のための制御策および緩和策として捉えてください。
システムには必ずパッチを適用しましょう!Snyk Infrastructure as Codeを使えば、こうした緩和策をCI/CDパイプラインの早い段階で、本番環境にデプロイされるずっと前に検出できます。Kubernetesやクラウドサービスプロバイダーにおける影響の大きいセキュリティ対策を特定するため、敵対的な手法を活用しています。無料アカウントに登録して、Snykを無料でご利用ください。
CTFを始めよう
オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。


