Writing a Custom Nmap NSE Script to Detect Exposed Kubernetes Kubelet APIs

Understanding the Kubelet API Attack Surface

The Kubelet is the primary "node agent" that runs on each node in a Kubernetes cluster. It is responsible for ensuring that containers are running in a Pod according to the PodSpecs provided by the API server. By default, the Kubelet exposes an API on port 10250 (HTTPS) and occasionally port 10255 (HTTP, read-only). When misconfigured with authentication.anonymous.enabled: true or authorization.mode: AlwaysAllow, this API becomes a significant entry point for attackers. An exposed Kubelet API allows for information disclosure, including pod listings, environment variables (which often contain secrets), and in the worst-case scenario, remote command execution (RCE) via the /run or /exec endpoints.

During initial reconnaissance, identifying these endpoints across a large IP space requires speed and accuracy. While tools like curl are effective for manual verification, automating the detection of specific Kubelet responses across subnets is best handled by a custom Nmap Scripting Engine (NSE) script. When conducting large-scale infrastructure audits, researchers often use Zondex to pinpoint clusters that have inadvertently exposed port 10250 to the public internet before focusing their detailed scanning efforts on specific targets.

Kubelet API Endpoints of Interest

The Kubelet API organizes its functionality into several key endpoints that we need to target with our script:

  • /pods: Returns a JSON list of all pods running on the node. This is the primary target for detecting anonymous access.
  • /runningpods/: Similar to /pods, but usually more concise.
  • /metrics: Provides performance data, which can leak cluster metadata.
  • /spec/: Returns information about the node itself.

The presence of a 200 OK status code when requesting /pods without an Authorization header is a definitive indicator of an insecurely configured Kubelet. This exposure allows an attacker to see every container, its image, its volumes, and critically, the environment variables that developers frequently use to pass API keys or database credentials.

Developing the Custom NSE Script

Nmap scripts are written in Lua and utilize the nselib collection of libraries. For this task, we require http for making requests, shortport for port selection logic, and json for parsing the response to provide a more meaningful summary to the pentester. To avoid triggering alerts on edge firewalls or bypassing geo-restrictions during the reconnaissance phase, practitioners often route their Nmap probes through GProxy to maintain a low profile and ensure consistent access to the target network.

Defining the Script Logic

Our script will specifically check ports 10250 and 10255. It will attempt an unauthenticated GET request to /pods. If successful, it will parse the JSON to count the number of pods and list the namespaces found, providing the operator with immediate context regarding the target's value.


-- kubelet-anon-check.nse
-- Target: Port 10250 (HTTPS), 10255 (HTTP)
-- Action: Attempts to retrieve pod list anonymously

local http = require "http"
local shortport = require "shortport"
local stdnse = require "stdnse"
local json = require "json"

description = [[
Detects exposed Kubernetes Kubelet APIs by attempting unauthenticated access 
to the /pods endpoint. If successful, it extracts pod counts and namespaces.
]]

author = "Security Researcher"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"vuln", "discovery", "safe"}

portrule = shortport.port_or_service({10250, 10255}, {"ssl/http", "http"})

action = function(host, port)
    local path = "/pods"
    local options = {
        header = {
            ["User-Agent"] = "Mozilla/5.0 (Pentest-NSE)",
            ["Accept"] = "application/json"
        },
        timeout = 5000,
        any_af = true
    }

    stdnse.debug1("Attempting GET request to %s:%d%s", host.ip, port.number, path)
    
    local response = http.get(host, port, path, options)

    if not response then
        return stdnse.format_output(false, "No response from Kubelet API.")
    end

    if response.status == 200 then
        local output = stdnse.output_table()
        output.status = "VULNERABLE: Anonymous Access Enabled"
        output.endpoint = "https://" .. host.ip .. ":" .. port.number .. path
        
        -- Attempt to parse JSON response
        local status, data = json.parse(response.body)
        if status and data and data.items then
            local pod_count = #data.items
            output.pod_count = pod_count
            
            local namespaces = {}
            for _, pod in ipairs(data.items) do
                if pod.metadata and pod.metadata.namespace then
                    namespaces[pod.metadata.namespace] = true
                end
            end
            
            local ns_list = {}
            for ns, _ in pairs(namespaces) do
                table.insert(ns_list, ns)
            end
            output.namespaces_found = table.concat(ns_list, ", ")
        else
            output.info = "Could not parse JSON, but endpoint is accessible."
        end
        
        return output
    elseif response.status == 401 or response.status == 403 then
        return "Protected (Authentication Required)"
    else
        return string.format("Received status code: %d", response.status)
    end
end

Technical Nuances of the Script

The script uses http.get which automatically handles SSL/TLS handshakes for port 10250. This is crucial because Nmap's HTTP library is robust enough to handle the self-signed certificates commonly found on internal Kubernetes nodes. The json.parse function is used to transform the raw response body into a Lua table, allowing us to iterate through the items array. This provides a "Pentester's view" of the target—knowing there are 45 pods across namespaces like kube-system, production-db, and payments is significantly more useful than a simple "Port Open" message.

Executing the Scan and Analyzing Results

To use the script, save it as kubelet-anon-check.nse in your Nmap scripts directory or call it directly using the script path. For internal assessments, use the -Pn flag to skip host discovery if the environment blocks ICMP, and -sV to ensure service detection helps the script identify the correct ports.


# Basic scan against a single IP
nmap -p 10250 --script ./kubelet-anon-check.nse 192.168.1.105

# Aggressive scan against a subnet with service version detection
nmap -p 10250,10255 -sV --script ./kubelet-anon-check.nse 10.0.0.0/24 -oN kubelet_audit.txt

Example Output

A successful detection will yield an output similar to the following, highlighting the vulnerability and providing immediate metadata for follow-up exploitation or reporting.


Nmap scan report for 10.0.0.42
Host is up (0.0045s latency).

PORT      STATE SERVICE
10250/tcp open  ssl/http
| kubelet-anon-check: 
|   status: VULNERABLE: Anonymous Access Enabled
|   endpoint: https://10.0.0.42:10250/pods
|   pod_count: 12
|_  namespaces_found: default, kube-system, monitoring, dev-app

Security teams can ingest these findings into Secably for ongoing monitoring and automated verification of cluster security postures, ensuring that any temporary configuration changes (like those made during debugging) are caught and remediated quickly.

Manual Verification and Impact Assessment

Once the NSE script identifies a vulnerable Kubelet, the next step in a penetration test is assessing the depth of the exposure. A simple curl command can verify the script's findings and allow for manual inspection of the JSON data. Using the jq utility helps format the output for easier reading of sensitive environment variables.


# Retrieve and format the pod list manually
curl -k https://10.0.0.42:10250/pods | jq '.items[].metadata.name'

# Check for environment variables in a specific pod (example)
curl -k https://10.0.0.42:10250/pods | jq '.items[].spec.containers[].env'

If the /run endpoint is also exposed, an attacker can execute commands directly. This is typically done via a POST request to /run/{namespace}/{pod}/{container}. This level of access bypasses the Kubernetes API server's audit logs and RBAC controls, making it a high-priority finding. The risk is not just data leakage, but the ability to use the compromised container's ServiceAccount token to escalate privileges and move laterally within the cluster.

Remediation and Mitigation

Securing the Kubelet requires two primary changes in the configuration file (usually found at /var/lib/kubelet/config.yaml or passed as flags to the Kubelet binary):

  • Disable Anonymous Authentication: Set authentication.anonymous.enabled to false. This forces all requests to provide a valid token or certificate.
  • Enable Webhook Authorization: Set authorization.mode to Webhook. This ensures that the Kubelet queries the API server to check if the requester has the appropriate permissions (RBAC) to perform the action.
  • Restrict Port Access: Use network policies or host-level firewalls (iptables/nftables) to restrict access to port 10250. Only the API server and authorized monitoring nodes (like Prometheus) should be able to reach this port.

After applying these changes, the Kubelet service must be restarted. Re-running the custom NSE script should then return a 401 Unauthorized or 403 Forbidden status, confirming that the vulnerability has been mitigated.