Skip to content
Stribog

Security

All writing

External Penetration Testing: Scope the Perimeter You Built

External penetration testing on a self-hosted perimeter: write down what should answer, find what does (kubelet, etcd, Docker ports) and read what it proves.

Stribog15 min read

Search for external penetration testing and the results are service pages listing phases and web-application findings: injection, cross-site scripting, weak sessions. Real findings, but half the story on a self-hosted perimeter, where one misplaced packet path puts a control-plane port or a dashboard on the internet with no application bug involved.

This article covers the other half: what the test is testing against, the documented mechanisms that create exposure your firewall rules do not describe, the open-source tools that build and check the list, and what a clean result proves.

What 'external' means, and what it does not cover

NIST SP 800-115, the Technical Guide to Information Security Testing and Assessment, puts it plainly: external security testing is conducted from outside the organization's security perimeter, "usually as seen from the Internet". The guide then describes where such a test starts, and that is the part that matters for scoping. It says external testing often begins with reconnaissance of public registration data, DNS server information and other public material, and only then moves to enumeration of external hosts and listening services.

Internal testing starts elsewhere: per the same guide, internal assessors are often granted some network access, normally as general users. An internal test asks what a foothold can reach. An external test asks what the internet can reach before any foothold exists.

So an external test does not cover lateral movement, in-cluster authorization or NetworkPolicy between namespaces. Those belong to internal testing and to auditing default-deny Kubernetes network policy. Asking an external tester to prove them yields a report implying coverage it lacks.

SP 800-115 ships a Rules of Engagement template as Appendix B. Use it, or an equivalent, before anyone scans. Scan only address space you hold; your provider's acceptable-use terms decide what is permitted even there.

Write the expected-exposure manifest before anyone scans

A port list without a statement of intent is a pile of observations. Port 443 open on the ingress address is correct; open on a node's second public IP it may be a side door. Only the operator can tell those apart, and only if the intent was written down first.

The expected-exposure manifest is that intent: every public name, every IPv4 and IPv6 prefix you hold, every port that should answer, what stands in front of it, and who owns the decision. It also lists what must not answer. Keep it in version control and attach it to the statement of work as the scope annex.

yaml
version: 1
owner: platform-team
reviewed: "2026-11-11"
prefixes:
  ipv4:
    - 203.0.113.0/28
  ipv6:
    - "2001:db8:40::/64"
hosts:
  - name: www.example.com
    addresses: ["203.0.113.10", "2001:db8:40::10"]
    ports:
      - port: 443
        proto: tcp
        expect: open
        service: ingress, TLS terminated at the gateway
        auth: none by design (public site)
        owner: web
  - name: k8s-api.example.com
    addresses: ["203.0.113.12"]
    ports:
      - port: 6443
        proto: tcp
        expect: filtered
        service: kube-apiserver, admin VPN source ranges only
        auth: client certificates or OIDC
        owner: platform
must_not_answer:
  - { ports: "2379-2380", proto: tcp, why: etcd client and peer }
  - { ports: "10250", proto: tcp, why: kubelet API }
  - { ports: "10256", proto: tcp, why: kube-proxy health }
  - { ports: "10257", proto: tcp, why: kube-controller-manager }
  - { ports: "10259", proto: tcp, why: kube-scheduler }
  - { ports: "30000-32767", proto: tcp, why: NodePort range }
  - { ports: "30000-32767", proto: udp, why: NodePort range }
expected-exposure.yaml — the scope annex. Documentation addresses (RFC 5737, RFC 3849) stand in for real ones.

expect: filtered records a deliberately source-restricted exposure: filtered from outside is a pass, open is a finding. must_not_answer makes the absence of a port testable. The port numbers come from the Kubernetes ports and protocols reference: 6443 for the API server, 2379-2380 for etcd, 10250 for the kubelet, 10259 for kube-scheduler, 10257 for kube-controller-manager, 10256 for kube-proxy, and 30000-32767 over TCP and UDP for NodePort Services. The same page notes that every default can be overridden, so the manifest should carry your actual numbers, not the defaults.

Four side doors your firewall rules do not describe

A ruleset tells you what the host firewall was meant to do, not what the packet path does: on a self-hosted node, several components program reachability of their own. These four are documented upstream, which makes them checkable.

The ruleset describes one path. The scan sees all of them, and the manifest says which ones are meant to be there.

1. Docker publishing goes around ufw

Docker's packet filtering documentation states it directly: when you publish a container's ports, traffic is diverted before it goes through the ufw firewall settings, because Docker routes container traffic in the nat table, ahead of the INPUT and OUTPUT chains ufw uses. Docker describes this for its default iptables backend. Its nftables backend, experimental since Docker 29.0.0, is not documented as restoring ufw filtering, so treat it the same way until a scan says otherwise. Docker's port publishing page adds that publishing is insecure by default: a published port is reachable from the outside world, not only the host, unless the mapping includes a localhost address. A ufw deny 3000 beside a 3000:3000 publish reads correctly and does nothing.

2. A NodePort listens on every node

The Kubernetes Service documentation says the control plane allocates a NodePort from the --service-node-port-range (default 30000-32767) and that each node proxies that same port number into the Service. Which node addresses it answers on is kube-proxy's nodePortAddresses setting. Left unset, it defaults to all node IPs in iptables and ipvs mode and to the node's primary IPs in nftables mode. On a single-NIC server whose primary address is public, either default puts the Service on the internet.

3. The kubelet binary defaults to anonymous and AlwaysAllow

The kubelet authentication and authorization page documents two defaults. Requests to the kubelet's HTTPS endpoint that no other method authenticates are treated as anonymous (system:anonymous, group system:unauthenticated), and the default authorization mode is AlwaysAllow, which allows every authenticated request, anonymous ones included. These are binary defaults; kubeadm and most distributions ship a KubeletConfiguration that disables anonymous and uses Webhook, so check what your nodes run. Regression-testing that belongs in automated penetration testing; the external test confirms 10250 does not answer from outside at all.

4. The API server tells strangers its version

Kubernetes RBAC binds the system:public-info-viewer ClusterRole to both system:authenticated and system:unauthenticated, granting read-only access to non-sensitive cluster information, per the RBAC reference. That is why /version answers an anonymous probe. In a scan dated 2022-05-16, the Shadowserver Foundation found 381,645 unique IPv4 addresses where the Kubernetes API answered its probe with 200 OK, out of 454,729 instances it saw, nearly 84%. That is a 2022 IPv4 snapshot of version disclosure, not exploitability: Shadowserver ran no intrusive checks. It does show an internet-facing API server is easy to find and fingerprint, which justifies an expect: filtered line.

None of the four is a CVE. Each is documented behaviour that produces reachability, the class of finding a scan observes and a configuration review, reading intent, misses.

Discovery tools compared: names, then ports

Building the manifest is a discovery exercise, and so is day one of any commissioned test. Run the same open-source layer yourself: names first, then ports.

Names: Amass or subfinder

Many names leak through Certificate Transparency. RFC 9162, the Experimental CT v2.0 specification that obsoletes RFC 6962, describes CT as a protocol for publicly logging TLS server certificates as they are issued or observed, so that anyone can audit certificate authority activity. Any hostname on a logged certificate is public, whether or not it is in your DNS zone; a search service such as crt.sh lists them. The inverse is easy to forget: a name with no logged certificate will not appear, so CT is a floor for the inventory, not a ceiling.

  • [subfinder](https://github.com/projectdiscovery/subfinder) does passive subdomain enumeration only, from passive online sources. It sends nothing to your hosts, which makes it safe to run from anywhere and on any schedule. MIT-licensed; v2.17.0 shipped in October 2026.
  • [OWASP Amass](https://github.com/owasp-amass/amass) maps attack surfaces and discovers external assets using both open-source information gathering and active reconnaissance. The active techniques find names passive sources miss, and they also touch infrastructure, so they belong inside the rules of engagement.

Start with subfinder; bring Amass in when the passive list looks short.

Ports: masscan, naabu or nmap

  • [masscan](https://github.com/robertdavidgraham/masscan) describes itself as an Internet-scale port scanner, able to cover the whole IPv4 internet in under five minutes at 10 million packets per second from one machine. Its latest tagged release is 1.3.2 from January 2021, though commits continued into 2026, so read that as a release-cadence difference, not abandonment. AGPL-3.0, which matters if it ends up inside a product you distribute.
  • [naabu](https://github.com/projectdiscovery/naabu) is a Go scanner doing SYN, CONNECT and UDP scans. Its -exclude-cdn flag skips full scans for CDN and WAF hosts and probes only 80 and 443 there. MIT-licensed; v2.7.0 shipped in October 2026.
  • [nmap](https://nmap.org/book/man-misc-options.html) is the identification layer: service version detection and scripts on the ports the sweep found. With -6, ping scanning, port scanning, version detection and NSE all support IPv6, provided the scanning host itself has IPv6 connectivity.

For a few dozen addresses naabu's defaults are easier to live with; for a large IPv4 allocation, masscan's rate control justifies its licence. Either way, nmap identifies.

Discovery, sweep, identification, diff. A commissioned tester repeats the same flow, so the manifest and the raw output are the hand-over.

The script below runs that flow from outside your network. Its scan targets come from the manifest, not from DNS. addresses.txt lists every address the manifest holds, including node and allocation addresses that no name points to, because those are where NodePorts and Docker publishes answer. expected.txt is the manifest's open list flattened to address:port lines, with IPv6 entries bracketed and written in the compressed form nmap prints; the script lowercases both files before comparing. Generate both files from expected-exposure.yaml in the same change that edits it, so the scan and the scope annex cannot drift apart.

Names are still discovered and resolved, but never port-scanned. A subdomain that resolves to an address outside the manifest is either a gap in the manifest or a host somebody else runs, such as a vendor's landing page or a CDN edge. Sweeping every port on a third party's address is outside any rules of engagement you can sign for, so the script writes those addresses to resolved-not-in-manifest.txt for a person to read instead. Before the sweep starts, it also refuses an expected.txt that names an address it will not scan, because that line could only ever come back as a false "closed".

bash
#!/usr/bin/env bash
# Usage: external-selfcheck.sh <domain> <addresses.txt> <expected.txt>
# addresses.txt: every address in the manifest, one per line, including node
#   and allocation addresses with no DNS name. Only these are port-scanned.
# expected.txt: one "203.0.113.10:443" or "[2001:db8:40::10]:443" per line.
# TCP only: the manifest's UDP must_not_answer lines are NOT tested here.
set -euo pipefail

usage="usage: $0 <domain> <addresses.txt> <expected.txt>"
domain="${1:?$usage}"
addresses="${2:?$usage}"
expected="${3:?$usage}"
out="selfcheck-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$out"
status=0

# 1. Scan targets come from the manifest, never from DNS.
tr 'A-F' 'a-f' < "$addresses" | sort -u > "$out/targets.txt"
grep -E '^[0-9.]+$' "$out/targets.txt" > "$out/v4.txt" || true
grep -E '^[0-9a-f:]+$' "$out/targets.txt" > "$out/v6.txt" || true
tr 'A-F' 'a-f' < "$expected" | sort -u > "$out/expected.txt"
sed -E 's/^\[?([^]]*)\]?:[0-9]+$/\1/' "$out/expected.txt" | sort -u \
  | comm -23 - "$out/targets.txt" > "$out/expected-unscanned.txt"
if [[ -s "$out/expected-unscanned.txt" ]]; then
  echo "expected.txt names addresses missing from addresses.txt" >&2
  exit 2
fi

# 2. Names: passive sources only, resolved for comparison, never scanned.
subfinder -d "$domain" -silent | sort -u > "$out/names.txt"
echo "$domain" >> "$out/names.txt"
: > "$out/resolved.txt"
while read -r name; do
  dig +short A "$name" | grep -E '^[0-9.]+$' >> "$out/resolved.txt" || true
  dig +short AAAA "$name" | grep -E '^[0-9a-f:]+$' >> "$out/resolved.txt" || true
done < "$out/names.txt"
sort -u "$out/resolved.txt" | comm -23 - "$out/targets.txt" \
  > "$out/resolved-not-in-manifest.txt"

# 3. IPv4: full TCP sweep, then version detection on what answered.
: > "$out/open.txt"
if [[ -s "$out/v4.txt" ]]; then
  naabu -list "$out/v4.txt" -p 1-65535 -rate 500 -silent > "$out/open-v4.txt"
  cat "$out/open-v4.txt" >> "$out/open.txt"
  cut -d: -f1 "$out/open-v4.txt" | sort -u | while read -r ip; do
    ports=$(awk -F: -v ip="$ip" '$1 == ip { print $2 }' "$out/open-v4.txt" | paste -sd, -)
    nmap -Pn -sV -p "$ports" -oN "$out/sv-$ip.txt" "$ip" < /dev/null > /dev/null
  done
fi

# 4. IPv6: a manifest that lists IPv6 the scanner cannot reach is a failure.
scanned="$out/expected.txt"
if [[ -s "$out/v6.txt" ]]; then
  if ip -6 route show default | grep -q .; then
    nmap -6 -Pn -sV -p- --open -oN "$out/sv-v6.txt" -oG "$out/v6.gnmap" \
      -iL "$out/v6.txt" > /dev/null
    awk '/Ports:/ { host = $2; sub(/.*Ports: /, "");
      n = split($0, p, ", ");
      for (i = 1; i <= n; i++) { split(p[i], f, "/");
        if (f[2] == "open") print "[" host "]:" f[1] } }' \
      "$out/v6.gnmap" >> "$out/open.txt"
  else
    echo "FAIL: manifest lists IPv6, scanner has no IPv6 route" \
      | tee "$out/IPV6-SKIPPED" >&2
    status=1
    grep -v '^\[' "$out/expected.txt" > "$out/expected-v4.txt" || true
    scanned="$out/expected-v4.txt"
  fi
fi

# 5. Diff against the manifest, for addresses actually probed.
sort -u -o "$out/open.txt" "$out/open.txt"
comm -23 "$out/open.txt" "$out/expected.txt" > "$out/unexpected.txt"
comm -13 "$out/open.txt" "$scanned" > "$out/expected-but-closed.txt"
echo "unexpected open (TCP): $(wc -l < "$out/unexpected.txt")" \
  "resolved-not-in-manifest: $(wc -l < "$out/resolved-not-in-manifest.txt")" \
  "raw output: $out/"
if [[ -s "$out/unexpected.txt" ]]; then status=1; fi
exit "$status"
external-selfcheck.sh — run only against addresses you hold, under written rules of engagement.

Read the exit status narrowly. Zero means that every TCP port which answered, on an address the script actually probed, is on the expected list. It says nothing about addresses missing from addresses.txt, and nothing about the hosts in resolved-not-in-manifest.txt, which is why that count is printed on every run rather than left in a file nobody opens. If the manifest lists IPv6 and the scanner has no IPv6 route, the run writes IPV6-SKIPPED and exits non-zero: a perimeter tested over one address family is not a pass.

The script is also TCP-only. naabu's default scan type is CONNECT and the nmap passes here are TCP scans, so the manifest's UDP 30000-32767 must_not_answer line is not tested at all. UDP needs its own test, slower and harder to read, and any report, yours or a commissioned tester's, should say whether one was run. A listed port that did not answer lands in expected-but-closed.txt only for address families that were actually scanned. On a probed address that means a broken service, a stale manifest, or a sweep that lost replies; a slower re-run separates the last from the first two.

Run it from a host outside every allowed source range, on a schedule, and keep each timestamped directory: the sequence of runs is the record of how the perimeter changed between commissioned tests. Per-deploy regression against a reviewed baseline is the automated penetration testing article's subject; this is the discovery front end.

Closing what the scan finds

Each fix below closes a side door at the layer that opened it, then the same script re-runs to show the port no longer answers. Adding a firewall rule alone repeats the original mistake: it describes intent on a path the packets were never taking.

Docker. Bind publishes to localhost behind the host's reverse proxy. Docker's port publishing page adds a boundary: in releases older than 28.0.0, hosts on the same L2 segment, such as the same switch, can still reach ports published to localhost. Upgrade first, or the bind leaves that path open.

yaml
services:
  grafana:
    image: grafana/grafana:13.2.6
    restart: unless-stopped
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "false"
    ports:
      - "127.0.0.1:3000:3000"
compose.yaml — Grafana reachable only through the host's reverse proxy.

The bind is the exposure control. The environment line restates Grafana's default, so that a later change turning it on shows up in review. Grafana's anonymous access is switched on by enabled = true under [auth.anonymous], with org_role setting what unauthenticated users get: Viewer, Editor or Admin.

Kubelet. Set both explicitly rather than trusting distribution defaults; with anonymous disabled, unauthenticated requests get 401.

yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
  anonymous:
    enabled: false
  webhook:
    enabled: true
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt
authorization:
  mode: Webhook
Fields to merge into each node's existing KubeletConfiguration, not a whole file. The CA path is kubeadm's; use your cluster's. Webhook authorization still relies on the kubelet's existing --kubeconfig.

NodePort. Limit the node addresses that NodePort Services answer on to the private node network. Put it in the KubeProxyConfiguration file: per the kube-proxy reference, the --nodeport-addresses flag is ignored when a config file is passed with --config, which is how most clusters run kube-proxy. On bare metal, MetalLB and kube-vip put public traffic on a LoadBalancer address instead.

yaml
apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: KubeProxyConfiguration
nodePortAddresses:
  - 192.0.2.0/24
A field to merge into the existing KubeProxyConfiguration. 192.0.2.0/24 is a documentation-range placeholder for your private node network: a wrong CIDR removes NodePorts from the addresses you meant to keep.

API server. Source-restrict 6443 and narrow anonymous access. The authentication reference documents anonymous conditions in an AuthenticationConfiguration file passed with --authentication-config, stable since v1.34 and first available in v1.31. If the file sets anonymous, you cannot also pass --anonymous-auth, so remove that flag in the same change. The same page is blunter about OIDC: --authentication-config together with any --oidc-* argument is a misconfiguration, and the API server reports an error and immediately exits. Move those issuers into the file as jwt entries and drop the flags before the restart, or the control plane does not come back. The file is the whole authentication config, not an anonymous-only overlay.

yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
anonymous:
  enabled: true
  conditions:
    - path: /livez
    - path: /readyz
    - path: /healthz
A stanza to merge into your existing AuthenticationConfiguration, not a file to apply as-is: replacing the file drops any jwt issuers it already carries. Upstream marks its own sample the same way.

With that in place, only the three health endpoints answer anonymous requests, so /version stops disclosing to a stranger. etcd should never be on the manifest's open list, and it should still refuse a client that reaches it: run it with --client-cert-auth and --trusted-ca-file, which per the etcd security guide makes every incoming HTTPS request present a client certificate signed by the trusted CA. An immutable node OS narrows drift further, per the Talos Linux security case.

What an external finding proves, and what it cannot

An external result is evidence about one packet path, from one vantage, at one time. A dirty result is strong: the port answered, whatever the ruleset says. A clean result is weaker: from that address, at that moment, with that scanner's timeouts, nothing unlisted replied.

  • It proves how the firewall, Docker's NAT rules, kube-proxy and the hosting edge combine, which a layer-by-layer review cannot.
  • It does not prove RBAC is tight or that NetworkPolicy contains a compromised pod. Those are internal claims.
  • It does not prove tomorrow. A NodePort created after the scan is not in the scan.

For an auditor: the manifest at its reviewed commit, raw output with timestamps and source address, and the diff. Reading a commissioned report against cluster state is covered in VAPT report evidence. If API-server audit logs reach a SIEM you run, as in Kubernetes audit logs into Wazuh, probes that reach 6443 should appear there, a check that detection matches exposure.

Failure modes that make the test lie

  • IPv4 only. A scanner with no IPv6 route reports clean while every AAAA record goes untested; ask any report lacking an equivalent of IPV6-SKIPPED. IPv6 prefixes are too large to sweep, so names and known addresses carry the test.
  • Scanning the CDN, not the origin. A CDN-fronted name resolves to the edge, testing the provider's perimeter, and -exclude-cdn probes only 80 and 443 there. Put origin addresses in scope from the allocation list.
  • Rate limits and drops. A sweep faster than the path tolerates loses replies silently, and lost replies read as closed. naabu's documentation also warns that raising -rate can increase false positives. The gate trusts naabu's open list; the nmap sv-* files are for a person to read, so a false open still fails the run. Slow down and repeat.
  • Scope that misses where things listen. Scoping only the ingress address misses node addresses where NodePorts and Docker publishes answer. Scope by allocation.
  • Allowed-source vantage. Scanning from a trusted office or VPN range makes expect: filtered exposures look open.

Exit ramps and the long game

Own the artefacts and the test stops depending on any one tester: plain YAML in your repository, open-source tools on any host you control, raw output in your storage rather than a vendor portal. Any competent tester can repeat the procedure and get a comparable answer, which makes the result evidence rather than opinion.

Set your own cadence for commissioned tests. Between them the manifest is the living document: every new public name, NodePort Service, published port or firewall change updates it in the same change that creates the exposure. The commissioned test then checks a maintained list, and its findings are differences from intent. That is the long game: the written intent outlives every scanner, tester and tool version that checks it.

§FAQ/Common questions

Frequently asked

What is external penetration testing?

Testing conducted from outside the organisation's security perimeter, usually as seen from the internet, per NIST SP 800-115. It typically starts with reconnaissance of public registration data and DNS, then enumerates external hosts and listening services. The tester starts without credentials.

How is an external test different from an internal one?

An internal tester is often given some network access, normally as a general user, and tests what a foothold can reach. An external tester tests what the internet can reach before any foothold exists. Lateral movement, in-cluster authorization and NetworkPolicy are internal questions.

Which Kubernetes ports should never answer from the internet?

By default etcd on 2379-2380, the kubelet on 10250, kube-scheduler on 10259, kube-controller-manager on 10257 and kube-proxy on 10256. The API server on 6443 and NodePorts in 30000-32767 should answer only where your manifest records an intended exception. Defaults can be overridden, so list your actual ports.

Why does a container port answer when ufw denies it?

Docker's documentation explains that with the iptables backend, published container traffic is routed in the nat table before it reaches the INPUT and OUTPUT chains ufw uses. Bind the publish to 127.0.0.1, and on Docker older than 28.0.0 upgrade first, because hosts on the same L2 segment can still reach localhost publishes.

Can I run the external scan myself before hiring a tester?

Yes, against addresses you hold and under written rules of engagement such as the SP 800-115 Appendix B template. Running the same discovery tools first means the commissioned test checks a maintained manifest rather than discovering your perimeter for you.

external penetration testingexternal penetration test scopepenetration testing programsexternal attack surface discovery self-hostedexposed kubelet port 10250docker published ports bypass ufw

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.