Azure's Weakest Link - Full Cross-Tenant Compromise

Posted by Haakon Holm Gulbrandsrud on August 20, 2025 · 11 mins read

In my previous blog post Azure’s Weakest Link? I hinted at the existence of a hidden, globally shared, architecture that, if exploited, could allow for cross-tenant compromises. I can now reveal that this was indeed exploitable, and the massive potential of this bug awarded me $40.000, and a trip to Black Hat. The slides of the presentation can be found here, and I will update with a link to the recorded talk when it is published.

TL;DR

API Connections allow anyone to fully compromise any other Connection worldwide, giving full access to the connected Backend. This includes cross-tenant compromise of Key Vaults and Azure SQL databases, as well as any other externally connected service, such as Jira or SalesForce.

Background

If you haven’t read the first part of this series, I would recommend checking that out first, Azure’s Weakest Link? - Part 1.

The crucial detail that allows for cross-tenant compromise of any deployed API Connection is, as I hinted at in part 1, the shared APIM Instance where all API Connections are created. From Microsoft’s documentation we find a nice overview of what the architecture looks like: Complete Architecture overview, showing the APIM instance, Token Store and the routing to the backend service

We can see that there are a couple of resource types here, Logic Apps in particular, that can query the APIM instance. The APIM service, in turn, exchanges the “User token” with the “Connection Token”, and then uses that to complete the call to the backend service. To be able to do this exchange however, you must be in the Connection ACL of the API Connection, which the Logic App itself is by default.

Here I am querying my own ARM Connector as an example.

GET /apim/arm/f54b3cce37e248e78a62766ea85caad9/subscriptions?x-ms-api-version=2016-01-01 HTTP/2
Host: d84b73b612cf5960.16.common.logic-norwayeast.azure-apihub.net
Authorization: Bearer <Token>


HTTP/2 403 Forbidden
Content-Length: 473
Content-Type: application/json
X-Ms-Failure-Cause: apihub-token-exchange
X-Ms-Apihub-Obo: false
X-Ms-Apihub-Cached-Response: false
Date: Wed, 20 Aug 2025 07:14:07 GMT

{
    "status":403,
    "source":"https://logic-norwayeast-001.token.azure-apihub.net:443/tokens/logic-apis-norwayeast/d84b73b612cf5960/arm/f54b3cce37e248e78a62766ea85caad9/exchange",
    "message":"Error from token exchange: Permission denied due to missing connection ACL: User = 328bb660-70cf-4212-8ac8-2f28413d8a19@72f13b38-6d4b-417c-be51-4e46f66a37a8 appid=c44b4083-3bb0-49c1-b47d-974e53cbdf3c, connection=logic-apis-norwayeast/d84b73b612cf5960/arm/f54b3cce37e248e78a62766ea85caad9"
}

No one but the Logic App itself should be allowed to do this token exchange. But this is obviously not true, as we are in fact able to complete the call, as evidenced by part 1 of this series.

Yet another Undocumented Endpoint

Crucially, what is missing from the diagram is the possibility of calling the APIM Instance directly from the Azure Resource Manager, ARM. As the /extensions/proxy/[Action] endpoint has sadly been restricted to only allowing the explicitly defined testlink endpoint, we need to find another way of calling arbitrary actions. To our rescue comes, also undocumented, DynamicInvoke. Clearly defined in the definition of the API Connection, we see not only the testLinks field we used for our in-tenant exploit, but also an even more interesting testRequests:

testLinks, testRequests, and the URL of the APIM instance is defined in the API Connection

This gives us, once again, a clearly defined way of calling any defined action endpoint on the connection, by substituting the path and method in the request body, and it is even also possible to include custom body and headers in this request. As an example, here I am listing the secrets of an Azure Key Vault Connector:

POST /subscriptions/162fc6db-03cd-4fe8-ab44-dc0a947e74af/resourceGroups/api-connection/providers/Microsoft.Web/connections/keyvault/DynamicInvoke?api-version=2018-07-01-preview HTTP/2
Host: management.azure.com


{
    "request":{
        "method":"get",
        "path":"/secrets",
    }
}


HTTP/2 200 OK
Content-Length: 1315
Content-Type: application/json; charset=utf-8

{
    "response": {
        "statusCode": "OK",
        "body": {
            "value": [
                {
                    "name": "SuperSecret",
                    "version": null,
                    "contentType": null,
                    "isEnabled": true,
                    "createdTime": "2025-04-04T05:38:26Z",
                    "lastUpdatedTime": "2025-04-04T05:38:26Z",
                    "validityStartTime": null,
                    "validityEndTime": null
                }
            ],
            "continuationToken": null
        }
    }
}

As it is a POST request we are using on the DynamicInvoke endpoint, we must be at least Contributor to the API Connection, meaning that we need a significant amount of privileges in the tenant. This means in turn that simply listing the secrets is not a particularly interesting bug, as we presumable already could do this by going directly to the Key Vault. Although, this does evade any special RBAC rules, as we do not need the special secret reading role to read the secrets.

What is extremely interesting however, is that we are given path parameters that ARM is going to append to the request to call the APIM instance. You can imagine that when ARM receives the DynamicInvoke request it is going to take the host and ConnectionID parameters from the API Connection definition, and then add the action endpoint to it, so the request will look something like this:

GET /apim/[ConnectorType]/[ConnectionId]/[Action-Endpoint] HTTP/1.1
Host: [Global APIM Host]
Authorization: Bearer <Super-Privileged ARM Token>

Any seasoned bug hunter will immediately see the potential here.

Exploitation

With the assumption that ARM has super-privileged rights to ALL API Connections on the server, the possibility of traversing from our own connection to another is mouth-watering. There are only two problems we need to overcome, the first is obvious, how to find someone else’s connectionID; and the second is, how to make ARM accept our path-traversal path in its request.

Solving the second problem is quite easy, as string parameters are supported in the connections, meaning that any action endpoint with a string path parameter in the URL is exploitable, but as we don’t want to hunt around for such parameters, I decided it would be easier to just create my own.

Using a Logic App Custom Connector, a resource from Azure, we are able to create any kind of API Connection definition, and it will be placed alongside the predefined ones. We even get a nice Swagger view to define our endpoints:

The view we are greeted with when we have created a Custom Connector Definition

Then, defining a vulnerable path is quite straightforward, just create the simplest one:

A path that accepts a string parameter

After creating an API Connection that uses this Custom Definition, which can be done through a Logic App or manually, we can try to do some exploitation. Our custom connection is not going to get a nice human-readable name, like arm or keyvault as the predefined ones, it is just going to be a hash-value of something, but that does not really matter. We know that when querying DynamicInvoke with our Custom Connector, ARM is going to do its thing and query the endpoint /apim/[CustomConnector]/[ConnectionId]/path/{path}. By supplying a {path} that gets translated to something like ../../../../[VictimConnectorType]/[VictimConnectionID]/[action], the full path will be normalized to only /apim/[VictimConnectorType]/[VictimConnectionId]/[action]. This is a direct attack on any other API Connection on the APIM Instance.

Given that the ARM token used in the call has full access to all Connections, we then have full control of it, and can query any endpoint with the full privileges of the connection, which in most cases will be administrator rights. This of course works flawlessly, as shown here, where we traverse to a victim connection and retrieve their secrets:

Retrieving secrets from a keyvault connector in a different tenant

Taken as a whole, we are then able to completely compromise any resource connected by an API Connection, anywhere in the world. The most serious case is perhaps a Key Vault, as that is where you store your most sensitive secrets, but any backend is equally vulnerable, ranging from your Slack instance to your Databases or Salesforce instances. The only requirement is that you have an API Connection to your resource.

The first problem we had is still not solved however, we have no reliable way of obtaining the ConnectionId of a given Connection. While I have not found a way of leaking these from the APIM instance, these are not secret values and can be found quite readily on the Internet. For instance CI/CD logs will often include them, and sometimes people will post them on open forums. Perhaps, with more research done on the APIM server, or by querying the Token Store, there will be ways of obtaining them.

Microsoft Response

I submitted the bug to MSRC on April 7, it was confirmed only three days later and mitigations were in place after a week. After some time thinking about it, I was awarded a $40.000 bounty.

The fix that was implemented however, was perhaps not so satisfactory. It seems there is now a blacklist in place on the path parameter, disallowing ../ and some URL-encoded variants. This means that bypasses are probably possible, either by finding some other way of normalizing to ../ or by being able to change the path of the API Connection directly. Any bypass should yield the same bounty, so I encourage you to try it out for yourself.