メッセージブローカーを突破する
Adam Goldschmidt
2020年8月5日
0 分で読めます最近、開発者がワークフローをプログラムで作成、スケジュール、監視できるオープンソースライブラリ、Apache Airflowに関する2つの脆弱性を報告しました。どちらの脆弱性も、攻撃者がスコープを変更して別のマシンへの権限を得られるもので、攻撃の実行前にメッセージブローカーへのアクセス権を得る必要があります。
この記事では、メッセージブローカーを信頼できない理由と、Apache Airflowを悪用して本来保護されているマシンへの権限を得た方法を紹介します。その前に、まずメッセージブローカーの基本を確認しましょう。
メッセージブローカーとは?
メッセージブローカーとは、サービス同士が通信し、情報をやり取りできるようにするソフトウェアです。APIに似ているように思えるかもしれませんが、通常は、各サービスが書き込みや読み取りを行うキューを実装することで実現します。これにより、異なる言語で書かれていたり、異なるプラットフォームで実装されていたりしても、サービス同士が非同期にやり取りできます。
メッセージブローカーはアプリケーション間の橋渡しとなり、送信側は受信側の場所や数を知らなくてもメッセージを送信できます。前述のとおり、これはメッセージキューと呼ばれるコンポーネントによって実現されます。メッセージキューは、処理を行うサービスがメッセージを処理するまで、それらを保存します。また、キューがメッセージの配信を担うため、送信側は別のタスクを続けて実行できます。こうして非同期プログラミングが可能になります。
メッセージブローカーのユースケース
メッセージブローカーはソフトウェア開発で広く利用されています。複数のサービス間で信頼性の高い通信や確実なメッセージ配信が必要な場合、または非同期処理を実現したい場合に役立ちます。
メッセージブローカーのユースケースはさまざまです。代表的なものを挙げてみましょう。
決済処理:決済は一度だけ送信されることが重要です。メッセージブローカーを使って取引を処理すれば、決済情報の紛失や重複を防ぎ、受領の証明も得られます。
非同期タスク:負荷の高い処理をユーザーからのリアルタイムリクエストから切り離すことで、すぐに応答を返し、ユーザーを待たせずに済みます。
1つ以上の宛先へのメッセージルーティング:単一の送信元にメッセージを公開し、複数のサービスがそこから読み取る方式なら、より簡単で保守もしやすくなります。
メッセージブローカーとは何か、何に使うのかが分かったところで、なぜ、どのように脆弱性が生じるのかを見ていきましょう。
メッセージブローカーを過信する
データベースが侵害される可能性は、開発者なら誰もが知っています。たとえば、データベースが侵害された場合でも攻撃者に手間をかけさせるため、パスワードを暗号化するなど、通常はセキュリティ対策を強化します。
当然、メッセージブローカーでも同じように考えるべきでしょう。転送されるデータは最終的に別のマシンに届くため、機密情報の暗号化だけでなく、データを扱うマシンの保護にも十分な注意を払う必要があります。
しかし、実際はそうなっていません。私たちのセキュリティチームは、メッセージブローカーの危険な利用例をいくつか発見しました。その1つがApache Airflowです。これは、メッセージブローカーを過信する使い方で、たとえばコマンドをブローカー内に保存し、サニタイズせずに実行します。その結果、ブローカーと通信するマシン上でコマンドインジェクションやリモートコード実行につながる可能性があります。
Apache Airflowの悪用
Apache Airflowとは?
前述のとおり、Apache Airflowは本格的なワークフロー管理システムです。Airflowでは、ワークフローをDAG(有向非巡回グラフ)として設計・表現し、DAGの各ステップを個別のタスクとして定義します。コードを中心としたプラットフォームで、ワークフローを迅速かつ効率的に反復改善できます。
Airflowには、DAGのスケジュール設定と実行を担うタスクスケジューラーがあります。スケジューラーは、DAGを実行するためにexecutorと呼ばれるコンポーネントを使用します。
Airflowでゼロデイ脆弱性を発見
発端は、メッセージブローカーを実装したPythonの分散タスクキューであるCeleryを依存関係として使うオープンソースソフトウェアに、デシリアライゼーションの脆弱性がないか調べていたことでした。
AirflowがCeleryをexecutorとして使い、デフォルトでpickleを使用していることが分かりました。また、Pythonのpickleモジュールには既知の脆弱性があり、Celeryでも以前はデフォルトで使用されていたことを知っていました。
AirflowがCeleryとどのように連携するかを、簡単な図で示します。

AirflowスケジューラーはCeleryをexecutorとして使い、Celeryがタスクを保存して、スケジュールに従って実行します。
Celeryはメッセージブローカー(Redis、RabbitMQ)にタスクを保存し、ワーカーがブローカーからタスクを読み取って実行します。
AirflowのCeleryワーカーは、メッセージブローカーに保存されたpickleデータをデシリアライズします。つまり、メッセージブローカーにアクセスできれば、デシリアライゼーション攻撃によってワーカー内でリモートコードを実行できます。この脆弱性にはCVE-2020-11982が割り当てられました。
ところで、デシリアライゼーション攻撃とは?
シリアライゼーションとは、オブジェクトをバイト列に変換し、ディスクやデータベースに保存したり、ストリーム経由で送信したりできるようにする処理です。バイト列からオブジェクトを作成する逆の処理をデシリアライゼーションと呼びます。シリアライゼーションは、通信(複数のホスト間でオブジェクトを共有する)や永続化(オブジェクトの状態をファイルやデータベースに保存する)によく使われます。
デシリアライゼーション攻撃とは、アプリケーションが、復元後のデータが安全かどうかを十分に検証せずにデータをデシリアライズすることで、攻撃者が実行状態や処理の流れを制御できるようになる攻撃です。メッセージブローカーにシリアライズされたデータを保存すべきではないと言っているわけではありません。そうする必要があるユースケースは数多くあります。ただ、この種の設計を実装する際には考慮すべき点であり、追加のセキュリティ対策を導入する価値があると思います。
デシリアライゼーション攻撃について詳しく知りたい方は、こちらの記事をご覧ください。
pickleでタスクをデシリアライズするようAirflowに仕向ける
まず、新しいAirflowタスクを作成し、その構造を確認しました。DAG(タスクの集まり)を追加し、「test」という名前のキューに割り当て、CeleryブローカーをRedisに設定して、キューを起動しました。
airflow worker -q test
Redisのメッセージ構造を調べたところ、メッセージはunackedというRedisハッシュテーブルに保存されていることが分かりました。このハッシュテーブルに保存される値は、次のような構造です。
注目すべきなのは、bodyの値、content-type、content-encodingです。bodyの値をデコードすると、次のようになります。
ここには実際のコマンドが保存されています。これは2つ目の脆弱性で重要になります。また、unacked_indexというセットも見つけました。セット内の要素はハッシュテーブルのキーと同じだろうと考え、確認することにしました。まず、ハッシュテーブルのキーを取得しました。
次に、unacked_indexのすべての要素を表示しました。
ご覧のとおり、順序は異なりますが、値は同じです。この時点で、unacked_indexは実行するタスクIDを保存し、ハッシュテーブルはタスクIDと実際のタスクを対応付けていると確信しました。つまり、独自のタスクを追加するには、unacked_indexに任意の値を追加し、同じ値をキーとする新しい要素をハッシュテーブルに作成して、値として悪意のあるペイロードを設定すればよいのです。
デシリアライゼーション攻撃を実行するには、悪意のあるペイロードが必要でした。デシリアライズ時にpickleがmaliciousという名前の新しいファイルを作成するペイロードを出力するPythonスクリプトを、すぐに作成しました。
Pythonでpickleを悪用する方法については、こちらをご覧ください。そこで、キューに悪意のある値を取り込ませ、pickleでデシリアライズさせるため、content-typeをapplication/x-python-serialize(pickle)に、content-encodingをbinaryに変更する必要がありました。以下は、悪意のあるタスクを追加するPoCです。
前述のとおり、実行した手順は次のとおりです。
タスクIDとして、任意の値を
unacked_indexに追加する新しい要素を
unackedに追加する。キーには先ほどのIDを、値には悪意のあるペイロードを指定します。悪意のあるペイロードでは、base64でエンコードしたペイロードをbodyの値に設定しました。
以下の図に、処理の流れを示します。

テスト用キューを実行すると、なんとmaliciousという新しいファイルが、キューを実行したワーカー上に作成されました。これは完全なスコープの変更です。この脆弱性の影響については、次の脆弱性を確認したあとに説明します。
コマンドインジェクションの脆弱性
2つ目の脆弱性は、CVE-2020-11981が割り当てられたコマンドインジェクションです。
Airflowのソースコードを調べると、メッセージブローカーからのコマンドがサニタイズされずに実行されることが分かりました。脆弱性を発見したときにありがちな「やった!」という歓声を上げたあと、Redisに戻り、bodyキーに次のペイロードを注入できることを確認しました。
先ほどと同じ考え方で、これは概念実証です(ここではcontent-typeやcontent-encodingを変更する必要はありません。pickleの処理が不要なためです)。
さらに、RabbitMQのダッシュボードからもこのペイロードを注入できることが分かりました。RabbitMQはRedisと同じ構造を使わないため、base64エンコードせず、通常のJSONリストを使うだけで十分です。
この脆弱性の影響は、前の脆弱性とよく似ています。ワーカーマシンを完全に制御できるのです。影響をさらに説明するために、この脆弱性を悪用するまで、攻撃者がアクセスできたのはメッセージブローカーという1台のマシンだけだったと想像してください。さらに、一部の環境では、メッセージブローカーのマシンにアクセスしなくても、APIを使ってブローカーにメッセージを注入できる可能性があります。
ワーカーマシンを完全に制御できれば、シークレットの漏えいやサービス拒否に加え、同じインフラ内の別のマシンへのアクセスも可能になる場合があります。侵害されたネットワーク内で攻撃の範囲や権限を拡大できる脆弱性は、軽微なセキュリティインシデントで済むか、深刻な侵害に発展するかを分ける要因になり得ます。
修正方法

メッセージブローカーにコマンドを保存する場合、修正方法は上の図のようになります。これなら、攻撃者がメッセージブローカーを制御しても、ワーカーが実行するのは既知のコマンドだけで、想定外の入力はサニタイズされます。Apacheはパッケージのロジック上、コマンドをメッセージブローカーに保存する必要があるため、この方法で修正しました。
インフラのセキュリティについては、適切な認証やTLSなどの対策をメッセージブローカーに導入し、そもそも攻撃者がアクセスしにくくする必要があります。
まとめ
この2つの脆弱性では、メッセージブローカーにアクセスするだけで、キューサーバー(またはワーカー)上でコードやコマンドを実行できることを示しました。初期設定のRedisはパスワードを必要としません。
Apacheチームは、これらの脆弱性を速やかに認識し、修正しました。Snyk Security Teamは、どちらもインフラへの初期アクセスが必要なため、非常に深刻度が高いとは考えていませんが、それでも危険です。脆弱性の悪用に初期権限や、複数の脆弱性(何らかの脆弱性チェーン)が必要になることは珍しくありません。
この記事から持ち帰っていただきたい主な教訓は、次のとおりです。メッセージブローカーを無条件に信頼しないでください。ブローカーの設計やアーキテクチャについて考え、利用時のリスクをどう最小限に抑えるかを検討しましょう。
CTFを始めよう
オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。


