Overview

I identified a High risk vulnerability impacting GitHub’s dependabot-core repository that could have allowed an attacker to conduct a supply chain attack on GitHub users by backdooring the Dependabot containers. The cause of the vulnerability was a race condition in a workflow that maintainers would trigger to perform integration testing on approved pull requests prior to merging.

Background

Continuous Integration / Continuous Delivery (CI/CD) pipelines for Open-Source (and even closed source!) projects have a concept of an approval gate. This is a step that an authorized actor must perform before pipeline execution can continue. This pattern is common for running integration tests that require access to secrets or creating preview environments on code originating from fork pull requests.

The attack would work like this:

  • Contributor creates a pull request
  • Maintainer reviews the code
  • Maintainer triggers privileged CI operation

In GitHub Actions, we will typically see workflows that use approval gates in conjunction with pull_request_target or issue_comment events.

There is another trigger that maintainers will use and that is the workflow_dispatch trigger. This trigger is often susceptible to a Time-of-Check-Time-of-Use vulnerability that is similar to the issue_comment trigger.

For bug bounty hunters, a valid workflow_dispatch TOCTOU will typically be a Medium or High risk vulnerability. This is because the attack complexity will always be high and it requires interaction. If you are reporting a workflow_dispatch TOCTOU, make sure there is impact - don’t bother reporting a GitHub Actions TOCTOU if the impact is limited to pull-requests: write only and there are no Cache Poisoning escalation paths.

Actions

I want to provide a bit of background on actions’ security boundaries for those who might not be familiar.

In GitHub Actions, there are two types of workflows: privileged and unprivileged fork workflows.

Workflows that run on the following triggers from forks are always unprivileged:

  • pull_request
  • pull_request_review_comment
  • pull_request_review

This means that they cannot have access to secrets and they cannot have a github token with write access.

On the other hand, workflows that run on every other trigger are privileged. They run in the context of the base repository, can have access to secrets, and can have access to a privileged github token.

The workflow_dispatch trigger is privileged. One common pattern I’ve seen is that maintainers will create a workflow that runs on workflow_dispatch and takes a pull request number. This is a common pattern for triggering infrequent, long running test jobs on pull requests that require access to credentials.

Let’s Take It Deeper

Dependabot/dependabot-core

While working on the Gato-X version 1.0 rewrite, which included an overhauled static analysis algorithm, I added a detection for workflow_dispatch TOCTOU vulnerabilities. I scanned a few large organizations and I got a hit for the dependabot/dependabot-core repository.

The issue existed within the .github/workflows/images-branch.yml workflow. It ran on the workflow_dispatch trigger and checked out code from the pull request head:

In the Build ecosystem image step, we can see that the workflow runs a bash script located at script/build. Due to the previous checkout, this code contained the latest state of the pull request at the moment of executing gh pr checkout ${{ inputs.pr }} within the Prepare tag (forks) step.

Exploit Preconditions

When analyzing a bug lead for a workflow_dispatch TOCTOU, it is important to check if a maintainer would run the workflow as part of a normal interaction with an external contributor.

In some cases you will encounter workflows that take a pull request number as a parameter but the workflow is only used to merge a specific backport pull request created by a bot account. In this case, it is not a vulnerability because the maintainer would never run the workflow on an external contributor fork pull request.

In this case, I found examples of previous pull requests where the creator was an external contributor and the maintainers ran this workflow. Bingo! This established feasibility for this vulnerability.

Looking at the diffs in https://github.com/dependabot/dependabot-core/pull/10829/files, the level of effort is not excessive to make a meaningful change. Given what an attacker could get from exploiting vulnerability it is well within the realm of what a threat actor would be willing to do.

Winning the Race Condition

The next step here would be to win a race condition. The race window here existed between the check at the end of the approval job (the repository had the protection setting of dismissing approvals if the PR was updated) and the gh pr checkout within the push-updater-images job.

1
2
DECISION=$(gh pr view ${{ env.PR }} --json reviewDecision,state -t '{{.reviewDecision}}:{{.state}}')
          echo "decision=$DECISION" >> $GITHUB_OUTPUT

To estimate the race window, I looked at the workflow timestamps from PR 10829. The job ended at 10:07:29.1026629Z

1
2
3
4
5
6
7
2024-12-20T10:07:29.0775426Z Entering 'nuget/helpers/lib/NuGet.Client'
2024-12-20T10:07:29.0799720Z http.https://github.com/.extraheader
2024-12-20T10:07:29.0840004Z Entering 'nuget/helpers/lib/NuGet.Client/submodules/NuGet.Build.Localization'
2024-12-20T10:07:29.0864876Z http.https://github.com/.extraheader
2024-12-20T10:07:29.1019441Z Evaluate and set job outputs
2024-12-20T10:07:29.1024795Z Set output 'decision'
2024-12-20T10:07:29.1026629Z Cleaning up orphan processes

We can see in the logs for https://github.com/dependabot/dependabot-core/actions/runs/12429468333/job/34703034734, that the workflow does not check out the PR until 10:07:48.5908963Z. That leaves a race window of almost 19 seconds, which is wide enough to comfortably win via automated or manual methods.

1
2024-12-20T10:07:48.5908963Z ##[group]Run gh pr checkout 10829

An attacker can write a script to poll for workflow_dispatch events on that workflow as soon as they see the maintainer approve their pull request (or start refreshing at that point with their Git push ready to fire, either works). In this example, the maintainer approved the workflow at 9:59 UTC, and then ran the workflow roughly 8 minutes later.

Achieving Impact

If the attacker can successfully won the race condition, then the code they modify within script/build will run within the workflow. They could simply modify the build script to download and replace source code files (or do a git checkout of a different SHA within the fork network - many ways to change code files after achieving execution).

Even looking at the build produced from that specific workflow (https://github.com/dependabot/dependabot-core/actions/runs/12429468333) we see that there were 35k downloads, so even the trivial poisoning approach within a build would net fairly widespread impact.

1
2
3
Pulled image ghcr.io/dependabot/dependabot-updater-github-actions:a4ba0ebb195c183b0c56c4f081c6baec485da5e2
Pulling image ghcr.io/github/dependabot-update-job-proxy/dependabot-update-job-proxy:v2.0.20241213020825@sha256:4839cb1467623a51c41a50d4d9a770fc36bebc75b45f12ea3b58aeb14413c53b...
Pulled image ghcr.io/github/dependabot-update-job-proxy/dependabot-update-job-proxy:v2.0.20241213020825@sha256:4839cb1467623a51c41a50d4d9a770fc36bebc75b45f12ea3b58aeb14413c53b

The vulnerable workflow used a GITHUB_TOKEN with packages: write and id-token: write, so an attacker can publish signed containers to GHCR. Ultimately, the impact of this vulnerability is that an attacker can backdoor Dependabot updater images on GHCR, which could lead to broad arbitrary code execution within Dependabot updater jobs in both public and private repositories.

Dependabot updater images do not have access to other private repositories or even the GITHUB_TOKEN, but they do have access to the repo clone it is running on at DEPENDABOT_REPO_CONTENTS_PATH. Since this backdoor would be specific to the Dependabot images, even if an attacker cannot escape from the container, they could add code in it to compress the cloned repository and exfiltrate it to an attacker’s servers. This would lead to a loss of private source code for GitHub customers who use Dependabot.

GitHub’s Fix

GitHub fixed this issue in the following pull request: Ensure additional commits after approval are rejected

Now, the workflows saves off the commit SHA attached to the approval decision.

1
2
3
          # For security, the `gh` call that retrieves the PR approval status *must* also retrieve the commit at the
          # tip of the PR to ensure that any subsequent unreviewed commits are not pulled into this action workflow.
          DECISION=$(gh pr view ${{ env.PR }} --json reviewDecision,state,commits --jq '"\(.reviewDecision):\(.state):\(.commits | last .oid)"')

After checking out the pull request, the workflow verifies that the SHA matches the HEAD SHA of the pull request. If the SHA changes, it means the pull request creator added new code in an attempt to exploit the race condition.

1
2
3
4
5
          gh pr checkout ${{ inputs.pr }}
          # Ensure the commit we've checked out matches our expected SHA from when we checked the PR's approval status above.
          # This is a security measure to prevent any unreviewed commits from getting pulled into this action workflow.
          # The format is "APPROVED:OPEN:<PR_COMMIT_SHA>", so compare the end of the string to the current commit.
          [[ ${{needs.approval.outputs.decision}} =~ $(git rev-parse HEAD)$ ]]

Disclosure Timeline

Kudos to GitHub for triaging the report and addressing the issue so quickly!

  1. January 1st. Submitted Report
  2. January 3rd. Fixed
  3. January 31st. Awarded bounty.

Conclusion

It’s important to look at more than just the usual suspects of issue_comment, pull_request_target, and workflow_run when looking for Poisoned Pipeline Execution vulnerabilities.

This is especially important as awareness of the risks surrounding pull_request_target and issue_comment grows. Maintainers might opt for workflow_dispatch triggered workflows because they are safer, but forget to account for race conditions.

Wondering how you can find these vulnerabilities? Head over to the Gato-X repository and install the tool. Gato-X scans for workflow dispatch TOCTOU events along with several other GitHub Actions vulnerabilities.