CVE-2025-1974 — ingress-nginx Admission Webhook Unauthenticated RCE¶
Published 2026-06-06
Critical severity — unauthenticated remote code execution
An attacker with access to the cluster pod network can execute arbitrary code inside the ingress-nginx controller process. No authentication or cluster credentials are required.
| Field | Value |
|---|---|
| Project | ingress-nginx |
| Affected component | Admission webhook (CheckIngress handler), NGINX config template |
| Severity | CRITICAL |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (v3.1) |
| CWE | CWE-653 |
| Affected versions | < 1.11.5; >= 1.12.0-beta.0, < 1.12.1 |
| Fixed version | 1.11.5 (2025-03-25), 1.12.1 (2025-03-25) |
| Advisory | GHSA-mgvx-rpfc-9mpv |
1. Vulnerability overview¶
ingress-nginx is the most widely deployed Kubernetes ingress controller. In versions before 1.11.5 and 1.12.1, it exposes an admission webhook (a ValidatingWebhookConfiguration) that any pod-network peer can reach without credentials. This webhook is part of the "IngressNightmare" set of five related CVEs. CVE-2025-1974 is the critical one: an unauthenticated attacker can achieve arbitrary code execution inside the controller process. In default cluster configurations the controller holds read access to every Kubernetes Secret cluster-wide, so a successful exploit yields full cluster credential theft.
Root cause¶
The attack chains three weaknesses that together let a network-only attacker load an arbitrary shared library into the controller process.
Weakness 1 — unsandboxed nginx -t in the admission handler (internal/ingress/controller/controller.go, CheckIngress).
When a CREATE or UPDATE Ingress resource is submitted, the admission handler generates an NGINX config from the Ingress's annotations and calls nginx -t to validate it before any authentication check. A crafted AdmissionReview payload the handler accepts (with no credentials required) therefore causes the controller to run an NGINX config test on attacker-controlled configuration. The fix disables the call entirely:
// Before (vulnerable):
err = n.testTemplate(content)
if err != nil {
n.metricCollector.IncCheckErrorCount(ing.ObjectMeta.Namespace, ing.Name)
return err
}
// After (mitigated):
/* Deactivated to mitigate CVE-2025-1974
// TODO: Implement sandboxing so this test can be done safely
err = n.testTemplate(content)
if err != nil {
n.metricCollector.IncCheckErrorCount(ing.ObjectMeta.Namespace, ing.Name)
return err
}
*/
Weakness 2 — unquoted annotation values in the NGINX config template (rootfs/etc/nginx/template/nginx.tmpl).
Several Ingress annotation values were interpolated directly into the NGINX config template without quoting, so a newline character embedded in an annotation value can break out of the current config context and inject an arbitrary NGINX directive at the main context level. The vulnerable field used in this exploit is nginx.ingress.kubernetes.io/auth-tls-match-cn. The fix wraps the interpolated values with the quote filter:
- if ( $ssl_client_s_dn !~ {{ $server.CertificateAuth.MatchCN }} ) {
+ if ( $ssl_client_s_dn !~ {{ $server.CertificateAuth.MatchCN | quote }} ) {
- set $target {{ $externalAuth.URL }};
+ set $target {{ $externalAuth.URL | quote }};
- mirror {{ $location.Mirror.Source }};
+ mirror {{ $location.Mirror.Source | quote }};
Weakness 3 — file descriptor staging via mismatched Content-Length.
NGINX's client_body_in_file_only on setting causes request bodies to be spooled to a temporary file on disk. By sending an HTTP body whose actual size is smaller than the declared Content-Length, an attacker keeps the socket (and the spooled file) open indefinitely. The file stays accessible at a predictable path inside the controller pod (/tmp/nginx/client-body/<N>), reachable from the NGINX config-test context via path traversal (../../../../tmp/nginx/client-body/<N>).
With all three weaknesses in play: the attacker pre-stages a malicious shared library (.so) in the controller's temporary storage via the held upload, then sends an AdmissionReview with the auth-tls-match-cn annotation set to inject ssl_engine ../../../../tmp/nginx/client-body/<N>; into the generated NGINX config. When the pre-authentication nginx -t runs, NGINX calls ENGINE_by_id on this path, dlopens the staged .so, and fires its constructor, executing arbitrary attacker code inside the controller process.
2. Vulnerable environment¶
The reproduction uses a local Kubernetes cluster created with kind (Kubernetes-in-Docker). kind provides a genuine multi-process Kubernetes control plane on the host's Docker, including real network concurrency between the held fd-staging upload and the AdmissionReview webhook call, which is the timing property the exploit depends on. No cloud subscription is needed.
Stack summary:
| Component | Role | Version / Image |
|---|---|---|
| ingress-nginx controller | Vulnerable target; serves both the HTTP ingress listener (used for fd-staging) and the admission webhook | registry.k8s.io/ingress-nginx/controller:v1.11.4 |
| stage-backend | Benign upstream (stock nginx) so the stage.local ingress has a live endpoint and NGINX enters the body-read phase |
nginx:1.27.3-bookworm |
| kind control plane | Kubernetes API server + cluster networking; backs the webhook | kind cluster cve-2025-1974 |
The controller is exposed on the host at 127.0.0.1:8080 (HTTP ingress listener) and 127.0.0.1:8443 (admission webhook). The exploit client uses only these two endpoints; it holds no kubeconfig and no cluster credentials.
A pre-seeded Kubernetes Secret ingress-nginx/ingressnightmare-auth-ca (key ca.crt) acts as the render gate: ingress-nginx v1.11.4's authtls.Parse() resolves the auth-tls-secret annotation by calling getPemCertificate(), which requires the referenced secret to carry a ca.crt key. The MatchCN field (carrying the injected ssl_engine directive) is only rendered into the NGINX config when the secret resolves successfully. The attacker merely names this secret; it grants no credential.
Environment files are available as env.zip. Individual files:
- env/up.sh — creates the kind cluster, installs ingress-nginx v1.11.4, seeds the auth-tls secret, and stands up the staging backend
- env/down.sh — tears the cluster down
- env/smoke.sh — quick sanity check
- env/positive-control.sh — two-part chain-adequacy probe (see below)
- env/manifests/ — Kubernetes manifests
Standing the environment up:
Verifying the environment is the vulnerable one:
Run the smoke test:
SMOKE OK confirms all four conditions:
- Controller is pinned to
v1.11.4and Ready. - Admission webhook on
127.0.0.1:8443returns HTTP 400 to an empty unauthenticated POST (the webhook is live). stage.localingress returns HTTP 200 with a live backend endpoint (the HTTP listener is in the body-read phase, so fd-staging will spool bytes to disk).- The
ingress-nginx/ingressnightmare-auth-casecret with keyca.crtis present (render gate is open).
For full chain adequacy, run:
This two-part probe verifies the timing and rendering conditions independently of the exploit:
- Part A confirms that a held over-stated-Content-Length upload spools a client-body file to
/tmp/nginx/client-body/<N>inside the controller pod, that the file remains resident while a concurrent webhook request is in flight, and that it is reachable from the NGINX config-test context via the../../tmp/nginx/client-body/<N>traversal path. - Part B submits an
AdmissionReviewthat injects a harmless sentinel directive (no attacker code loaded) through the sameauth-tls-match-cnbreakout path and asserts the webhook returnsallowed:falsewith the sentinel named in thenginx -terror output, confirming the injected text reaches the NGINX config parser.
Both parts must return PASS before running the exploit.
3. How to exploit¶
Exploit files are available as exploit.zip. Individual files:
- exploit/run.sh — orchestrating entry point
- exploit/build_so.sh — compiles the
.sopayload inside an Alpine container - exploit/engine.c — the shared library source; its constructor writes the nonce to the marker path
- exploit/run.py — manages the held uploads and sweeps
AdmissionReviewrequests
Steps¶
Step 1. Confirm the environment is ready.
Both must pass before proceeding.
Step 2. Choose a fresh nonce and marker path.
The nonce is a per-attempt string chosen by the verifier. It is baked into the compiled .so constructor, so only code that successfully dlopend that exact shared library inside the controller can write it. The marker path must be writable by the controller process (uid 101 / www-data).
# Example (use a fresh value each attempt):
NONCE="verify-1780300425-11267"
MARKER_PATH="/tmp/${NONCE}"
Step 3. Run the exploit.
Run the exploit with KUBECONFIG unset to enforce the unauthenticated network-only actor constraint.
env -u KUBECONFIG bash exploit/run.sh verify-1780300425-11267 \
/tmp/verify-1780300425-11267 ingress-nginx/ingressnightmare-auth-ca
The three arguments are:
<NONCE>— the per-attempt string to write into the marker file.<MARKER_PATH>— the controller-pod-local path where the nonce will be written.<SECRET_REF>— the referenceableauth-tls-secretthat opens the render gate. Useingress-nginx/ingressnightmare-auth-caexactly as pre-seeded byenv/up.sh.
An optional fourth and fifth argument (N_MIN N_MAX) control the brute-force range for the client-body counter N (default 0 200).
What the exploit does internally:
build_so.shcompilesengine.cinside analpine:3.20 --platform linux/arm64container (the controller runs musl/aarch64). The compiled shared library's__attribute__((constructor))writes<NONCE>to<MARKER_PATH>ondlopen.run.pyopens 6 concurrent HTTP uploads tohttp://127.0.0.1:8080withHost: stage.local, sending the full.sobody but declaring a largerContent-Lengthand holding the connections open for ~40 seconds. NGINX spools the body bytes to/tmp/nginx/client-body/<N>files inside the controller pod.run.pysweeps N from 0 to 200, posting anAdmissionReviewfor each candidate N. Theauth-tls-match-cnannotation value breaks out of the NGINX configifblock and injectsssl_engine ../../../../tmp/nginx/client-body/<N>;at the main context level. The pre-authenticationnginx -treaches the directive, callsENGINE_by_id, anddlopens the file at that path. When N matches an active staged file, the constructor fires and writes the marker.
The exploit does not read the marker back. It exits 0 when all 201 candidate N values have had their AdmissionReview processed.
Step 4. Verify via the independent privileged channel.
The verifier reads the marker through a kubectl exec into the controller pod, a channel the exploit cannot reach because it runs with no kubeconfig.
KUBECONFIG=env/kubeconfig kubectl -n ingress-nginx exec \
"$(KUBECONFIG=env/kubeconfig kubectl -n ingress-nginx get pod \
-l app.kubernetes.io/component=controller \
-o jsonpath='{.items[0].metadata.name}')" \
-- cat /tmp/verify-1780300425-11267
KUBECONFIG=env/kubeconfig kubectl -n ingress-nginx exec \
"$(KUBECONFIG=env/kubeconfig kubectl -n ingress-nginx get pod \
-l app.kubernetes.io/component=controller \
-o jsonpath='{.items[0].metadata.name}')" \
-- stat -c 'uid=%u gid=%g owner=%U path=%n' /tmp/verify-1780300425-11267
What proved it worked¶
In the verified run, the marker file was absent before the exploit:
After the exploit ran (network-only, KUBECONFIG unset), the verifier's kubectl exec read the file and found:
The stat call confirmed:
The controller's own id returned uid=101(www-data). The file is owned by the controller process identity. The marker value matched the nonce chosen for this attempt (verify-1780300425-11267), which differs from the example nonce baked into the exploit source (verify-1780300131-13064), ruling out any hard-coded or replayed value. The marker was written solely by the .so constructor executing inside the controller during nginx -t, which is the exact code path that the 1.11.5 and 1.12.1 patches remove.
This observation is independent of the exploit. The exploit has no pod or node access, runs no kubectl, and cannot write to the controller's /tmp directly. The only path from the nonce to the marker file is through the vulnerable nginx -t → ENGINE_by_id → dlopen → constructor chain.
Environment teardown¶
Or equivalently:
This deletes the kind cluster cve-2025-1974 and removes the generated env/ca/ material.
4. Security advice¶
Remediation¶
Upgrade to ingress-nginx 1.11.5 or 1.12.1, both released 2025-03-25. Patch commit e6716b13f237fb42a05117784fdee004e74fc801 (PRs #13069 and #13070 in kubernetes/ingress-nginx) addresses all five IngressNightmare CVEs in a single change. For CVE-2025-1974 specifically, it disables the pre-authentication nginx -t call in the admission handler and adds quoting for all annotation values that were interpolated unquoted into the NGINX config template.
If an immediate upgrade is not possible, restrict network access to the admission webhook service (port 8443) to the Kubernetes API server only, so that arbitrary pod-network peers cannot submit crafted AdmissionReview requests directly to the controller.
Mitigations¶
- Restrict webhook reachability. The webhook (port 8443 on the controller service) should be reachable only from the API server, not from arbitrary pods. Enforce this with a Kubernetes
NetworkPolicylimiting ingress to the controller's service port to the API server's address range. - Set
--annotations-risk-level. Newer ingress-nginx versions support the--annotations-risk-levelflag. Setting it to block unknown or high-risk annotations narrows the injection surface, though this flag is not a substitute for patching. - Minimize Secret access. In the default installation, the ingress-nginx controller can read all Secrets cluster-wide. Scoping its RBAC to only the namespaces it needs limits the blast radius if code execution is achieved.
References¶
- NVD CVE-2025-1974
- GHSA-mgvx-rpfc-9mpv — affected/fixed versions, credits
- ingress-nginx release v1.11.5
- ingress-nginx release v1.12.1
- Patch commit e6716b13
- hakaioffsec/IngressNightmare-PoC — Python PoC with
lib_template.c - Esonhugh/ingressNightmare-CVE-2025-1974-exps — Go PoC, multiple injection methods
- Exploit-DB 52475 — EDB-verified Python exploit
- NetApp advisory NTAP-20250328-0008