GitHub Actions exploitation: introduction

Rédigé par Hugo Vincent - 27/06/2024 - dans Pentest - Téléchargement

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.

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:

Hello world workflow.

In the previous example the configured trigger is push, meaning on every push, the workflow will be triggered:

Triggered workflow.Output of the workflow.

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:

GitHub contexts.

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:

Script injection.

Due to the way that the ${{}} gets expanded, this causes the workflow to execute the following command:

echo "New issue: $(id)"
Script injection.

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}[...]"
First job output.Second job output.

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:

Default permissions after 2023.

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
Permissions.

Interesting permissions are:

  • contents: Work with the contents of the repository. For example, contents: read permits an action to list the commits, and contents: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:

Workflow waiting approval.

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.

Fix typo issues.

 

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 }}"
Empty 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 }}"
Secret provided to the runner.

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:

Workflow run.

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:

Permissions of the trigger workflow.Permissions of the triggered workflow.

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
Artifacts.

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.