DEV Community

The Cyber Sidekick
The Cyber Sidekick

Posted on

Stop exposing the API server through a NodePort (CKS)

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",
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)