IngressNightmare: closing CVE-2025-1974 on a live cluster
A critical unauthenticated RCE in the ingress-nginx admission webhook, and how we closed it on a production Kubernetes cluster without dropping traffic.
- Kubernetes
- Security
- ingress-nginx
- CVE
- NetworkPolicy
$ cat content/posts/ingressnightmare-cve-2025-1974.md
CVE-2025-1974, nicknamed IngressNightmare, is a critical vulnerability in the ingress-nginx controller. It scored 9.8 on CVSS, and the reason for that score is simple: it allowed remote code execution on the controller pod with no authentication at all.
We were running ingress-nginx in production when it was disclosed. This is what the problem actually was and what we did about it.
What the vulnerability was
Ingress-nginx ships with a validating admission webhook. Its job is to check Ingress objects before the API server accepts them, so a malformed rule gets rejected instead of breaking the NGINX config for everyone.
That webhook listens on port 8443. In a correctly designed setup only one client ever needs to reach it: the Kubernetes API server.
The problem was that the webhook was reachable from anywhere with network access to it, not just from the API server. An attacker who could reach port 8443 directly could send it a crafted Ingress object. Parts of an Ingress spec end up in the generated NGINX configuration, and by injecting configuration directives through that path an attacker could get the controller to execute code.
The controller pod runs with permissions to read Secrets across namespaces in many default installs. Code execution there is not a contained problem, it is a route to the rest of the cluster.
The chain looks like this:
attacker
|
v
port 8443, admission webhook, reachable directly
|
v
crafted Ingress object, bypassing the API server
|
v
injected directives land in the NGINX config
|
v
code execution in the controller pod
The important detail is the third step. The webhook was never meant to be addressed directly. Everything downstream of that assumption held only because nobody was expected to talk to it.
What we did
The order mattered here. The exposure had to be closed before anything else, because an upgrade takes longer than a firewall change.
1. Confirm the exposure. Check which controller version was running and whether 8443 was actually reachable from outside the cluster, rather than assuming the default.
kubectl get pods -n ingress-nginx
kubectl get svc -n ingress-nginx
kubectl -n ingress-nginx get deploy ingress-nginx-controller \
-o jsonpath='{.spec.template.spec.containers[0].image}'
2. Cut off external access to 8443. This was the change that actually stopped the bleeding, done at the security group level so it took effect immediately and did not depend on anything inside the cluster being healthy.
3. Restrict the webhook to the API server with a NetworkPolicy. The security group handles traffic from outside. A NetworkPolicy handles the inside, so a compromised pod elsewhere in the cluster cannot reach the webhook either.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ingress-nginx-admission-restrict
namespace: ingress-nginx
spec:
podSelector:
matchLabels:
app.kubernetes.io/component: controller
policyTypes:
- Ingress
ingress:
- from:
# TODO: replace with the CIDR your API server actually calls from.
# Check your control plane's egress address before applying this.
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 8443
4. Upgrade the controller to a patched release. Blocking the port removes the reachable path, but the parsing bug is still in the binary until it is patched. The upgrade is a rolling update of the controller Deployment.
helm repo update
helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
-n ingress-nginx --reuse-values
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller
5. Verify. Confirm the port is no longer reachable from outside, confirm the running image is the patched version, and confirm ingress traffic is still being served.
Why this did not cause downtime
This is the part worth being precise about, because "zero downtime" is easy to claim and easy to get wrong.
The first three steps do not touch a running workload. A security group rule and a NetworkPolicy both operate on the network path into the webhook. The webhook is only consulted when an Ingress object is created or changed. No pod restarts, no NGINX reload, and no effect on traffic already being proxied to backends.
The upgrade in step four is the only step that replaces pods, and that is a rolling update: new controller pods come up and pass readiness before old ones are removed. Running more than one controller replica is what makes that safe. On a single-replica install there is a real gap, and that is worth knowing before you start rather than after.
So the sequencing is not arbitrary. The exposure closes in seconds with no disruption, which buys the time to do the upgrade carefully instead of under pressure.
What I took from it
Default configurations encode assumptions, and those assumptions are not always written down. The webhook being reachable was not a bug in our setup, it was the default, and it was fine right up until the moment a parsing flaw turned reachability into remote code execution.
The general lesson is not about this CVE. It is that anything listening should have an explicit answer to "who is allowed to talk to this", and that answer should be enforced rather than assumed. Every internal control plane component worth the name deserves that question asked of it before someone else asks it for you.
Note to self: fill in the exact ingress-nginx version that was running and the patched version it moved to, plus the API server CIDR used in the NetworkPolicy. Those specifics are what make this concrete for a reader who is checking your work.