GitHub Actions exploitation: Dependabot

Written by Hugo Vincent - 06/08/2024 - in Pentest - Download

Following our GitHub action exploitation series, we found a new GitHub action exploitation technique leveraging the Dependabot GitHub app to compromise some repositories, leading to arbitrary code push. In this article we will explain how we discovered it and illustrate with 2 real world examples which are the Spring-security and trpc projects.

GitHub Apps

The definition of a GitHub app from the GitHub documentation is quite explicit:

GitHub Apps are tools that extend GitHub's functionality. GitHub Apps can do things on GitHub like open issues, comment on pull requests, and manage projects. They can also do things outside of GitHub based on events that happen on GitHub. For example, a GitHub App can post on Slack when an issue is opened on GitHub.

A GitHub app is a nice alternative to the creation of a service account or user PAT that can be used in different scenarios. An example of such a GitHub app is Dependabot.

Dependabot

Dependabot is a special GitHub app that scans your repositories and creates a pull request when a new version of your dependencies is available. This is particularly useful because dependencies can sometimes be affected by known vulnerabilities, and Dependabot can detect these issues and suggest version upgrades to keep your repository secure.

Dependabot is free and can be enabled on your repository in the configuration section:

Settings

It can then be configured with a configuration file in your repository at this location: .github/dependabot.yml.

config

After that, if a dependency needs to be updated a new branch will be created:

Dependabot branch

And a PR will be opened:

Dependabot PR

One interesting point to note is that Dependabot will launch a workflow in your repository. The workflow is a bit special, and we cannot see what is being run inside. Normally we can inspect the content of a workflow but not here:

Dependabot run1Dependabot run2

However, using our tool gh-hijack-runner.py1 we managed to access some secrets that are sent to this runner:

Dependabot hijack.

Using these credentials it is possible to interact with the Dependabot internal API, for instance to create a controlled commit and make a pull request with custom content:

Custom commit.Custom PR.

If you are interested in a past Dependabot vulnerability you can check this article2.

Exploitation

Using octoscan3 on the homebrew/brew repository we got this alert:

brew.

Looking at the workflow, it seems a true positive. We have a dangerous pull_request_target and a checkout with a reference from the PR:

File: vendor-gems.yml
03: on:
13:   pull_request_target:
[...]
57:       - name: Check out pull request
58:         id: checkout
59:         if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
60:         run: |
61:           gh pr checkout '${{ github.event.pull_request.number || github.event.inputs.pull_request }}'

However, there is one additional check at the beginning of the workflow:

File: vendor-gems.yml
24: jobs:
25:   vendor-gems:
26:     if: >
27:       github.repository_owner == 'Homebrew' && (
28:         github.event_name == 'workflow_dispatch' ||
29:         github.event_name == 'pull_request' ||
30:         github.event_name == 'push' || (
31:           github.event.pull_request.user.login == 'dependabot[bot]' &&
32:           contains(github.event.pull_request.title, '/Library/Homebrew')
33:         )
34:       )

Basically if we want to exploit this workflow, the author of the PR must be Dependabot (line 31). Indeed, the GitHub context github.event.pull_request.user.login refers to the author of the PR. To our knowledge there is no way to force Dependabot to open a PR on a repository not under our control. However, we found some particular use case where the check was less restrictive and where this could be exploited.

Spring-security

On the Spring-security project, there is a workflow called merge-dependabot-pr.yml. It is configured with a dangerous pull_request_target trigger:

File: merge-dependabot-pr.yml
3: on: pull_request_target
4: 
5: run-name: Merge Dependabot PR ${{ github.ref_name }}
6: 
7: permissions: write-all

Then the code from the PR is downloaded:

File: merge-dependabot-pr.yml
15:       - uses: actions/checkout@v4
16:         with:
17:           show-progress: false
18:           ref: ${{ github.event.pull_request.head.sha }}

After that, a Bash script is executed, retrieving the version of the project in the gradle.properties file. If a milestone is opened with the associated version, the mergeEnabled variable is set to true:

File: merge-dependabot-pr.yml
25:       - name: Set Milestone to Dependabot Pull Request
26:         id: set-milestone
27:         run: |
[...]
32:             CURRENT_VERSION=$(cat gradle.properties | sed -n '/^version=/ { s/^version=//;p }')
33:           fi
34:           export CANDIDATE_VERSION=${CURRENT_VERSION/-SNAPSHOT}
35:           MILESTONE=$(gh api repos/$GITHUB_REPOSITORY/milestones --jq 'map(select(.due_on != null and (.title | startswith(env.CANDIDATE_VERSION)))) | .[0] | .title')
[...]
42:             gh pr edit ${{ github.event.pull_request.number }} --milestone $MILESTONE
43:             echo mergeEnabled=true >> $GITHUB_OUTPUT

Finally, the code is merged if the previous mergeEnabled variable was set to true:

File: merge-dependabot-pr.yml
48:       - name: Merge Dependabot pull request
49:         if: steps.set-milestone.outputs.mergeEnabled
50:         run: gh pr merge ${{ github.event.pull_request.number }} --auto --rebase
51:         env:
52:           GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}

There is one additional check (line 12) that is slightly different from the brew repository:

File: merge-dependabot-pr.yml
09: jobs:
10:   merge-dependabot-pr:
11:     runs-on: ubuntu-latest
12:     if: github.actor == 'dependabot[bot]'
13:     steps:

Here the GitHub context github.actor refers to the last identity that performed an action on the PR. This change is enough to exploit this workflow and push arbitrary code on the project.

Attack details

This attack was not performed directly against the spring-projects/spring-security repository, but the scenario was reproduced on a cloned version to demonstrate the impact. In this scenario there is the victim account hugo-syn who has the vulnerable hugo-syn/spring-security repository and there is also the attacker account 0x41gilecat who forked the repository (0x41gilecat/spring-security).

The idea of the attack is to trigger Dependabot on the forked repository in such a way that a PR on the forked repository is made by Dependabot, then a PR from the Dependabot branch is opened on the vulnerable repository and finally Dependabot is triggered again to launch the vulnerable workflow.

On the fork repository, the malicious file (evil.txt) is injected and a vulnerable package is added to trigger Dependabot. Note that a modification of the dependabot.yml file is also performed to trigger Dependabot faster:

Preparation 1.

This will trigger Dependabot on the fork repository where it will create a new branch and an associated PR:

branches.PR.

From there, the attacker can open a PR from the Dependabot branch, here dependabot/npm_and_yarn/docs/main/antora-3.2.0-alpha.6, to the vulnerable repository:

Open PR.

This will trigger the vulnerable workflow, but the last person having performed an action on the PR is the attacker, so github.actor == '0x41gilecat' thus preventing the exploitation. This can be bypassed by triggering a force push by Dependabot on the PR off the forked repository with a specific comment:

Comment.

Now the last person having performed an action on the PR of the vulnerable repository is the bot, thus github.actor == 'dependabot[bot]':

Bypass.

After this, the evil.txt file is merged on the main branch of the vulnerable repository:

evil.

The spring-session repository was also affected by this vulnerability. Here are the 2 fixes:

TRPC

We also found this vulnerability on the trpc/trpc repository in the dependabot-merge.yml workflow:

File: dependabot-approve.yml
03: on: pull_request_target
[...]
08: 
09: jobs:
10:   dependabot:
11:     runs-on: ubuntu-latest
12:     if: ${{ github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]' }}
13:     steps:
[...]
20:       - name: Enable auto-merge for Dependabot PRs
21:         run: gh pr merge --auto --squash "$PR_URL"
22:         env:
23:           PR_URL: ${{github.event.pull_request.html_url}}
24:           GITHUB_TOKEN: ${{secrets.TRPC_GITHUB_TOKEN}}

All the prerequisites are here:

  • A dangerous trigger (line 3).
  • A weak access control based on github.actor (line 12).
  • An automatic merge of the PR (line 21).

We implemented this rule in octoscan:

octoscan.

You can find the fix for this vulnerability in this commit.

Failed attempts

During this research, the following pattern was frequently observed:

File: dependabot_automerge.yml
02: name: Dependabot auto-merge
03: on:
04:   pull_request_target:
05: 
06: jobs:
07:   automerge:
08:     runs-on: ubuntu-latest
09: 
10:     if: ${{ github.actor == 'dependabot[bot]' }}
11: 
12:     steps:
13:       - name: Dependabot metadata
14:         uses: dependabot/fetch-metadata
[...]
25:       - name: Auto-merge for Dependabot PRs
27:         run: gh pr merge --auto --rebase "$PR_URL"

The key difference here is the usage of the dependabot/fetch-metadata action. This action embed additional checks:

File: verified_commits.ts
7: const DEPENDABOT_LOGIN = 'dependabot[bot]'
[...]
16:   const { pull_request: pr } = context.payload
[...]
25: 
26:   // Don't bother hitting the API if the PR author isn't Dependabot unless verification is disabled
27:   if (!skipVerification && pr.user.login !== DEPENDABOT_LOGIN) {
28:     core.debug(`PR author '${pr.user.login}' is not Dependabot.`)
29:     return false
30:   }

Line 27, there is a check that fails if the author of the PR is not Dependabot. This is again based on the github.event.pull_request.user.login context, representing the author of the PR. From our tests it is not possible to force a PR from Dependabot on a remote repository.

Conclusion

This article highlights the fact that even workflows that were intentionally hardened to prevent exploitation can still be exploited. This is inherent to the fact that there are multiple ways to obtain the identity of a user, but each won't give the same results, such as:

  • github.event.pull_request.user.login
  • github.actor
  • github.event.sender.login
  • github.event.head_commit.author.name
  • github.event.commits.*.author.name

This exploitation scenario can be prevented using our tool octoscan, already available on GitHub. Finally, the GitHub documentation4 is also a good resource to ensure choosing the correct variable.