Stop exposing the API server through a NodePort (CKS)
Lesson three of the CKS series. A security review found the Kubernetes API server reachable through a NodePort, and your job is to put it back behind a ClusterIP. It is one line in a manifest, plus a second step that trips up most people, because the API server will not fix the Service for you. Let's do it on a real control plane.
🎥 Watch the video: https://www.youtube.com/watch?v=GWOuoCNDZq4
This is a CKS Cluster Setup walkthrough. Every command below is real output from a live cluster, and you can reproduce the whole thing yourself (scripts at the end).
The scenario
The setup. A review of this cluster reports that the API server is reachable through a NodePort Service. Change the API server setup so that it is reachable only through a ClusterIP Service. The cluster was built with kubeadm, so the control plane runs as static pods, and you have root on the control plane node.
- Finding: the API server is reachable through a NodePort
- Fix it: reachable through a ClusterIP Service only
- kubeadm cluster: control plane = static pods on the node
Who owns the kubernetes Service
The Service called kubernetes in the default namespace is not like other Services. Nobody applies it; the API server creates it and keeps it pointed at itself. A kube-apiserver flag, --kubernetes-service-node-port, tells it to create that Service as a NodePort instead. That opens the API server on a high port on every node in the cluster. The catch: the API server only picks the type when it creates the Service. Remove the flag, and the Service that already exists stays a NodePort until you delete it and let the API server recreate it.
The exposure
We are on the control plane node. The kubernetes Service is type NodePort, with port four four three mapped to three zero four four three. And look at what an anonymous request gets through that port: no credentials at all, and the API server hands back its exact version. That is a gift to an attacker scanning for known vulnerable builds, and it is available on every node's IP.
$ kubectl get svc kubernetes
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes NodePort 10.96.0.1 <none> 443:30443/TCP 3s
$ curl -sk https://172.18.0.4:30443/version | head -n 5
{
"major": "1",
"minor": "37",
"emulationMajor": "1",
"emulationMinor": "37",
Find the flag
Since the API server owns this Service, the cause is in the API server's configuration. On a kubeadm cluster that is the static pod manifest, kube-apiserver.yaml, in /etc/kubernetes/manifests. Grep it for node-port and there it is, on line sixteen.
$ grep -n node-port /etc/kubernetes/manifests/kube-apiserver.yaml
16: - --kubernetes-service-node-port=30443
Back up the manifest
Before editing a control plane manifest, back it up, and put the backup somewhere other than the manifests folder. Here it goes to /root. If you drop a copy in the manifests folder, the kubelet treats it as a second static pod and tries to run two API servers, which is a great way to lose the exam's cluster for ten minutes. The listing shows the manifests folder still has exactly four files.
$ cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak && ls /root /etc/kubernetes/manifests
/etc/kubernetes/manifests:
etcd.yaml
kube-apiserver.yaml
kube-controller-manager.yaml
kube-scheduler.yaml
/root:
kube-apiserver.yaml.bak
Remove the flag
Now open the manifest in vi, find the node-port line and delete it with d d, then save with colon w q. Setting the value to zero would also work, but removing the line leaves the manifest exactly as kubeadm would write it. The kubelet watches this folder, so saving the file is enough to restart the API server.
$ grep -- ' - --' /etc/kubernetes/manifests/kube-apiserver.yaml | head -n 8
- --advertise-address=172.18.0.4
- --kubernetes-service-node-port=30443
- --allow-privileged=true
- --authorization-mode=Node,RBAC
- --client-ca-file=/etc/kubernetes/pki/ca.crt
- --enable-admission-plugins=NodeRestriction
- --enable-bootstrap-token-auth=true
- --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
$ grep -- ' - --' /etc/kubernetes/manifests/kube-apiserver.yaml | head -n 7
- --advertise-address=172.18.0.4
- --allow-privileged=true
- --authorization-mode=Node,RBAC
- --client-ca-file=/etc/kubernetes/pki/ca.crt
- --enable-admission-plugins=NodeRestriction
- --enable-bootstrap-token-auth=true
- --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
Still a NodePort
Give it thirty seconds or so. crictl shows a brand new API server container, created a few seconds ago, so the change took. Now check the Service again. Still NodePort, still three zero four four three. This is where people assume the edit failed and start changing other things. It did not fail: the API server only sets the type when it creates the Service, and this one already exists.
$ crictl ps --name kube-apiserver -o table
CONTAINER IMAGE CREATED STATE NAME ATTEMPT
92213fa10199c bec5f0e1e2eeb 2 seconds ago Running kube-apiserver 0
$ kubectl get svc kubernetes
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes NodePort 10.96.0.1 <none> 443:30443/TCP 42s
Recreate and verify
So delete it. That sounds scary, but the API server owns this Service and recreates it within a second or two, this time using its new configuration. There it is: ClusterIP, port four four three, no node port. And the final proof: the same request to three zero four four three that leaked the version a minute ago now cannot even connect.
$ kubectl delete svc kubernetes
service "kubernetes" deleted from default namespace
$ kubectl get svc kubernetes
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 6s
$ curl -sSk --max-time 5 https://172.18.0.4:30443/version
curl: (7) Failed to connect to 172.18.0.4 port 30443 after 0 ms: Could not connect to server
Exam tips
Tips for the exam. Grep the manifest for the setting instead of scrolling. Back up to a folder outside the manifests directory, never inside it. After saving, wait for a new API server container with crictl or watch, and remember kubectl will refuse connections for a while during the restart; that is normal. If the API server does not come back, check crictl ps dash a and the container logs for a typo. And when the Service type has not changed, delete the kubernetes Service; the API server recreates it.
- grep -n the manifest for the flag
- Backup OUTSIDE /etc/kubernetes/manifests
- Wait for the restart: crictl ps, kubectl refuses meanwhile
- Not back? crictl ps -a + crictl logs for the typo
- Type unchanged? delete svc kubernetes; it is recreated
Recap
- The kubernetes Service is owned by the API server
- Remove --kubernetes-service-node-port from the static pod
- Delete the Service so it is recreated as ClusterIP
- Verify the port is closed; subscribe + dev.to writeup
Reproduce this yourself
The entire scenario is scripted on a throwaway kind cluster: https://github.com/The-Cyber-Sidekick/TCS_CKS_2026_Exam_Scenarios
git clone https://github.com/The-Cyber-Sidekick/TCS_CKS_2026_Exam_Scenarios.git
cd TCS_CKS_2026_Exam_Scenarios/scenario3-apiserver-nodeport
./setup.sh # creates the cluster AND arms the scenario
# solve it by hand, or:
./solution.sh # apply the answer key and verify
If this helped, subscribe to The Cyber SideKick on YouTube for more CKS drills, and grab the newsletter at https://thecybersidekick.beehiiv.com.
Top comments (0)