This is post one of three I’m posting today. The full collections is:

Update 2026-03-27: I’ve also updated the post with a link to a human-readable transcript of the whole Claude session, available here

This post is entirely generated by Claude Opus 4.6. I’m not trying to promote AI Slop, but in the interests of a “fair” experiment, everything other than the frontmatter and this paragraph is free-range, organic, AI-generated good(?)ness.

If you don’t care about how I set this up and just want to know if you should be scared for your job, Claude’s writeup is available here. Be aware that I did not even attempt to solve these challenges manually, so I cannot talk for how accurate the attack paths were to the intended challenge solutions. For a human writeup, head on over to https://www.skybound.link/2026/03/kubecon-eu-2026-ctf-writeup/


KubeCon EU 2026 in Amsterdam hosted a series of Kubernetes security CTF challenges that tested progressively deeper knowledge of the platform’s attack surface. Over the course of three challenges, we encountered ValidatingAdmissionPolicy information leaks, Linkerd service mesh authorization bypasses, and kubelet API exploitation through the Kubernetes node proxy — a breadth that reflects how modern cluster security depends on getting dozens of independent controls right simultaneously.

This writeup is part of research into LLM-assisted security assessments. A human supervised the engagement while an AI agent — Claude — performed reconnaissance, analyzed configurations, and executed attack chains. The agent had no prior knowledge of any challenge structure. Where the agent succeeded, the reasoning is documented; where it struggled or failed, that’s documented too.

Challenge 1: The Admission

Theme: A Dutch pub where you place food orders through Kubernetes custom resources, enforced by ValidatingAdmissionPolicies. Flags: 2 of 2 captured.

Environment and Access

We landed in a pod as system:serviceaccount:table-1:table-1 with surprisingly broad permissions: full CRUD on orders.amsterdam.pub, write access to adminrules.amsterdam.pub, and — critically — read access to validatingadmissionpolicies. That last permission meant we could read the exact CEL validation expressions and their error message templates before triggering them.

1
2
3
4
5
$ kubectl auth can-i --list
adminrules.amsterdam.pub    [get list watch create update patch]
orders.amsterdam.pub        [get watch list create update patch delete]
menus.amsterdam.pub         [get watch list]
validatingadmissionpolicies [get watch list]

Flag 1: The Extra Sauce

The pub’s MOTD instructed us to place orders using amsterdam.pub/v2, but a create-order-v2 policy blocked all v2 orders with an unconditionally false expression (1 == 0). This forced us to v1, where a richer validation chain awaited.

The create-order-v1 policy had five CEL validations. The first four were satisfiable constraints — order items must exist on the menu, extra sauce only on items that allow it, at least one item with extra sauce. The fifth was the oracle:

1
2
3
- expression: |
    has(object.flags) && has(object.flags.flag1) && object.flags.flag1 == params.flags.flag1
  messageExpression: '"Flag 1 is: " + params.flags.flag1'

The vulnerability is in the messageExpression. When the validation fails, the denial message interpolates the server-side secret from the RestaurantRule parameter resource — a resource we couldn’t read directly.

We crafted an order satisfying validations 1–4 but deliberately failing 5 with a wrong flag value:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: amsterdam.pub/v1
kind: Order
metadata:
  name: my-order
  namespace: table-1
isTestOrder: false
flags:
  flag1: "wrong"
spec:
  items:
    - name: Bitterballen
      extraSauce: true
Flag 1 is: flag_ctf{3xtr4_sauce_is_always_nice}

Flag 2: The Admin Escalation

Flag 2 was hidden in the delete-order policy, which leaked params.flags.flag2 in its error message — but only if the deleted order had isTestOrder: true. The create-order-v1 policy required isTestOrder: false, and the update-order-v1 policy checked that only admin service accounts could flip it.

The critical misconfiguration: we had write access to the AdminRule custom resource that the update policy used as its authority source. The policy checked whether our identity was in the admin list, but we could edit the admin list ourselves.

The four-step chain:

  1. Create a valid v1 order with isTestOrder: false (using Flag 1’s value to pass all validations)
  2. Escalate by adding our SA to the AdminRule: system:serviceaccount:table-1:table-1
  3. Update the order to set isTestOrder: true (now permitted)
  4. Delete the order, triggering: Flag 2 is: flag_ctf{never_forget_about_RBAC_and_versioning}

The flag name itself is the lesson. The RBAC was too permissive on the AdminRule CRD, creating a circular trust problem where governed subjects could modify their own governance.


Challenge 2: Operation Shadow Mesh

Theme: A Linkerd service mesh “heist” with an Envoy Gateway requiring mTLS client certificates. Flags: 2 of 3 captured. The gateway mTLS flag (Flag 1) was not solved.

Environment and Access

We landed as system:serviceaccount:default:jumppod with namespace-varying permissions. The MOTD directed us to an Envoy Gateway requiring a client certificate. Key namespaces: backend, production, supersecret, envoy-gateway-system, linkerd.

production:   pods, pods/exec, pods/log, deployments
supersecret:  meshtlsauthentications (get/watch/list/update/patch)
default:      gateways, clienttrafficpolicies, envoyproxies

Reconnaissance: The Decoy Certificate

A ClientTrafficPolicy resource had annotations from [email protected] containing a client certificate and private key with the comment “putting the cert and key here so they don’t get lost :)”. The certificate was signed by O=Linkerd-CTF, CN=Linkerd-CTF — matching the gateway’s acceptable CA. But the private key’s RSA parameters were mathematically invalid, and more importantly, the certificate was signed by a different CA with the same distinguished name. The gateway’s openssl s_client handshake confirmed: tlsv1 alert unknown ca. A classic decoy.

Flag 2: Breaking the Mesh Perimeter

The production namespace contained a receiver pod — a Linkerd-meshed Python HTTP server with three containers: linkerd-proxy, python-server, and debug-tools. The debug-tools container had NET_ADMIN/NET_RAW capabilities, curl, and tcpdump.

In the supersecret namespace, a MeshTLSAuthentication resource restricted access to only default.supersecret.serviceaccount.identity.linkerd.cluster.local. We had update/patch permissions on this resource.

The attack: add the receiver pod’s mesh identity to the allowed list:

1
2
3
4
5
6
7
8
9
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: supersecret
  namespace: supersecret
spec:
  identities:
  - default.supersecret.serviceaccount.identity.linkerd.cluster.local
  - default.production.serviceaccount.identity.linkerd.cluster.local

Then curl from the debug-tools container (which shares the receiver’s Linkerd sidecar network):

1
Target Acquired: flag_ctf{not_so_supersecret_anymore}

The response also revealed the next objective: a classified payload hitting the receiver every 5 seconds.

Flag 3: Caught in the Wire

The hint pointed to tcpdump — pre-installed in the debug-tools container. Capturing traffic on port 8080 revealed periodic HTTP requests from the supersecret namespace:

GET / HTTP/1.1
host: receiver.production:8080
x-flag: flag_ctf{caught_in_the_wire}
l5d-client-id: default.supersecret.serviceaccount.identity.linkerd.cluster.local

The flag was transmitted in a custom HTTP header, invisible to the application but visible on the wire. The Linkerd proxy terminated mesh mTLS and forwarded plain HTTP to the application container — meaning any container sharing the pod’s network namespace could observe inter-service traffic.

Flag 1 (Unsolved): Gateway mTLS

The gateway required a client certificate signed by the real Linkerd-CTF CA. We attempted to obtain one through the Linkerd Identity service’s gRPC Certify RPC — successfully generating a signed mesh identity certificate by calling the identity service with a CSR and the pod’s SA token. However, the mesh root CA (root.linkerd.cluster.local) was different from the gateway’s client CA (Linkerd-CTF), so the mesh identity certificate was rejected.

The intended solution path for this flag remains unclear. The CTP annotation certificate was a confirmed decoy, and no accessible resource contained the correct CA’s private key or a valid client certificate.


Challenge 3: Stealth-Left

Theme: Stealth aircraft (B-2, F-117, SR-71) with secrets mounted at /etc/secret, discoverable through kubelet API enumeration. Flags: 2 of 3 captured. The SR-71 flag required WebSocket-capable tooling that we couldn’t fully deploy in time.

Environment and Access

Extremely restricted: system:serviceaccount:jumphost:jumphost had almost no cluster permissions. The MOTD hinted at “hidden /etc/secrets crossing our skies” and directed us to the b2 namespace.

b2 namespace: pods (get/list), pods/exec (get/list/create)

Flag 1: The B-2 Spirit

Straightforward — exec into the b2 pod and read its mounted secret:

1
2
$ kubectl exec -n b2 b2-6454ffccfb-bmfgs -- cat /etc/secret/flag
flag_ctf{not_really_stealth_right}

The accompanying hint was more interesting: “Is there any other plane flying above our head?” with a note that the stealth service account could interact with Nodes.

Flag 2: The F-117 Nighthawk

The b2 pod ran as the stealth SA, which had nodes and nodes/proxy (get/watch/list) permissions — kubelet API read access through the Kubernetes API server proxy.

The kubelet’s /pods/ endpoint returns the full pod list for a node, including namespace information. Querying all three nodes revealed hidden namespaces:

1
2
3
4
5
6
7
8
9
$ kubectl get --raw /api/v1/nodes/node-1/proxy/pods/ | grep namespace | sort -u
"namespace":"b2"
"namespace":"f117-19rks1k2"
"namespace":"kube-system"

$ kubectl get --raw /api/v1/nodes/node-2/proxy/pods/ | grep namespace | sort -u
"namespace":"jumphost"
"namespace":"kube-system"
"namespace":"sr71-49fj1d92"

Two hidden namespaces with stealth aircraft pods: f117-19rks1k2 and sr71-49fj1d92. The stealth SA had pods/exec in the f117 namespace:

1
2
$ kubectl exec -n f117-19rks1k2 f117-56dcc5bbcf-lhpjg -- cat /etc/secret/flag
flag_ctf{kubecon_EU_24_ftw}

Flag 3 (Unsolved): The SR-71 Blackbird

The SR-71 pod was in sr71-49fj1d92 on node-2, but the stealth SA had NO pods/exec there — only nodes/proxy with GET permission. The intended path was to use nodes/proxy for command execution via the kubelet’s /exec endpoint, which accepts WebSocket upgrades initiated as GET requests.

We installed websocat on the jumphost to attempt a proper WebSocket connection through the API server’s node proxy, but the API server consistently returned HTTP 400 for WebSocket upgrade requests to the nodes/proxy path. The likely solution was to install websocat inside the b2 pod (which could reach the kubelet’s exec endpoint through the API server with proper WebSocket framing), but the environment expired before we could complete this approach.


Post-Exploitation Analysis and Defensive Recommendations

These three challenges collectively demonstrate how Kubernetes security failures cascade across abstraction layers.

Treat policy parameter resources as security-critical. In Challenge 1, the AdminRule and RestaurantRule CRDs fed into admission policies but were writable by the subjects being governed. Any custom resource referenced by ValidatingAdmissionPolicy paramKind should have RBAC as restrictive as the policies themselves.

Never interpolate secrets in error messages. Both Challenge 1 flags exploited messageExpression fields that included params.flags.* values. The CEL expression language makes this easy to do accidentally. Treat messageExpression with the same discipline as log output — static strings only.

Audit mesh authorization holistically. Challenge 2’s MeshTLSAuthentication was the single control preventing cross-namespace access. The combination of writable mesh policies and pods/exec in the production namespace created an attack chain: modify the policy, then use tcpdump from a sidecar container to observe decrypted inter-service traffic.

Restrict nodes/proxy carefully. Challenge 3 demonstrated that even GET-only access to nodes/proxy enables pod enumeration across all namespaces via the kubelet’s /pods/ endpoint — bypassing namespace-scoped RBAC entirely. Combined with WebSocket-capable tooling, the kubelet’s /exec endpoint can provide command execution in any pod on the node through what appears to be a read-only permission.

Remember that sidecar containers share the network namespace. The tcpdump flag in Challenge 2 illustrates a fundamental property: any container in a pod can observe all network traffic for that pod. Debug containers with NET_RAW capability are particularly dangerous in meshed environments where the sidecar proxy handles mTLS termination — the inter-service traffic is plaintext inside the pod.