From beb8ae11a253459022bc86eec7d014a086a28db2 Mon Sep 17 00:00:00 2001 From: HackTricks News Bot Date: Mon, 9 Nov 2026 20:50:29 +0000 Subject: [PATCH 1/2] Add content from: The Pwn Request Reckoning: What Still Works Against GitHub A... --- .../abusing-github-actions/README.md | 34 ++++++++++++++++--- .../gh-actions-artifact-poisoning.md | 29 ++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) 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 ba3396d791..e23934ebb8 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 @@ -172,7 +172,7 @@ As the attacker also controls the code being executed, even if there aren't secr ### **`pull_request_target`** -The workflow trigger **`pull_request_target`** have **write permission** to the target repository and **access to secrets** (and doesn't ask for permission).[[10]](#references)[[14]](#references) +The **`pull_request_target`** trigger runs from the base repository and can access its secrets and the permissions granted to `GITHUB_TOKEN`. It does not use the usual external-contributor approval gate for `pull_request` runs; a protected environment or an applicable event policy is needed when a privileged job requires an approval boundary.[[10]](#references)[[14]](#references)[[52]](#references) Note that the workflow trigger **`pull_request_target`** **runs in the base context** and not in the one given by the PR (to **not execute untrusted code**). For more info about `pull_request_target` [**check the docs**](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target).[[14]](#references)\ Moreover, for more info about this specific dangerous use check this [**github blog post**](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/).[[14]](#references) @@ -279,6 +279,10 @@ jobs: 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.[[28]](#references) +The checkout fork guard does not cover `issue_comment` or `repository_dispatch`. Thus even `actions/checkout@v7` can fetch a fork PR through `refs/pull//merge` in a comment-triggered, secret-bearing job. A `contains(comment.body, '/deploy')` check only matches text; it does not authorize the commenter. For comment commands, check the commenter's **current** repository permission (write or higher), rather than `author_association`. A dispatch payload is also not proof of the sender's identity.[[51]](#references)[[52]](#references) + +`refs/pull//merge` is mutable. If a maintainer authorizes a PR and the fork owner pushes another commit before checkout, the job can execute code that was never approved. Bind approval to the exact head SHA and verify that the PR still points to that SHA immediately before execution. This matters even when the command itself came from an authorized commenter.[[52]](#references) + ## Abusing Forked Execution @@ -286,7 +290,7 @@ We have mentioned all the ways an external attacker could manage to make a githu ### Untrusted checkout execution -In the case of **`pull_request`,** the workflow is going to be executed in the **context of the PR** (so it'll execute the **malicious PRs code**), but someone needs to **authorize it first** and it will run with some [limitations](#pull_request).[[10]](#references) +With **`pull_request`**, the workflow runs in the **PR context** and may execute the fork's code. External-contributor approval depends on repository settings and contributor status; when required, approval gates that run, but does not grant it secrets or a write-capable token. See the [trigger limitations](#pull_request).[[10]](#references)[[52]](#references) In case of a workflow using **`pull_request_target` or `workflow_run`** that depends on a workflow that can be triggered from **`pull_request_target` or `pull_request`** the code from the original repo will be executed, so the **attacker cannot control the executed code**.[[10]](#references)[[14]](#references) @@ -323,6 +327,22 @@ jobs: The potentially **untrusted code is being run during `npm install` or `npm build`** as the build scripts and referenced **packages are controlled by the author of the PR**.[[14]](#references)[[24]](#references) +Supported `actions/checkout` versions now reject fork-head repositories, PR head/merge refs, and fork head/merge SHAs under `pull_request_target` and PR-triggered `workflow_run` unless `allow-unsafe-pr-checkout: true` is set. The check is implemented in the action, not in Git itself. A SHA pin to an older checkout release keeps the old behavior: for example, the commit `08eba0b27e820071cde6df949e0beb9ba4906955` points to v4.3.0. Check the release behind a pinned SHA; the first protected releases are v2.8.0, v3.7.0, v4.4.0, v5.1.0, v6.1.0, and v7.0.0. Mutable major tags such as `@v4` inherit the fix when they resolve to a patched release, while old exact tags and SHAs do not.[[51]](#references)[[52]](#references) + +A privileged workflow can still fetch fork code with direct `git clone`/`git checkout`, `gh pr checkout`, `gh repo clone`, or a third-party checkout action. These paths do not run `actions/checkout`'s fork check. Look for a PR-supplied repository or ref followed by an install, test, or build step; the fetch alone is not the execution point.[[51]](#references)[[52]](#references) + +```yaml +- env: + PR_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_SHA: ${{ github.event.pull_request.head.sha }} + run: | + git clone "https://github.com/${PR_REPO}" pr-code + git -C pr-code checkout "${PR_SHA}" + npm --prefix pr-code install +``` + +The checkout input `allow-unsafe-pr-checkout: true` deliberately restores the fork checkout path even on patched releases; it is not a safe substitute for isolating the untrusted build from secrets and write tokens.[[51]](#references)[[52]](#references) + > [!WARNING] > A github dork to search for vulnerable actions is: `event.pull_request pull_request_target extension:yml` however, there are different ways to configure the jobs to be executed securely even if the action is configured insecurely (like using conditionals about who is the actor generating the PR). @@ -480,12 +500,12 @@ Typical attacker goals after tag poisoning: ### Cache Poisoning -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.[[17]](#references)[[26]](#references) +GitHub exposes a cross-workflow cache scoped by key, version, and branch. Historically, even a job with `permissions: contents: read` could write a shared cache key, but since June 2026 untrusted default-branch contexts can receive read-only cache tokens; test whether the run can actually save a cache entry.[[17]](#references)[[18]](#references) 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.[[17]](#references)[[26]](#references) **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.[[17]](#references) -- 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. +- Cache entries can be shared across workflows when keys and permitted branch scopes match; `restore-keys` may match a prefix. The cache is not scoped by workflow trust level.[[17]](#references) +- Repository `contents: read` permissions did not historically prevent cache saves; current cache-token policy must also permit the write.[[18]](#references) - 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.[[17]](#references) - 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.[[17]](#references) @@ -846,6 +866,8 @@ Update README.md 1. make sure to be ### Abusing Self-hosted runners +On a persistent self-hosted runner, an untrusted job can alter local `~/.npm` or `~/.cache/pip` content, build directories, or installed tools. A later privileged release job scheduled on the same host may then execute the poisoned files. These are **runner filesystem** paths, not Actions cache entries, so a read-only Actions cache token does not prevent this cross-job path; use ephemeral runners or separate runner groups across trust levels.[[52]](#references) + The way to find which **Github Actions are being executed in non-github infrastructure** is to search for **`runs-on: self-hosted`** in the Github Action configuration yaml.[[20]](#references) **Self-hosted** runners might have access to **extra sensitive information**, to other **network systems** (vulnerable endpoints in the network? metadata service?) or, even if it's isolated and destroyed, **more than one action might be run at the same time** and the malicious one could **steal the secrets** of the other one.[[19]](#references)[[20]](#references) @@ -1005,4 +1027,6 @@ An organization in GitHub is very proactive in reporting accounts to GitHub. All - [48] [Compromised AsyncAPI npm Packages: Inside a CI Supply-Chain Attack](https://securitylabs.datadoghq.com/articles/compromised-asyncapi-npm-packages/) - [49] [LiteLLM release-source compare diff](https://github.com/BerriAI/litellm/compare/a8113b0dd721804c752a6f1952367fb642205224...20535d8fa11f786a6c24df7488a0e449ed5f09ad) - [50] [LiteLLM Compromised, Steals Credentials - Wiz](https://wiz.io/blog/litellm-compromised-steals-credentials) +- [51] [Safer pull_request_target defaults for GitHub Actions checkout - GitHub Changelog](https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/) +- [52] [The Pwn Request Reckoning: What Still Works Against GitHub Actions - Wiz](https://wiz.io/blog/github-actions-security-pwn-request-reckoning) {{#include ../../../banners/hacktricks-training.md}} diff --git a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md index a3039f844f..c467d4adc3 100644 --- a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md +++ b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md @@ -2,6 +2,35 @@ {{#include ../../../banners/hacktricks-training.md}} +## Cross-run artifact execution + +A `pull_request` build may run fork code without privileged secrets and upload its `dist/` output as an artifact. A later `workflow_run` job can use `actions/download-artifact` with the triggering run ID and a token to download that output, then execute a file from it with release credentials. No fork checkout occurs in the privileged job, so checkout's fork guard is irrelevant; read-only cache tokens do not restrict artifact uploads or downloads.[[1]](#references)[[2]](#references) + +For example, if the unprivileged **Build** workflow uploads `build-output` from `dist/`, the following privileged workflow crosses the trust boundary:[[1]](#references)[[2]](#references) + +```yaml +on: {workflow_run: {workflows: [Build], types: [completed]}} +jobs: + deploy: + runs-on: ubuntu-latest + permissions: {actions: read} + steps: + - uses: actions/download-artifact@v4 + with: + name: build-output + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: dist + - run: ./dist/deploy.sh + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} +``` + +The `run-id` binds the download to the triggering build, but it does **not** make that build's output trusted. The execution of `deploy.sh`, rather than the download alone, gives the fork author the privileged job's environment. Keep low-trust artifacts as data, not executable input.[[2]](#references) See [the `workflow_run` trust boundary](./#workflow_run) and [cache poisoning](gh-actions-cache-poisoning.md) for related, but distinct, paths. + ## References +- [1] [Download workflow artifact GitHub Action](https://github.com/actions/download-artifact) +- [2] [The Pwn Request Reckoning: What Still Works Against GitHub Actions - Wiz](https://wiz.io/blog/github-actions-security-pwn-request-reckoning) + {{#include ../../../banners/hacktricks-training.md}} From 9241f3e41c28ace41a7a32db852a707f362f52f2 Mon Sep 17 00:00:00 2001 From: SirBroccoli Date: Mon, 9 Nov 2026 22:14:50 +0100 Subject: [PATCH 2/2] docs: repair PR placement and formatting after editorial review --- .../github-security/abusing-github-actions/README.md | 12 ++++++------ .../gh-actions-artifact-poisoning.md | 11 ++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) 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 e23934ebb8..bb4ae15b0c 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 @@ -172,7 +172,7 @@ As the attacker also controls the code being executed, even if there aren't secr ### **`pull_request_target`** -The **`pull_request_target`** trigger runs from the base repository and can access its secrets and the permissions granted to `GITHUB_TOKEN`. It does not use the usual external-contributor approval gate for `pull_request` runs; a protected environment or an applicable event policy is needed when a privileged job requires an approval boundary.[[10]](#references)[[14]](#references)[[52]](#references) +The **`pull_request_target`** trigger runs from the base repository and can access its secrets and the permissions granted to `GITHUB_TOKEN`. It does not use the usual external-contributor approval gate for `pull_request` runs; a protected environment with required reviewers provides a human gate, while an Actions event or actor policy can restrict who may trigger the workflow.[[10]](#references)[[14]](#references)[[52]](#references) Note that the workflow trigger **`pull_request_target`** **runs in the base context** and not in the one given by the PR (to **not execute untrusted code**). For more info about `pull_request_target` [**check the docs**](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target).[[14]](#references)\ Moreover, for more info about this specific dangerous use check this [**github blog post**](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/).[[14]](#references) @@ -281,7 +281,7 @@ This is the exact “pwn request” primitive that breached the Rspack org: the The checkout fork guard does not cover `issue_comment` or `repository_dispatch`. Thus even `actions/checkout@v7` can fetch a fork PR through `refs/pull//merge` in a comment-triggered, secret-bearing job. A `contains(comment.body, '/deploy')` check only matches text; it does not authorize the commenter. For comment commands, check the commenter's **current** repository permission (write or higher), rather than `author_association`. A dispatch payload is also not proof of the sender's identity.[[51]](#references)[[52]](#references) -`refs/pull//merge` is mutable. If a maintainer authorizes a PR and the fork owner pushes another commit before checkout, the job can execute code that was never approved. Bind approval to the exact head SHA and verify that the PR still points to that SHA immediately before execution. This matters even when the command itself came from an authorized commenter.[[52]](#references) +`refs/pull//merge` is mutable. If a maintainer authorizes a PR and the fork owner pushes another commit before checkout, the job can execute code that was never approved. Compare the PR head SHA with the commit authorized at comment time and abort if it changed before execution. This matters even when the command came from an authorized commenter.[[52]](#references) ## Abusing Forked Execution @@ -290,9 +290,9 @@ We have mentioned all the ways an external attacker could manage to make a githu ### Untrusted checkout execution -With **`pull_request`**, the workflow runs in the **PR context** and may execute the fork's code. External-contributor approval depends on repository settings and contributor status; when required, approval gates that run, but does not grant it secrets or a write-capable token. See the [trigger limitations](#pull_request).[[10]](#references)[[52]](#references) +With **`pull_request`**, the workflow runs in the **PR context** and may execute the fork's code. For public fork PRs, approval depends on repository settings and contributor status; when required, it gates the run but does not grant secrets or a write-capable token. Private-repository fork policies can explicitly allow either privilege. See the [trigger limitations](#pull_request).[[10]](#references)[[52]](#references) -In case of a workflow using **`pull_request_target` or `workflow_run`** that depends on a workflow that can be triggered from **`pull_request_target` or `pull_request`** the code from the original repo will be executed, so the **attacker cannot control the executed code**.[[10]](#references)[[14]](#references) +A `pull_request_target` or `workflow_run` workflow uses the base repository workflow definition. This does not automatically execute the PR code, but an explicit fork checkout or other low-trust input can still cross the boundary.[[10]](#references)[[14]](#references) > [!CAUTION] > However, if the **action** has an **explicit PR checkou**t that will **get the code from the PR** (and not from base), it will use the attackers controlled code. For example (check line 12 where the PR code is downloaded): @@ -325,7 +325,7 @@ jobs: Thank you! -The potentially **untrusted code is being run during `npm install` or `npm build`** as the build scripts and referenced **packages are controlled by the author of the PR**.[[14]](#references)[[24]](#references) +The potentially **untrusted code is being run during `npm install` or `npm build`** as the build scripts and referenced **packages are controlled by the author of the PR**.[[14]](#references)[[24]](#references) The example above uses a floating `@v2` tag, which now receives the checkout fork guard; it only illustrates the dangerous data flow, not a working default checkout on current releases.[[51]](#references) Supported `actions/checkout` versions now reject fork-head repositories, PR head/merge refs, and fork head/merge SHAs under `pull_request_target` and PR-triggered `workflow_run` unless `allow-unsafe-pr-checkout: true` is set. The check is implemented in the action, not in Git itself. A SHA pin to an older checkout release keeps the old behavior: for example, the commit `08eba0b27e820071cde6df949e0beb9ba4906955` points to v4.3.0. Check the release behind a pinned SHA; the first protected releases are v2.8.0, v3.7.0, v4.4.0, v5.1.0, v6.1.0, and v7.0.0. Mutable major tags such as `@v4` inherit the fix when they resolve to a patched release, while old exact tags and SHAs do not.[[51]](#references)[[52]](#references) @@ -866,7 +866,7 @@ Update README.md 1. make sure to be ### Abusing Self-hosted runners -On a persistent self-hosted runner, an untrusted job can alter local `~/.npm` or `~/.cache/pip` content, build directories, or installed tools. A later privileged release job scheduled on the same host may then execute the poisoned files. These are **runner filesystem** paths, not Actions cache entries, so a read-only Actions cache token does not prevent this cross-job path; use ephemeral runners or separate runner groups across trust levels.[[52]](#references) +On a persistent self-hosted runner, an untrusted job can alter local `~/.npm` or `~/.cache/pip` content, build directories, or installed tools. A later privileged release job scheduled on the same host may then execute the poisoned files. These are **runner filesystem** paths, not Actions cache entries, so a read-only Actions cache token does not prevent this cross-job path. It requires a later trusted job to reuse that runner or shared filesystem; ephemeral runners or isolated runner groups with separate filesystems remove that reuse path.[[20]](#references)[[52]](#references) The way to find which **Github Actions are being executed in non-github infrastructure** is to search for **`runs-on: self-hosted`** in the Github Action configuration yaml.[[20]](#references) diff --git a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md index c467d4adc3..ef57ffed3a 100644 --- a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md +++ b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-artifact-poisoning.md @@ -4,9 +4,12 @@ ## Cross-run artifact execution -A `pull_request` build may run fork code without privileged secrets and upload its `dist/` output as an artifact. A later `workflow_run` job can use `actions/download-artifact` with the triggering run ID and a token to download that output, then execute a file from it with release credentials. No fork checkout occurs in the privileged job, so checkout's fork guard is irrelevant; read-only cache tokens do not restrict artifact uploads or downloads.[[1]](#references)[[2]](#references) +A `pull_request` build may upload fork-controlled output as an artifact without receiving secrets. A later privileged `workflow_run` job can download it using `actions/download-artifact`, a token with `actions: read`, and the triggering run ID. That ID selects the source run but does not make its output trusted. If the privileged job executes code from the artifact while release credentials are available, the fork author controls that execution. No fork checkout occurs, and neither the checkout guard nor read-only Actions cache tokens prevent this path.[[1]](#references)[[2]](#references) -For example, if the unprivileged **Build** workflow uploads `build-output` from `dist/`, the following privileged workflow crosses the trust boundary:[[1]](#references)[[2]](#references) +If an unprivileged **Build** workflow uploads `build-output` from `dist/`, the following illustrative privileged workflow selects that run's artifact. The final direct-execution step does not work as written because downloaded files lose executable permissions.[[1]](#references)[[2]](#references) + +
+Illustrative cross-run artifact consumer ```yaml on: {workflow_run: {workflows: [Build], types: [completed]}} @@ -26,7 +29,9 @@ jobs: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} ``` -The `run-id` binds the download to the triggering build, but it does **not** make that build's output trusted. The execution of `deploy.sh`, rather than the download alone, gives the fork author the privileged job's environment. Keep low-trust artifacts as data, not executable input.[[2]](#references) See [the `workflow_run` trust boundary](./#workflow_run) and [cache poisoning](gh-actions-cache-poisoning.md) for related, but distinct, paths. +
+ +Artifact upload/download does not preserve executable file permissions: downloaded files normally have mode `644`, so direct execution of a downloaded script fails by default. The trust-boundary risk remains if the consumer runs artifact content through an interpreter or build step.[[1]](#references) See [the `workflow_run` trust boundary](./#workflow_run) and [cache poisoning](gh-actions-cache-poisoning.md) for related, but distinct, paths. ## References