GitHub Actions exploitation: introduction
CI/CD (Continuous Integration / Continuous Delivery) systems are becoming increasingly popular today. This can be explained by the difficulty to maintain and deploy multiple projects simultaneously. These systems help teams and developers by enforcing automation when building, testing and deploying applications. For example, an application deployment can be triggered after a developer pushes a new version of the code to a repository.
GitHub Actions is the CI/CD environment of GitHub, allowing users to execute a specific set of tasks based on an event that happened on a repository. These tasks sometimes run in privileged contexts and may manipulate untrusted data coming from external sources that can be controlled by an attacker. This could lead to arbitrary code execution in privileged contexts allowing the attacker to steal sensitive secrets or push arbitrary code on the targeted repository. Such scenarios can be exploited without being an internal contributor of the targeted project.
In this series of articles we will explain the mechanics of GitHub Actions by first describing all the components that will then be useful for exploitation. In the following articles we will detail several critical misconfigurations that we found during our research on numerous popular open-source projects. Through our investigation, we aim to highlight common pitfalls and vulnerabilities to prevent developers from introducing vulnerabilities in their CI/CD environments.
Hello world
A workflow is a configurable automated process that will run one or more jobs. Workflows are defined with YAML files and will run when triggered by an event in a repository, manually, or at a defined schedule.
Workflows are defined in the .github/workflows
directory of a repository, and one can have multiple workflows, each of which can perform a different set of tasks. For example, it is possible to have a workflow to build and test pull requests, another to deploy an application every time a release is created, and yet another workflow to add a label every time someone opens a new issue.
A workflow must contain the following basic components:
-
One or more events that will trigger the workflow.
-
One or more jobs, each of which will execute on a runner machine and run a series of one or more steps.
-
Each step can either run a defined script or run an action, which is a reusable extension that can simplify a workflow.
Here is a Hello world workflow:
name: Hello world
on:
push:
jobs:
hello:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Hello world"
This workflow can be pushed on a GitHub repository:
In the previous example the configured trigger is push
, meaning on every push, the workflow will be triggered:
An event is a specific activity in a repository that triggers a workflow run. For example, activity can originate from GitHub when someone creates a pull request, opens an issue, or pushes a commit to a repository. It's also possible to trigger a workflow to run on a schedule or manually. Some event can be dangerous and can result in vulnerabilities, more on this in the next articles.
A job represents a series of tasks within a workflow, all executed on the same runner. These tasks can either be shell scripts or actions. The execution of steps is sequential, with each step relying on the completion of the previous one. As all steps share the same runner, data can be transparently passed from one to the next. For instance, you might first build your application in one step and then test the built application in the subsequent step.
Job dependencies can be configured, allowing coordination with other jobs. By default, jobs operate independently and run concurrently. When a job is dependent on another, it waits for the completion of the dependent job before initiating. Consider a scenario with multiple build jobs for diverse architectures without dependencies, and a packaging job dependent on those builds. The build jobs execute simultaneously, and upon successful completion, the packaging job follows suit.
It is possible to use this flexibility to create custom actions tailored to specific needs or leverage existing actions available in the GitHub Marketplace, thus enhancing the efficiency and simplicity of workflows.
A runner acts as the server responsible for executing workflows upon triggering. Each runner is capable of running one job at a time. GitHub extends support for various operating systems, including Ubuntu Linux, Microsoft Windows, and macOS runners, enabling workflows to run on newly provisioned virtual machines for each execution.
GitHub also offers the possibility to use self-hosted runners. This could be interesting in some cases as if attackers manage to compromise a self-hosted runner they might be able to access internal networks.
GitHub context
Contexts serve as a mean to retrieve information regarding various aspects of workflow runs, including variables, runner environments, jobs, and steps. Each context is an object that contains properties, which can be strings or other objects. This mechanism provides a structured way to access and utilize diverse information within the context of a workflow run.
Contexts can be accessed using the following expression syntax:
${{ <context> }}
For example:
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: echo ${{ github.repository }}
This will return:
Some contexts are interesting from an attacker perspective:
-
env
: contains environment variables set in a workflow, job, or step. -
secrets
: contains the names and values of secrets that are available to a workflow run. -
github
: information about the workflow run. -
steps
: information about the steps that have been run in the current job. -
needs
: contains the outputs of all jobs that are defined as a dependency of the current job.
GitHub Actions expression evaluation is a powerful language-independent feature which may lead to script injections when used in blocks such as run
.
The following workflow is affected by a script injection vulnerability:
name: Issue
on:
issues:
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: |
echo "New issue: ${{ github.event.issue.title }}"
If a malicious user opens an issue with $(id)
as the title:
Due to the way that the ${{}}
gets expanded, this causes the workflow to execute the following command:
echo "New issue: $(id)"
Given that expression evaluation in GitHub Actions is language-independent, the risk of injection is not confined to Bash scripts. The ${{}}
syntax, extends to other languages, such as JavaScript, where a syntactically valid construct could also be exploited for injection purposes.
This vulnerability opens the door for potential malicious activities. Attackers leveraging this injection could therefore execute actions with more severe consequences, such as uploading sensitive secrets to a website under their control, introducing new code to the repository, introducing backdoor vulnerabilities or initiating a supply chain attack. More details regarding this vulnerability in the following articles.
GitHub secrets
GitHub Actions allow developers to store secrets at three different places:
-
At the organization level, either globally or for selected repositories (only available for GitHub organizations).
-
Per repository.
-
Per repository for a specific environment.
These secrets can then be read only from the context of a workflow run. For example:
22: steps:
23: - name: Check out repository
24: uses: actions/checkout@v3
25: with:
26: ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
For more information on GitHub secrets please refer to our previous article1.
Workflow permissions
At the beginning of each workflow job, GitHub automatically creates a unique GITHUB_TOKEN
secret for jobs to authenticate to GitHub.
Upon enabling GitHub Actions, a GitHub App is automatically installed in the repository. The GITHUB_TOKEN
secret corresponds to an access token specific to this GitHub App install. Using this installation access token allows authentication on behalf of the GitHub App residing in the repository. The token's permissions are restricted to the repository containing the workflow.
Before each job begins, GitHub fetches an installation access token for the job. The GITHUB_TOKEN
expires when a job finishes or after a maximum of 24 hours.
In this example the tokens will be different in each job but not in each step:
name: permissions
on:
push:
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
job1:
runs-on: ubuntu-latest
steps:
- name: step1
run: |
echo "${TOKEN:0:9}[...]"
- name: step2
run: |
echo "${TOKEN:0:9}[...]"
job2:
runs-on: ubuntu-latest
steps:
- run: |
echo "${TOKEN:0:9}[...]"
The token is available in the github.token
and secrets.GITHUB_TOKEN
contexts.
The default configuration grants the GITHUB_TOKEN
read-only permission to the repository. This was not always the default setting, as it was modified at the close of 2022. Prior to this change, the token possessed both read and write permissions for the repository. However, GitHub did not enforce this alteration, resulting in all GitHub organizations created before 2023 providing default write access to the GITHUB_TOKEN
. This behavior proves advantageous during exploitation, and configuration options exist at both the organization and repository levels to tailor these settings:
Depending on the trigger, the GITHUB_TOKEN
will have different permissions. This will be detailed in the next chapter.
Permissions can also be restricted directly in the workflow file to adjust the default access granted to the GITHUB_TOKEN
, either by adding or removing access as needed. This ensures that only the essential access required is granted. Permissions are defined at either the top level, applying them to all jobs in the workflow, or within specific jobs. When a specific permissions key is set to a particular job, all actions and run commands in that job using the GITHUB_TOKEN
will inherit the specified access rights.
The following permissions can be defined:
permissions:
actions: read|write|none
checks: read|write|none
contents: read|write|none
deployments: read|write|none
id-token: read|write|none
issues: read|write|none
discussions: read|write|none
packages: read|write|none
pages: read|write|none
pull-requests: read|write|none
repository-projects: read|write|none
security-events: read|write|none
statuses: read|write|none
The following syntax can be used to define one of read-all
or write-all
access for all the available scopes:
permissions: read-all
permissions: write-all
The {}
syntax will disable permissions for all the available scopes:
permissions: {}
Also, defining a specific permission will set all the other permissions to none
:
permissions:
pull-requests: read
Interesting permissions are:
-
contents
: Work with the contents of the repository. For example,contents: read
permits an action to list the commits, andcontents:write
allows the action to create a release. -
id-token
: Fetch an OpenID Connect (OIDC) token. -
pull-requests
: Work with pull requests. For example,pull-requests: write
permits an action to add a label to a pull request. -
issues
: Work with issues. For example,issues: write
permits an action to add a comment to an issue.
First time contributors
Forking a public repository allows anyone to propose changes to the GitHub Actions workflows by submitting a pull request. While workflows from forks are restricted from accessing sensitive data like secrets, they can pose a challenge for maintainers if they are altered for malicious purposes.
In order to mitigate this potential issue, workflows triggered by pull requests from certain external contributors to public repositories may not run automatically and could require approval. By default, initial approval is necessary for all first-time contributors before their workflows can be executed. This precautionary measure is implemented to enhance security and prevent potential misuse of workflows.
This will result in the workflow being paused until someone approves it:
This restriction can be easily bypassed. An attacker could first make an innocent pull request fixing a legitimate bug or typo in the documentation. If this pull request gets accepted then the next workflows will automatically run.
Workflow triggers
As previously explained GitHub workflows can be triggered using different events. Some are particularly interesting as they can provide a privileged context to an external attacker. This section describes some triggers.
push
The push
trigger is the most commonly used trigger. It runs a workflow when a commit or tag is pushed. However, from an attacker perspective this is not useful since to trigger such events, an attacker would need write privileges on the targeted repository which will not be the initial assumption for this research. Every attack proposed in this paper will be exploited from an external attacker that does not have any privilege on the targeted repository.
pull_request
The pull_request
trigger will run a workflow when activity on a pull request in the repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated.
on:
pull_request:
types: [opened, reopened]
However since any GitHub user can create a fork of the targeted repository and create a pull request, some restrictions apply. For example the provided GITHUB_TOKEN
will have restricted permissions and will not have write access on the repository, as otherwise it would allow anyone to modify any project hosted on GitHub. Even if someone adds an explicit write permission, the token will not be granted write access:
on:
pull_request:
# this will not work
permissions:
contents: write
The same goes for secrets, GitHub will not send any repository secrets to a runner when a workflow is triggered by a pull request made by a forked repository. Instead, the secrets will be empty:
name: "PR"
on:
pull_request:
jobs:
init:
runs-on: ubuntu-latest
name: "init"
steps:
- run: |
echo "Secret value: ${{ secrets.SUPER_SECRET }}"
Yet, there are instances where the necessity arises to execute a workflow triggered by diverse pull request events, demanding not only read, but also write access to the repository, or access to its secrets. Consider a scenario where a workflow aims to apply labels to a pull request based on certain properties. Such a workflow requires a GITHUB_TOKEN
with write privileges on at least the pull-request
permission. This kind of event is covered by the pull_request_target
trigger as explained in the next section.
pull_request_target
As the pull_request
trigger, the pull_request_target
trigger will run a workflow when activity on a pull request in the repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated.
However, in this case the GITHUB_TOKEN
is granted read/write repository permission unless the permissions
key is specified and the workflow can access secrets, even when it is triggered from a fork. This makes it a very interesting trigger as it can be triggered from a fork and still has access to sensitive elements. Particular attention must be paid to this type of workflow to prevent malicious users to exploit weaknesses and gain access to sensitive information or tokens.
With a trigger configured on the previous workflow, an attacker could gain access to the defined secret:
name: "PR"
on:
pull_request_target:
jobs:
init:
runs-on: ubuntu-latest
name: "init"
steps:
- run: |
echo "Secret value: ${{ secrets.SUPER_SECRET }}"
The secret is hidden in the output log to prevent any leak, but we can observe that the token is passed to the runner.
This trigger is even more dangerous as it is not subject to the first time contributors protection. An external attacker could trigger a pull_request_target
workflow without first being a contributor.
workflow_run
The workflow_run
event takes place when a workflow run is either requested or completed. It enables the execution of a workflow based on the initiation or conclusion of another one. Notably, the workflow triggered by the workflow_run
event has the capability to access secrets and write tokens, even if the preceding one did not possess such privileges. This is interesting in situations where the initial workflow intentionally lacks privileges, but subsequent privileged actions are required in a later workflow.
It is possible to access the workflow_run
event payload associated with the workflow that triggered the target one. As an illustration, if the triggering workflow generates artifacts, a workflow initiated by the workflow_run
event can retrieve and utilize these artifacts as needed. This feature enables smooth interaction and data sharing between workflows, enhancing flexibility and extensibility in GitHub Actions.
For example this workflow is provided in the GitHub documentation as an example:
name: Upload data
on:
pull_request:
jobs:
upload:
runs-on: ubuntu-latest
steps:
- name: Save PR number
env:
PR_NUMBER: ${{ github.event.number }}
run: |
mkdir -p ./pr
echo $PR_NUMBER > ./pr/pr_number
- uses: actions/upload-artifact@v3
with:
name: pr_number
path: pr/
Upon the completion of the preceding workflow, it initiates the execution of the subsequent one:
name: Use the data
on:
workflow_run:
workflows: [Upload data]
types:
- completed
jobs:
download:
runs-on: ubuntu-latest
steps:
- name: 'Download artifact'
uses: actions/github-script@v6
with:
script: |
[...]
This is only based on the name of the triggering workflow. An attacker can easily create a pull request with a workflow matching the name of a workflow_run
to trigger this one. If a vulnerability is present in the triggered workflow, and it uses secrets, an attacker could expose them.
By default, the workflow triggered by the workflow_run
event has the same capability as the triggering one. Since an attacker can only control workflows triggered with the pull_request
event the resulting workflow will not have write privileges on the different permissions.
For example, here we have a workflow configured to run when a workflow called trigger
is launched:
If an external user creates the trigger
workflow the GITHUB_TOKEN
associated in the pr.yml
workflow will have write privileges on the repository even if the triggering one did not:
Note that this event will only trigger a run if the workflow file is on the default branch.
GitHub artifacts
Workflow artifacts serve as a mechanism to retain data beyond the completion of a job, facilitating data sharing among different jobs within workflows. An artifact, in this context, refers to a file or a group of files generated during the execution of a workflow. This functionality proves particularly useful for preserving outputs such as build and test results after the conclusion of a workflow run.
For example:
- name: upload artifact
uses: actions/upload-artifact@v3
with:
name: artifact-name
path: artifact.txt
Importantly, all actions and workflows invoked within a run possess write access to the artifacts associated with that specific run, ensuring seamless access and utilization of shared data. Also, any external user could generate and store artifacts in the targeted repository. This means that a workflow triggered by a workflow_run
event could download arbitrary data controlled by an external attacker. In some cases, this can lead to critical vulnerabilities.
Conclusion
In this first article we explored the mechanics of GitHub Actions, we expose the different elements that are present in a GitHub workflow. Some of them will play a crucial role when it comes to exploitation. The next articles will be focused on the exploitation part. We will detail several critical misconfigurations that we found during our research on several popular open-source projects.