GitHub Actions

Allowed unsecure commands

There are deprecated set-env and add-path workflow commands that can be explicitly enabled by setting the ACTIONS_ALLOW_UNSECURE_COMMANDS environment variable to true.
  • set-env sets environment variables via the following workflow command ::set-env name=<NAME>::<VALUE>
  • add-path updates the PATH environment variable via the following workflow command ::add-path::<VALUE>
Depending on the use of the environment variable, this could allow an attacker, in the worst case, to change the path and run a command other than the intended one, leading to arbitrary command execution. For example, consider the following workflow:
name: Vulnerable workflow
on:
pull_request_target
env:
# 1. Enable unsecure commands
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
ENVIRONMENT_NAME: prod
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# 2. Print github context
- run: |
print("""${{ toJSON(github) }}""")
shell: python
- name: Create new PR deployment
uses: actions/github-script@v5
with:
# 3. Create deployment
script: |
return await github.rest.repos.createDeployment({
...context.repo,
ref: context.payload.pull_request.head.sha,
auto_merge: false,
required_contexts: [],
environment: "${{ env.ENVIRONMENT_NAME }}",
transient_environment: false,
production_environment: false,
});
github-token: ${{ secrets.GITHUB_TOKEN }}
The above workflow enables unsecure commands by setting ACTIONS_ALLOW_UNSECURE_COMMANDS to true in the env section. As can be seen, the first step of the deploy job prints the github context to the workflow log. Since part of the variables in the github context is user-controlled, it is possible to abuse unsecure commands to set arbitrary environment variables. For instance, an attacker could use the pull request description to deliver the following payload, which will reset the ENVIRONMENT_NAME when the github context is printed:
\n::set-env name=ENVIRONMENT_NAME::", <YOUR_JS_CODE>//\n
GitHub Runner will process this line as a workflow command and save a malicious payload to ENVIRONMENT_NAME. As a result, injecting the ENVIRONMENT_NAME variable within the actions/github-script step will lead to code injection.
References:

Artifact poisoning

There are many different cases where workflows use artifacts created by other workflows, for example, to get test results, a compiled binary, metrics, changes made, a pull request number, etc. This opens up another source for payload delivery. As a result, if an attacker can control the content of the downloaded artifacts and that content is not properly validated, it may lead to the execution of arbitrary commands. For example, consider the following workflows:
# .github/workflows/pr.yml
name: Pull request
on:
pull_request:
jobs:
build:
steps:
- name: Checkout
uses: actions/checkout@v3
# 1. Build a binary
- name: Build
run: make
# 2. Upload the binary to the artifacts
- name: Upload the compiled binary
uses: actions/upload-artifact@v3
with:
name: my_bin
path: ./my_bin
# .github/workflows/run.yml
name: Run
on:
workflow_dispatch:
jobs:
run:
steps:
# 1. Download a binary from the artefacts of the pr.yml workflow
- name: Download binary
uses: dawidd6/action-download-artifact@v2
with:
workflow: pr.yml
name: my_bin
# 2. Run the binary
- name: Run
run: ./my_bin
As can be seen, the first workflow uses the pull_request event to build a binary from an incoming pull request and upload the compiled binary to the artifacts. Since the workflow uses pull_request it can be triggered by a third-party user from a fork. Therefore, it is possible to upload a malicious binary to the artifacts. The second workflow uses the workflow_dispatch, downloads the binary from the pr.yml workflow and runs the binary. Even though the run.yml workflow is triggered manually, an attacker could execute arbitrary commands if they poisoned the artifacts just before triggering run.yml. In this case, the attack flow can look like this:
  1. 1.
    An attacker forks the repository and makes malicious changes.
  2. 2.
    An opens a pull request to the base repository.
  3. 3.
    The pr.yml workflow checks out the code from the pull request, builds it and uploads the malicious binary to the artifacts.
  4. 4.
    A maintainer triggers the run.yml workflow.
  5. 5.
    The workflow downloads the malicious binary and runs it.
Remember that the pull_request event may require approval from maintainers for pull requests from forks. By default, pull_request requires approval only for first-time contributors.
References:

How to detect downloading artifacts?

There are several ways to download artifacts from another workflow. The most common ones can be found below:
  1. 1.
    Using the github.rest.actions.listWorkflowRunArtifacts() method within the actions/github-script action:
    - uses: actions/github-script@v6
    with:
    script: |
    let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
    owner: context.repo.owner,
    repo: context.repo.repo,
    run_id: context.payload.workflow_run.id,
    });
    let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
    return artifact.name == "<ARTEFACT_NAME>"
    })[0];
    let download = await github.rest.actions.downloadArtifact({
    owner: context.repo.owner,
    repo: context.repo.repo,
    artifact_id: matchArtifact.id,
    archive_format: 'zip',
    });
  2. 2.
    Using third-party actions, such as dawidd6/action-download-artifact:
    - uses: dawidd6/action-download-artifact@v2
    with:
    name: artifact_name
    workflow: wf.yml
  3. 3.
    Using the gh run download GitHub CLI command:
    - run: |
    gh run download "${WORKFLOW_RUN_ID}" --repo "${GITHUB_REPOSITORY}" --name "artifact_name"
  4. 4.
    Using the gh api GitHub CLI command with github.event.workflow_run.artifacts_url:
    run: |
    artifacts_url=${{ github.event.workflow_run.artifacts_url }}
    gh api "$artifacts_url" -q '.artifacts[] | [.name, .archive_download_url] | @tsv' | while read artifact
    do
    IFS=$'\t' read name url <<< "$artifact"
    gh api $url > "$name.zip"
    unzip -d "$name" "$name.zip"
    done

Cache poisoning

Any cache in Github Actions shares the same scope as the base branch. Therefore, if the cached content can be altered within the scope of the base branch, it is possible to poison the cache for all branches of the repository.
As a result, whatever is cached from an incoming pull request will be available in all workflows. You can reproduce this behaviour using the steps below.
Poisoning:
  1. 1.
    Create a workflow in a base repository with the next content:
    name: PR Workflow
    on: pull_request_target
    jobs:
    poison:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    with:
    ref: ${{ github.event.pull_request.head.ref }}
    - uses: actions/cache@v2
    with:
    path: ~/poison
    key: poison_key
    - run: |
    cat poison
  2. 2.
    Fork the repository and add poison file with arbitrary content.
  3. 3.
    Create a pull request to the base repository and wait for the workflow to complete.
Exploitation:
  1. 1.
    Create a new branch in the base repository and change any file.
  2. 2.
    Create a pull request to the base branch.
  3. 3.
    The workflow will retrieve the poison file from the step 2 of the Poisoning section above.

Cache and deployment environments

GitHub Actions does not separate different deployment environments during caching. Suppose there are environments development and production, where production can only be run with approval. In this case, only certain people can run a workflow in the production environment, while everyone else uses the development environment. However, running these environments on the same branch can lead to cache poisoning in the production environment, because there is a logical boundary only between branches. In other words, an attacker (under certain circumstances) or any developer with write permissions can poison the cache of the base branch and get arbitrary code execution in the production environments. Therefore, an attacker or a developer can at least gain access to secrets from the production environment.

GitHub Runner registration token disclosure

GitHub Actions supports self-hosted runners that users can deploy to run jobs. The deployment process includes registering a self-hosted runner on the GitHub Service. The self-hosted runner registration process is the exchange of a GitHub Runner registration token for a temporary JWT token and the subsequent generation of an RSA key that will be used by a self-hosted runner in the future to issue JWT tokens. Therefore, the GitHub Service allows self-hosted runners to be registered based on the GitHub Runner registration token and subsequently identifies the self-hosted runner by its public key.
The GitHub Runner registration token is a short-term token that has a 1 hour expiration time and it looks like this:
AUUYBYBGG5FM52VMJQPIF5DCNFBZA
In the case of a GitHub Runner registration token disclosure, an attacker can register a malicious runner, take over jobs, and gain full access to secrets and code.
You can use the next request to check a registration token:
POST /actions/runner-registration HTTP/1.1
Host: api.github.com
Authorization: RemoteAuth <GITHUB_RUNNER_REGISTRATION_TOKEN>
User-Agent: GitHubActionsRunner
Content-Type: application/json; charset=utf-8
{
"url": "https://github.com/<NAMESPACE>/<PROJECT>",
"runner_event": "register"
}
For further exploitation follow the "Adding self-hosted runners" guide to add a malicious runner.

Disclosure of sensitive data

GitHub Actions write all details about a run to workflow logs, which include all running commands and their outputs. Logs of the public projects are available for everyone, and in sensitive data gets into the logs, everyone can access this data. The same applies to byproducts of workflow execution.
References:

Printing sensitive output to workflow logs

The following example prints sensitive data received from the /user endpoint:
- run: |
curl --fail -H 'Authorization: Bearer ${{ secrets.GH_TOKEN }}' https://api.github.com/user || exit 1

Running commands in the verbose or debug mode

The following example leaks the Auth-Token header due to the curl verbose -v key:
- run: |
curl -v -H "Auth-Token: $AUTH_TOKEN" https://api.website.com/

Misuse of sensitive data in workflows

New sensitive data may appear during the execution of workflows, for example, received from a third-party service or vault. GitHub Actions provides the add-mask workflow command to mask such data in the workflow logs. If the add-mask is not used or workflow commands have been stopped sensitive data can leak into the workflow logs.
The following example does not mark TOKEN as sensitive using add-mask and curl will expose the TOKEN to the logs:
- run: |
TOKEN=$(./issue_new_token)
curl -i -H "PRIVATE-TOKEN: $TOKEN" https://api.website.com
Remember that if you can control the variables that are printed in the workflow logs and there is a step that uses add-mask to mask new sensitive data, you can disable add-mask by injecting the stop-commands command into the workflow log.
In the snippet below, an attacker could use the pull request description to deliver a payload and stop workflow command processing, which will cause the token to be exposed despite the add-mask being used.
- run: |
print("""${{ toJSON(github) }}""")
shell: python
- run: |
TOKEN=$(./issue_new_token)
echo "::add-mask::${TOKEN}"
curl -i -H "PRIVATE-TOKEN: $TOKEN" https://api.website.com
The payload may look like this:
\n::stop-commands::randomtoken\n

Misuse of secrets in reusable workflows

When a job is used to call a reusable workflow, jobs.<job_id>.secrets can be used to provide a map of secrets that are passed to the called workflow. Under certain circumstances, the misuse of secrets can lead to the disclosure of sensitive data.
Consider the following workflows:
# dispatch.yml
name: Dispatch
on:
workflow_dispatch:
jobs:
reusable:
uses: ./.github/workflows/reusable.yml
secrets:
creds: |
AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
# reusable.yml
name: Reusable
on:
workflow_call:
secrets:
creds:
description: Arguments
jobs:
reusable:
runs-on: ubuntu-latest
steps:
- name: Parse creds
run: |
import os
creds = os.environ['CREDS']
creds = creds.split('\n')
print(f'ID: {creds[0].split("=")[-1]}')
print(f'KEY: {creds[1].split("=")[-1]}')
env:
CREDS: ${{ secrets.creds }}
shell: python
The dispatch.yml workflow invokes the reusable.yml reusable workflow and passes AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY using the creds secrets. The reusable.yml workflow parses the creds secrets and extracts the AWS_SECRET_ACCESS_KEY and AWS_SECRET_ACCESS_KEY. Even though ${{ secrets.creds }} is masked in the logs, and AWS_SECRET_ACCESS_KEY and AWS_SECRET_ACCESS_KEY are stored in encrypted secrets, reusable.yml will reveal the ID and key in plain text.
It happens because by default reusable workflows do not have access to the encrypted secrets and secrets must be defined via the jobs.<job_id>.secrets. As a result, if some sensitive data is extracted from the passed secrets, as in the example above, it can lead to their leakage into the workflow log.
Below you can find one of the in-wild examples where the docker/build-push-action parses the npm credentials from the build_args and leaks them into the logs:
name: build
on:
push:
branches:
- main
jobs:
build:
uses: ./.github/workflows/build-docker.yaml
secrets:
build_args: |
NPM_EMAIL=${{ secrets.NPM_EMAIL }}
NPM_AUTH=${{ secrets.NPM_AUTH }}
# build-docker.yaml
name: build-docker
on:
workflow_call:
secrets:
build_args:
required: false
jobs:
main:
runs-on: ubuntu-latest
steps:
# ...
- uses: docker/build-push-action@v3
with:
build-args: ${{ secrets.build_args }}
# ...

Misuse of secrets in manual workflows

If the workflow_dispacth workflow receives secrets in the inputs context and does not mask them, with a high degree of probability the secrets will leak into the workflow log.
For example, the following workflow gets a token from the inputs context and reveals that token in the workflow log, passing the token to environment variables:
name: Release
on:
workflow_dispatch:
inputs:
token:
description: 'API Token'
required: true
jobs:
release:
runs-on: ubuntu-latest
steps:
- run: ./release.sh
env:
API_TOKEN: ${{ inputs.token }}

Output parameters and debug mode

All output parameters not marked as sensitive will be logged if the debug mode is enabled.
The following example will leak the tokens into the workflow logs if the debug mode is enabled:
- run: |
# set output using deprecated set-output command
echo "::set-output name=token::$(./get_token)"
# set output using $GITHUB_OUTPUT file
echo "token=$(./get_token)" >> $GITHUB_OUTPUT
The workflow log discloses the token in plaintext:
::set-output name=token::cG1jTXaVaAT2K1Qur6G2dryZ6WfuCZcuGck3VU
Warning: The `set-output` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
##[debug]='cG1jTXaVaAT2K1Qur6G2dryZ6WfuCZcuGck3VU'
##[debug]Set output token = uFlZdXxx5d2jXbTitxpq1FY8WsZTWtadcuFlZd

Workflow artifacts

Artifacts in the public repositories are available to everyone for a retention period (90 days by default). Therefore, if sensitive data is leaked into artifacts, an attacker can download them and the sensitive data inside.

Workflow cache

Unlike artifacts, the cache is only available while a workflow is running. However, anyone with read access can create a pull request on a repository and access the contents of the cache. Forks can also create pull requests on the base branch and access caches on the base branch.

Contexts misusing

GitHub Actions workflows can be triggered by a variety of events. Every workflow trigger is provided with contexts. One of the contexts is the GitHub context that contains information about the workflow run and the event that triggered the run.
Context information can be accessed using one of two syntaxes:
  • Index syntax github['sha']
  • Property dereference syntax github.sha
The list below contains user-controlled GitHub context variables for various events.
# pull_request_target
github.head_ref
github.event.pull_request.body
github.event.pull_request.head.label
github.event.pull_request.head.ref
github.event.pull_request.head.repo.default_branch
github.event.pull_request.head.repo.description
github.event.pull_request.head.repo.homepage
github.event.pull_request.title
# issues
github.event.issue.body
github.event.issue.title
# issue_comment
github.event.comment.body
github.event.issue.body
github.event.issue.title
# discussion
github.event.discussion.body
github.event.discussion.title
# discussion_comment
github.event.comment.body
github.event.discussion.body
github.event.discussion.title
# workflow_run
github.event.workflow.path
github.event.workflow_run.head_branch
github.event.workflow_run.head_commit.author.email
github.event.workflow_run.head_commit.author.name
github.event.workflow_run.head_commit.message
github.event.workflow_run.head_repository.description
GitHub Actions support their own expression syntax that allows access to the context values.
- name: Check title
run: |
title="${{ github.event.issue.title }}"
if [[ ! $title =~ ^.*:\ .*$ ]]; then
echo "Bad issue title"
exit 1
fi
The run: block in the example above creates a bash script based on the content inside the block to execute the script during the Check title step execution. The content of the run: block is interpreted as a template and expressions inside ${{ }} are evaluated and replaced with the resulting values before the bash script is run. Therefore, if an attacker can control a context variable that is used inside ${{ }}, they will be able to inject arbitrary commands into the bash script. In the case above, an attacker can use the payloads like a"; echo test or `echo test` to execute malicious commands.
Another context that can contain user-controlled data is the env context. The env context contains environment variables that have been set in a workflow, job, or step. Using variable interpolation ${{ }} with the env context data in a run: could allow an attacker to inject malicious commands. In the snippet below, an attacker can create an issue with a payload in the issue title.
on:
issues:
env:
TITLE: "${{ github.event.issue.title }}"
jobs:
check-title:
runs-on: ubuntu-latest
steps:
- name: Check title
run: |
title="${{ env.TITLE }}"
if [[ ! $title =~ ^.*:\ .*$ ]]; then
echo "Bad issue title"
exit 1
fi
Additionally, it is possible to control values of the outputs property that is provided by steps and needs contexts:
The outputs property contains the result/output of a specific job or step. outputs may accept user-controlled data which can be passed to the run: block. In the following example, an attacker can execute arbitrary commands by creating a pull request named `;arbitrary_command_here();// because steps.fetch-branch-names.outputs.prs-string contains a pull request title:
jobs:
combine-prs:
runs-on: ubuntu-latest
steps:
# action uses PR's title to craft prs-string output
- uses: actions/github-script@v6
id: fetch-branch-names
name: Fetch branch names
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', {
owner: context.repo.owner,
repo: context.repo.repo
});
branches = [];
prs = [];
base_branch = null;
for (const pull of pulls) {
// ...
if (statusOK) {
console.log('Adding branch to array: ' + branch);
branches.push(branch);
prs.push('Closes #' + pull['number'] + ' ' + pull['title']);
base_branch = pull['base']['ref'];
}
}
}
// ...
core.setOutput('prs-string', prs.join('\n'));
// ...
# action uses crafted prs-string within the ${{ }} expression
- uses: actions/github-script@v6
name: Create Combined Pull Request
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prString = `${{ steps.fetch-branch-names.outputs.prs-string }}`;
// ...
References:

Potentially dangerous third-party actions

Remember that injecting user-controlled data into variables of third-party actions can lead to vulnerabilities such as commands or code injection. Below you can find a list of the most common actions which, if misused, can lead to vulnerabilities.
Github action
Description
Potential vulnerability
Write workflows scripting the GitHub API in JavaScript
Using variable interpolation ${{ }} with script: can lead to JavaScript code injection
A GitHub Action to send queries to GitHub's GraphQL API
Using variable interpolation ${{ }} with query: can lead to injection to a GraphQL request
A GitHub Action to send arbitrary requests to GitHub's REST API
Using variable interpolation ${{ }} with route: can lead to injection to a request to REST API

Misconfiguration of OpenID Connect

With OpenID Connect, a GitHub Actions workflow requires a token to access resources in a cloud provider. The workflow requests an access token from a cloud provider, which checks the details presented by the JWT. If the trust configuration in the JWT is a match, a cloud provider responds by issuing a temporary token to the workflow, which can then be used to access resources in a cloud provider.
When developers configure a cloud to trust GitHub's OIDC provider, they must add conditions that filter incoming requests, so that untrusted repositories or workflows can't request access tokens for cloud resources. Audience and Subject claims are typically used in combination while setting conditions on the cloud role/resources to scope its access to the GitHub workflows.
  • Audience: By default, this value uses the URL of the organization or repository owner. This can be used to set a condition that only the workflows in the specific organization can access the cloud role.
  • Subject: Has a predefined format and is a concatenation of some of the key metadata about the workflow, such as the GitHub organization, repository, branch or associated job environment. There are also many additional claims supported in the OIDC token that can also be used for setting these conditions.
However, if a cloud provider is misconfigured, untrusted repositories can request access tokens for cloud resources.
References:
GitHub workflows can be triggered by events related to incoming pull requests. The table below contains all the events that can be used to handle incoming pull requests:
Event
REF
Possible GITHUB_TOKEN permissions
Access to secrets
pull_request (external forks)
PR merge branch
read
no
pull_request (branches in the same repo)
PR merge branch
write
yes
PR base branch
write
yes
Default branch
write
yes
Default branch
write
yes
  • pull_request and pull_request_target triggered a workflow when activity on a pull request in the workflow's repository occurs. The main differences between the two events are:
    1. 1.
      Workflows triggered via pull_request_target have write permissions to the target repository and have access to target repository secrets. The same is true for workflows triggered on pull_request from a branch in the same repository, but not from external forks. The reasoning behind the latter is that it is safe to share the repository secrets if the user has write permissions to the target repository already.
    2. 2.
      pull_request_target runs in the context of the target repository of the pull request, rather than in the merge commit. This means the standard checkout action uses the target repository to prevent accidental usage of a user-supplied code.
  • issue_comment runs a workflow when a pull request comment is created, edited, or deleted.
  • workflow_run runs a workflow when a workflow run is requested or completed. It allows the execution of a workflow based on the execution or completion of another workflow.
Normally, using pull_request_target, issue_comment or workflow_run is safe because actions only run code from a target repository, not an incoming pull request. However, if a workflow uses these events with an explicit checkout of a pull request, it can lead to untrusted commands or code execution.
on:
pull_request_target
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
# 1. Check out the content from an incoming pull request
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
# 2. Potentially untrusted commands are being run during "npm install" or "npm build" as
# the build scripts and referenced packages are controlled by the author of the pull request
- run: |
npm install
npm build
There are several ways to check out a code from a pull request:
  • Using the actions/checkout action to checkout changes from a head repository.
    - uses: actions/checkout@v3
    with:
    ref: refs/pull/${{ github.event.pull_request.number }}/merge
  • Explicitly checking out using git in the run: block.
    run: |
    git fetch origin $HEAD_BRANCH
    git checkout origin/master
    git config user.name "release-hash-check"
    git config user.email "<>"
    git merge --no-commit --no-edit origin/$HEAD_BRANCH
    env:
    HEAD_BRANCH: ${{ github.head_ref }}
  • Use GitHub API or third-party actions:
    - uses: octokit/request-[email protected]
    with:
    route: GET /repos/{owner}/{repo}/pulls/{number}
    owner: namespace
    repo: reponame
    number: ${{ github.event.issue.number }}
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The following context variables may help to find cases where an incoming pull request is checked out:
github.head_ref
github.event.number
github.event.pull_request.head.ref
github.event.pull_request.head.repo.default_branch
github.event.pull_request.head.repo.id
github.event.pull_request.head.sha
github.event.pull_request.merge_commit_sha
github.event.pull_request.number
github.event.pull_request.id
github.event.workflow_run.head_branch
github.event.workflow_run.head_repository.id
github.event.workflow_run.head_sha
# id is a commit sha
github.event.workflow_run.head_commit.id
# environment variable
env.GITHUB_HEAD_REF
References:

Confusion between head.ref and head.sha

Sometimes developers implement workflows in such a way that they require manual review before execution. It is guaranteed that untrusted content will be reviewed by a maintainer before execution. Consider the following workflow:
name: Handle pull requests
on:
pull_request_target:
types:
- labeled
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'approved' }}
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
- uses: actions/setup-node@v1
- name: Build
run: |
npm install
npm build
As can be seen, the workflow is triggered when the approved label is set on a pull request. In other words, only a maintainer with write permissions can manually trigger this workflow. However, the workflow is still vulnerable, because it uses github.event.pull_request.head.ref to check out the repository content. Consider the difference between head.ref and head.sha. head.ref points to a branch while head.sha points to a commit. This means that if head.sha is used to check out a repository, the content will be fetched from the commit that was used to trigger the workflow. In the case of labeling a pull request, it will be the HEAD commit that was reviewed by a maintainer before the label was set. However, if head.ref is used, a repository is checked out from the base branch. As a result, an attacker can inject a malicious payload right after the manual approval (TOCTOU attack). The attack flow may look like this:
  1. 1.
    An attacker forks a target repository.
  2. 2.
    An attacker makes valid changes and opens a pull request.
  3. 3.
    An attacker waits for the label to be set.
  4. 4.
    A maintainer sets the approved label that triggers the vulnerable workflow.
  5. 5.
    An attacker pushes a malicious payload to the fork.
  6. 6.
    The workflow checks out the malicious payload and executes them.

Misuse of the pull_request_target event in non-default branches

The pull_request_target event can be used to trigger workflows in non-default branches if there is no restriction based on branches and branches-ignore filters.
on:
pull_request_target:
branches:
main
release*
v1.*.*
# ...
GitHub uses the workflow from a non-default branch when creating a pull request to that branch. Therefore, if there is a vulnerable workflow in a non-default branch that can be triggered by the pull_request_target event, an attacker can open a pull request to that branch and exploit a vulnerability.
This is a common pitfall when fixing vulnerabilities, developers can only fix a vulnerability in the default branch and leave the vulnerable version in non-default branches.

Misuse of the workflow_run event

workflow_run was introduced to enable scenarios that require building the untrusted code and also need write permissions to update a pull request with e.g. code coverage results or other test results. To do this in a secure manner, the untrusted code is handled via the pull_request trigger so that it is isolated in an unprivileged environment. The workflow processing a pull request stores any results like code coverage or failed/passed tests in artifacts and exits. The following workflow then starts on workflow_run where it is granted write permission to the target repository and access to repository secrets so that it can download the artifacts and make any necessary modifications to the repository or interact with third-party services that require repository secrets (e.g. API tokens).
Nevertheless, there are still ways of transferring data controlled by a user from untrusted pull requests to a privileged workflow_run workflow context:
  1. 1.
    The github.event.workflow_run context. Please, check out Contexts misusing and Misuse of the events related to incoming pull requests for more details.
  2. 2.
    Artifacts. Please, check out Artifact poisoning for more details.
Even if a workflow_run workflow does not properly use context variables or artifacts, it may still be unexploitable because the pull_request event requires approval from maintainers for pull requests from forks. By default, pull_request workflows require approval only for first-time contributors. So, if a repository does not require approval for all outside collaborators (default behaviour), you can make changes to the target repository to become a contributor. After that, you will be able to fully control a pull_request workflow for exploitation.
References:

Misuse of self-hosted runners

Self-hosted runners can be launched as an ephemeral using the --ephemeral option. ephemeral option configures a self-hosted runner to only take one job and then let the service un-configure the runner after the job finishes. This is implemented by sending the ephemeral value within a request during runner registration:
POST /l8zOmabebUmdmqSaVGVjnhvkK9o6XCANRwPqdM3hfsO92dqZdj/_apis/distributedtask/pools/1/agents HTTP/2
Host: pipelines.actions.githubusercontent.com
User-Agent: VSServices/2.290.1.0
Authorization: Bearer <TOKEN>
Content-Type: application/json; charset=utf-8; api-version=6.0-preview.2
{
"labels": [
...
],
"maxParallelism": 1,
"createdOn": "0001-01-01T00:00:00",
"authorization": {
...
},
"id": 0,
"name": "Dummy-Runner",
"version": "2.290.1",
"osDescription": "Darwin 21.4.0",
"ephemeral": true,
"disableUpdate": false,
"status": 0,
"provisioningState": "Provisioned"
}
As a result, an ephemeral runner has the following lifecycle:
  1. 1.
    The runner is registered with the GitHub Actions service.
  2. 2.
    The runner takes one job and performs it.
  3. 3.
    When the runner completes the job, it cleans up the local environment (.runner, .credentials, .credentials_rsaparams files).
  4. 4.
    The GitHub Actions service automatically de-registers the runner.
Therefore, if an attacker gains access to an ephemeral self-hosted runner, they will not be able to affect other jobs.
However, self-hosted runners are not launched as ephemeral by default and there is no guarantee around running in an ephemeral clean virtual environment. So, runners can be persistently compromised by untrusted code in a workflow. An attacker can abuse a workflow to access the following files that are stored in a root folder of a runner host:
  • .runner that contains general info about a runner, such as an id, name, pool id, etc.
  • .credentials that contains authentication details such as the scheme used and authorization URL.
  • .credentials_rsaparams that contains the RSA parameters that were generated during the registration and are used to sign JWT tokens.
These files can be used to takeover a self-hosted runner with the next steps:
  1. 1.
    Fetch .runner, .credentials, .credentials_rsaparams files with all the necessary data written during the runner registration.
  2. 2.
    Get an access token using the following request:
    POST /_apis/oauth2/token/<UUID> HTTP/2
    Host: vstoken.actions.githubusercontent.com
    User-Agent: GitHubActionsRunner
    Content-Type: application/x-www-form-urlencoded
    grant_type=client_credentials&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=<BEARER_TOKEN>
    Where:
    • UUID can be found in the .credentials file.
    • BEARER_TOKEN is generated using the parameters from .credentials and .credentials_rsaparams files.
  3. 3.
    Remove a current session:
    DELETE /<RANDOM_PREFIX>/_apis/distributedtask/pools/1/sessions/<SESSION_ID> HTTP/2
    Host: pipelines.actions.githubusercontent.com
    User-Agent: VSServices/2.290.1.0
    Authorization: Bearer <BEARER_TOKEN>
    Where:
    • RANDOM_PREFIX can be found in the .runner file.
    • SESSION_ID is a session ID that can be found in the _diag/Runner_<DATE>-utc.log file with the runner logs.
    • BEARER_TOKEN is a bearer token from the response in the previous step.
  4. 4.
    Copy the .runner, .credentials, .credentials_rsaparams files to a root folder of a malicious runner.
  5. 5.
    Run a malicious runner.
The takeover has the greatest impact when a self-hosted runner is defined at the organization or enterprise level because GitHub can schedule workflows from multiple repositories onto the same runner. It allows an attacker to gain access to the jobs which will use the malicious runner.
References:

Using the pull_request event with self-hosted runners

GitHub does not provide a mechanism to prevent the pull_request event from being triggered from forks. The only thing available is to require approval for all outside collaborators. However, if approval is only required for the first contribution, you can make some valid changes and then add a malicious workflow that uses pull_request for running on a self-hosted runner.
References:

Detection of the use of self-hosted runners

You can find using of self-hosted runners by the following runs-on