Azureの最も弱い環 - 5つの完全なクロステナント侵害
Azure Logic Apps API Connectionsにおける5件のクロステナント権限昇格バグ。パストラバーサルから始まり、ルートCAに至る。
日本語
コピー

以前のブログ記事「Azure の最も弱い環?」と「Azure の最も弱い環 — テナントをまたぐ完全侵害」では、Azure Logic Apps の一部である API Connections の背後に潜む、深刻に不安全なアーキテクチャの概要と、その構造的な欠陥を突いたテナント間の完全侵害の一例を紹介した。今回は、このシステムで新たに見つかった脆弱性をいくつか報告する。いずれも攻撃者に同じプリミティブを与えるものだ。これらの脆弱性に対する報奨金は、合計でなんと 20 万ドルに達した。
この内容は Blue Hat Asia 2026 でも講演した。録画が公開されたら、ここにリンクを追加する。
TL;DR
API Connections を使えば、誰でも世界中の任意の接続を完全に侵害でき、接続先のバックエンドへ完全にアクセスできる。テナントをまたいだ Key Vault や Azure SQL Database の侵害も含まれ、Jira や Salesforce など外部に接続されたあらゆるサービスも同様だ。悪用を防いでいるのは、各システムに後付けされた何層もの入力検証だけであり、後述するとおり、ここですべてのエッジケースを塞ぐのは極めて難しい。
アーキテクチャ
このシリーズの最初の記事、「Azure の最も弱い環? — Part 1」と「Azure の最も弱い環 — テナントをまたぐ完全侵害」をまだ読んでいないなら、先にそちらを読むことを勧める。興味がなければ、まずアーキテクチャの最重要部分だけ説明しておく。
Microsoft のドキュメントには、API Connection のアーキテクチャがどう構築されているかを示す、なかなか興味深い図がある。

これは、システムの動作を理解するのに必要なことをほぼすべて示している。作った人には頭が下がる。
仕組みはこうだ。
- 左下のシンボルであるロジックアプリが、共有の Azure API Management インスタンスにクエリを送る
- API Management インスタンスはまずコネクタタイプの swagger(OpenAPI)定義を確認し、有効なアクションかどうかを検証する
- 一種の鍵交換が行われ、入力トークン(鍵)がバックエンドサービス用に設定されたトークンと交換される
- API Management インスタンスは、この新しいトークンでバックエンドインスタンスへの呼び出しを完了する
ここから推測できるのは、自分が所有する接続とは別の接続を API Management インスタンスに操作させることができれば、被害者の接続に設定されたトークンをそのバックエンドサービスに対して事実上利用できてしまうということだ。
このサービスは事実上、何にでもなり得る。ここを見ればわかるとおり、バックエンドサービスのリストは事実上無制限で、多数の Azure サービスが含まれている。
図はよくできているが、決定的な欠落がひとつある。このシリーズのパート 2 ですでに大いに活用した点だ。ARM REST API DynamicInvoke または /extensions/proxy/ エンドポイントを使えば、ARM 経由で APIM サービスに直接クエリを送れる。勝手ながら、これを反映するよう図を更新しておいた。

ARM はリソースマネージャーとして、基本的にすべてにアクセスできる。この事実を踏まえれば、攻撃の道筋は明らかだ。呼び出し元のサービスに、自分たちのものとは別の接続を問い合わせさせる。そうすればバックエンドサービスを完全に掌握できる。
ただし注意点がある。これらの接続を設定した担当者が、API Connection のバックエンド権限を最小限に抑えるようアクセストークンやキーをスコープ設定することは、もちろんあり得る。ただ、実際にそうしている例はほとんどないだろう。OAuth 接続なら、トークンはまず間違いなく最初に接続を作った本人のものだ。API キーの場合はどうか。そんなものを最小権限にスコープ設定するために手間をかける人がいるだろうか。少なくとも Microsoft はやっていない。
最初の悪用
API Connections、いや Azure 全体を通じて、私が最初に成功させたクロステナントの悪用については、パート 2 ですでに説明した。復習として、自分の接続の DynamicInvoke エンドポイントを使い、別テナントの Key Vault を読み取るペイロードの例を挙げておく。
POST /subscriptions/8e3ce52f-d45b-4347-8705-65892507465e/resourceGroups/token-storer/providers/Microsoft.Web/connections/custom2/DynamicInvoke?api-version=2018-07-01-preview HTTP/2
Host: management.azure.com
Authorization: Bearer <token>
Content-Type: application/json
Content-Length: 147
{
"request":{
"method":"get",
"path":"path/%2e%2e/%2e%2e/%2e%2e/%2e%2e/apim/keyvault/fd8d0f4f4069495991ccb4974f96a1ed/secrets/victimsecret/value"
},
}
HTTP/2 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 1329
{
"response": {
"statusCode": "OK",
"body": {
"value": "dontreadme",
"name": "victimsecret",
"version": "7914d45aa60342809fb8cc12dc68b10e",
"contentType": null,
"isEnabled": true,
"createdTime": "2025-04-04T05:38:26Z",
"lastUpdatedTime": "2025-04-04T05:38:26Z",
"validityStartTime": null,
"validityEndTime": null
},
"headers": {
<Headers>
}
}
}
見てのとおり、かなりわかりやすいパストラバーサル脆弱性だ。これは私たちがすでに想定していたことを裏づけている。ARM から APIM インスタンスへリクエストが飛ぶとき、それがどの接続を対象にしていようと、すべての接続に対する権限を持っているということだ。
さらなる悪用
この脆弱性を報告してから数週間後、修正済みの印がついた。もう一度試してみると、次のエラーメッセージが出迎えてくれた。

これで十分な修正なのか、私は考え込んだ。許可されるパスを制限しているだけで、根本にあるトークンの問題には触れていないように見えた。だから、抜け道はあるだろうと踏んだ。
ARM API について面白い事実がひとつある。場合によっては、君が思っているほどブラックボックスではないということだ。関連するコードは、リソースタイプの中に ときどき 見つかる。そのコードが実際にこれらのマシン上で実行されているのか、単に成果物として同梱されているだけなのかは分からないが、興味深い情報の宝庫だ。そこで Standard Logic App を作成し、完全に専用のホストを用意して SSH で入り、コードを抽出した。そして見つけたのが、これだ:

修正がどう実装されたか、見てみよう。
ここで実際にもっと面白かったのは、探してもいなかったのに見つけたものだ。実際のDynamicInvokeのコードを探したとき、目に入ったのはこの公開関数ひとつだけではなかった。四つあった。

これらの関数はまったくドキュメント化されておらず、どこにも言及が見当たらなかった。だが、到達可能で、同じパス上に位置していた。たとえばここでDynamicListを叩くには、URI 内のdynamicInvokeをDynamicListに変えるだけでいい。
彼らが実装した場当たり的なブラックリスト修正はDynamicInvokeにしか適用されていないと知っていたので、同じ機能を持つならこれらもすべて同じ形で脆弱だろうと踏んだ。必要な引数と戻り値の話で退屈させるのは避けるが、攻略法は実質同じで、ペイロードが少し込み入っているだけだ。DynamicListでは各パスパラメータを個別に指定し、結果を得るために少し手を動かす必要があるが、理屈は同じ。たとえば被害者のSQLサーバーに対して、その接続経由でクロステナントの書き込みを成立させるなら、次のようなペイロードが効く:
POST /subscriptions/162fc6db-03cd-4fe8-ab44-dc0a947e74af/resourceGroups/api-connection/providers/Microsoft.Web/connections/custom_validator/DynamicList?api-version=2018-07-01-preview&_=1765372176638 HTTP/2
Host: management.azure.com
Authorization: Bearer <token>
Content-Length: 524
Content-Type: application/json
{
"dynamicInvocationDefinition": {
"operationID": "nine",
"parameters": {
"one": {
"value": ".."
},
"two": {
"value": ".."
},
"three": {
"value": "sql"
},
"four": {
"value": "88d28f5ff7b642cfb91df184938d074c"
},
"five": {
"value": "v2"
},
"six": {
"value": "datasets"
},
"seven": {
"value": "victimservice.database.windows.net,VictimDatabase"
},
"eigth": {
"value": "query"
},
"nine": {
"value": "sql"
},
"query": {
"value": {
"query": "Insert INTO MySecrets VALUES ('NewEvilSecrets', 3)"
}
}
},
"ItemsPath": "ResultSets/Table1",
"ItemValuePath": "secrets",
"ItemTitlePath": "value"
}
}
最初のパラメータがパストラバーサルを実行し、その後データベースに対して任意のSQLクエリを投げているのはすぐに分かる。
最初に報告したのはDynamicListのケースだけだった。同じような修正がDynamicInvokeにも入り、残り二つも報告できるだろうと考えたからだ。残念ながら、彼らの修正はこれらのエンドポイントをすべてブロックするだけだった。しかし他に緩和策が入っているようには見えないので、これらのエンドポイントが再びアクセス可能になれば、やはり脆弱だろう。
さらなる攻略
これらの動的呼び出しでパスをいじる話は、ここで終わりを迎えたようだった。DynamicInvokeのパス制限は突破できず、他のエンドポイントはすべてブロックされていた。必要だったのは別の入口だ。ある日眠ろうとしていたとき、Logic Apps 自体を使うという手を自分が見落としていたことに気づいた。Consumption と Standard の Logic Apps では、接続作成のフローに実は大きな違いがある。最も分かりやすいのは、ワークフローが他の多数と同居する Consumption Logic Apps が Version 1 の接続を作るのに対し、専用ホストの Standard Logic Apps は Version 2 の接続を作る点だ。次の図を見ると、両フローのより微妙な違いもはっきりする:


違いが分かるだろうか。こうして書き出してみると、論点はかなりはっきりする。Standard Logic App の場合は、リソースを作成し、バックエンドサーバーに必要な認証を設定したうえで、そのロジックアプリをマネージド ID で認可すればアクセスできる。だが Consumption Logic App には、認可すべき ID がそもそも存在しない。こちら側で制御できないからだ。これはある意味もっともではある。認可できないのだから、しない。
ここから導かれるのは、すべての Consumption Logic App が すべての Version 1 API Connection に暗黙的にアクセスできるということだ。これを悪用させない唯一の壁は、何らかの入力検証に違いない。
Logic App ワークフローのクラシックなビューは入力の制約がかなり強いが、API エンドポイントと GUI の Code ビューを使えば、入力をもう少し自由にいじれる。

入力の中身を詳しく見たり、編集したり、さらには追加したりできる。参照先のコネクタを被害者の接続に差し替えるという安直な手は通らない。実行時に、その接続が自分のものであるかを徹底的に検証しているからだ。とはいえコードベースは手元にあるので、有効なパラメータを全部探し出せばいい。host.api.RuntimeUrl パラメータを見つけるのに、それほど探し回る必要はなかった。
このパラメータがなぜ含まれているのか、なぜ今もなお受け付けられているのか、私にはどうにも理解できない。だが、動きは予想どおりだ。host.connection で参照した接続に対して一連の検証を行ったうえで、その接続に埋め込まれた runtimeUrl の代わりに、まったく別のものを使うよう指定できる。ホストが azure-apihub.net で終わっていれば、必要な Authorization ヘッダーも付与される。

では、このワークフローを実行すると何が起きるのか。

注意深い読者なら、エクスプロイトで使われているruntimeUrlが単なるconnectionIdではないことに気づくだろう。URI に余分な要素が付加されているためだが、こちらには影響しない。エクスプロイトは問題なく動く。
天からのマナ
もちろんこの件も Microsoft に報告し、彼らはしばらく修正に取り組んだ。修正がようやく出たとき、エクスプロイトはエラーメッセージを返すようになった。
The host API runtime URL is not valid for the API connection '/subscriptions/8e3ce52f-d45b-4347-8705-65892507465e/resourceGroups/token-storer/providers/Microsoft.Web/connections/keyvault-1'. The runtime URL path must match the expected API endpoint 'https://logic-apis-norwayeast.azure-apihub.net/apim/keyvault/cbfb0f4313144117a7440368902bb871?' for the referenced connection. Either omit the 'host.api' input property, or correct the runtime URL to match the API associated with the connection.
メッセージがやたら長いことを除けば、かなりまともな修正に見える。ランタイム URL が期待値と完全に一致していなければ、問答無用で弾く。パラメータを単に削除するだけではないところも、良識的だ。もちろん、その値を間違ったタイミングで検証していなければ、の話だが:

同じことをもう一度試しても、結果は変わらない。何が起きているかというと、検証がパスの正規化より前に行われるため、ランタイムは URI が参照先の接続を指していると判断するのに、APIM インスタンス側ではそうは見えないのだ。
そして、またしても重大な脆弱性をひとつ手に入れた。
話はこれで終わらない。Microsoft がこのパストラバーサルの修正をなんとかまとめ上げたとき、意外なことが起きた。元の RuntimeUrl パスが検証されなくなり、その結果、あの攻撃がまた通るようになったのだ。画像を載せたいところだが、上と同じ画像になるだけなので、想像してもらえればいい。
報告と報奨金
API Connections で得た Azure の特権昇格の重大な脆弱性は、合計で 5 件になった。おそらく想定より多い。ここで報告した脆弱性はすべて、報告書を提出してから妥当な期間内に修正された。途中で MSRC と言い争うこともあったが、全体としては結果に満足している。