GitHub Actions Security: Pwn Requests and Token Abuse
GitHub Actions as an attack surface: pull_request_target pwn requests, GITHUB_TOKEN scope, expression injection and self hosted runner risk.
A six-line workflow can receive a write token, access secrets, and execute code that came from a pull request.
That is why CI/CD is a real attack surface.
GitHub Actions combines code, automation and credentials in the same flow. Runners and permissions complete that trust boundary. When the boundary between trusted code and untrusted input becomes unclear, the pipeline may expose far more access than the developer expected.
The examples below assume a lab repository or an authorized penetration test.
Read the trigger before the script
The first field I inspect is on:.
A workflow triggered by push has a different trust origin from one triggered by an external pull request.
pull_request is designed to process fork code with additional restrictions.
pull_request_target runs in the context of the base repository and may receive a more privileged GITHUB_TOKEN plus access to secrets.
That event can be used safely.
Risk appears when the workflow fetches untrusted pull request code and executes it inside that privileged context.
The classic pwn request pattern
Consider:
1
2
3
4
5
6
7
8
9
10
11
12
13
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install
- run: npm test
The workflow file came from the trusted branch.
Then it checks out code controlled by the pull request author.
npm install may execute lifecycle scripts.
npm test executes repository code.
If the job has secrets or a write-capable token, the trust boundary has collapsed.
Checkout alone does not execute the payload
This distinction matters during review.
Bringing untrusted files into the workspace does not execute them by itself.
The chain closes when another step interprets those files.
npm install.
pip install ..
make.
gradle test.
A shell script from the repository.
A local action under .github/actions/.
Even a parser may become relevant when the processed format has unsafe behavior.
I follow the data to the first execution point.
GITHUB_TOKEN needs least privilege
GitHub creates a GITHUB_TOKEN for each job.
Its permissions vary with event type, repository configuration, and the workflow permissions block.
I prefer explicit configuration:
1
2
permissions:
contents: read
If a workflow needs to create an issue, grant only what that action needs.
If it needs to publish a package, place that operation in a separate trusted job.
A broad token inside a job that processes external code is a dangerous combination.
Expressions can carry untrusted input
Consider:
1
- run: echo "Title: ${{ github.event.pull_request.title }}"
The pull request title is user-controlled.
Direct interpolation into shell can create command injection depending on the generated command.
A safer pattern uses an environment variable:
1
2
3
4
- name: Print title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE"
The value is still untrusted.
It is no longer being inserted directly into the shell source created from the template.
Third-party actions are part of the supply chain
This:
1
uses: vendor/action@main
trusts the future state of the main branch.
If that reference changes, your pipeline runs different code.
Pinning by commit reduces the risk:
1
uses: vendor/action@3f2a7b1c...
Tags may also move in some situations.
For sensitive workflows, I want to know exactly which commit will execute.
Self-hosted runners change the severity
A GitHub-hosted runner starts in an ephemeral VM for the job.
A self-hosted runner may be a persistent machine inside the corporate network.
It may reach an internal registry.
It may access databases.
It may carry local credentials.
It may keep files from previous jobs.
If untrusted code executes there, the problem moves beyond the repository into the network.
GitHub recommends strong caution around self-hosted runners, especially when pull requests from untrusted users can reach them.
Secrets do not need to appear in logs to leak
Log masking helps.
It does not create perfect isolation.
A payload can send a value over the network.
It can encode the secret.
It can write it into an artifact.
It can place it in a cache.
It can simply use the credential against the final service.
The strongest control is to avoid giving the secret to a job that executes untrusted code.
OIDC reduces long-lived secrets
GitHub Actions supports OpenID Connect so workflows can authenticate to cloud providers without storing a static access key in the repository.
That is a better design.
The cloud trust policy still needs tight restrictions.
A role that trusts any branch, repository, or environment expands the surface again.
OIDC replaces a static secret with a dynamic trust relationship.
That relationship needs to be specific.
Artifacts also cross trust boundaries
An unprivileged workflow may build an artifact from pull request code.
A later privileged workflow may download that artifact and execute it.
Two separate workflows do not help if the second treats the artifact as trusted code.
Files produced by an untrusted context remain untrusted.
This matters in chains built around workflow_run.
How I review a repository
I start with:
1
.github/workflows/
Then I mark privileged triggers.
I look for pull_request_target, workflow_run, issue_comment, and other flows that may mix external input with privilege.
I read permissions.
I locate secrets.
I follow dynamic checkout refs.
Then I mark steps that execute workspace content.
Finally, I inspect runner placement, network access, and cloud credentials.
Remediation
Separate untrusted-code processing from contexts that hold secrets.
Use pull_request when the job does not need elevated trust.
Restrict GITHUB_TOKEN.
Pin critical actions by commit.
Keep untrusted code away from self-hosted runners.
Use environments and approvals where they add a real control.
Prefer OIDC over static cloud keys and restrict the trust policy.
A secure pipeline treats pull request content as hostile input until repository policy explicitly moves it into a trusted state.
workflow_run can move the problem into a second workflow
A common pattern separates pull request builds from privileged publishing.
The first workflow runs without secrets.
The second uses workflow_run, downloads an artifact and receives a write-capable token.
The architecture looks isolated.
If the second workflow executes a script or binary produced by the first, untrusted code crossed the boundary inside the artifact.
I trace the provenance of every artifact entering a privileged job.
Reusable workflows carry trust too
workflow_call helps centralize automation.
It can also centralize secrets and permissions.
When I see secrets: inherit, I check exactly which called workflow receives those credentials and who can change its code.
A movable branch reference adds another risk.
A sensitive reusable workflow deserves pinning and ownership controls as strong as an external action.
Cache should not move executable state across trust levels
Dependency caching improves performance.
If a lower-trust job can populate a cache later consumed by a privileged job, cache poisoning may become possible depending on the format and tooling.
I pay special attention to caches containing scripts, binaries, package manager directories or content that is executed automatically.
The rule resembles artifact handling.
Lower-trust input remains lower-trust after another job restores it.
References
GitHub Docs, Securely using pull_request_target.
GitHub Docs, Secure use reference.
GitHub Docs, GITHUB_TOKEN.


