BrickellTechnologies

Research

CVE-2026-96615: Cross-Namespace Agent Impersonation in NGINX Gateway Fabric

Elias Hasas · ·7 min read

In NGINX Gateway Fabric 2.0.0 through 2.7.x, a Pod in one namespace can connect to the control plane, claim to be the nginx data plane for a Gateway in another namespace, and receive that Gateway’s configuration files. Those files include the TLS private key for every HTTPS listener on the Gateway.

We reported the issue to the maintainers in September. F5 published it today as CVE-2026-96615, with the vendor advisory at K000162610. It carries a CVSS 4.0 score of 7.1 (High). Version 2.8.0 contains the fix, and F5 lists no workaround.

Who is affected

You’re exposed if you run an affected version and more than one team can create Gateways or run Pods in the cluster. Giving each team its own namespace and its own Gateway is the standard Gateway API multi-tenancy model, so this doesn’t require an unusual setup. Clusters where a single team owns every Gateway and nobody else can schedule Pods carry much less risk, though they should still upgrade.

To check which version you’re running:

kubectl get deploy -A -l app.kubernetes.io/name=nginx-gateway-fabric \
  -o jsonpath='{range .items[*]}{.spec.template.spec.containers[*].image}{"\n"}{end}'

How the agent connection works

NGF has two halves. The control plane turns Gateway API resources into nginx configuration, and the data plane runs one nginx Deployment per Gateway. Each data plane Pod runs the NGINX Agent, which connects to the control plane over gRPC, identifies the Deployment it belongs to, and receives that Deployment’s files.

Two checks protect that connection. The agent must present a client certificate signed by NGF’s CA, and it must send a ServiceAccount token. A gRPC interceptor validates the token with the Kubernetes TokenReview API, confirms the audience, reads the system:serviceaccount:<namespace>:<name> identity, and verifies that a running Pod uses that ServiceAccount.

Both checks work as intended; the problem is what happens after them.

How we found it

We started with the interceptor, validateToken in internal/controller/nginx/agent/grpc/interceptor/interceptor.go. It validates the token correctly, but the only values it passes along in the gRPC context are the caller’s UUID header and the raw token. It discards the namespace and name it has just verified.

That raised the obvious question of how the control plane decides which Deployment a connection belongs to. The answer is in commandService.CreateConnection, which calls getAgentDeploymentNameAndType(resource.Instances). That function reads the owner-name and owner-type labels from the AgentConfig message the client sends. A client can write owner-name=victim_vgw-nginx and the control plane accepts it, because nothing compares that value with the ServiceAccount the interceptor checked.

An attacker would still need a valid client certificate. We expected that to be the hard part, and it wasn’t. When NGF provisions a Gateway, buildNginxSecrets in internal/controller/provisioner/objects.go copies the agent TLS secret into the Gateway’s namespace as <gateway>-nginx-agent-tls. The whole cluster shares one CA and one client certificate (CN=nginx-gateway, SAN *.cluster.local), and the server only checks that the certificate chain is valid. Anyone who can create a Gateway in their own namespace therefore receives the same certificate every other tenant’s data plane uses. The certificate is also mounted at /var/run/secrets/ngf in every nginx Pod, and those are the Pods that face the internet.

Before building a test environment, we checked for prior work. Issue #5421, opened in June, and PR #5626, merged in July, had already hardened this code. They bound the token to a specific Pod and rejected conflicting owner labels, but both dealt with Pods in the same namespace. Neither considered a caller claiming a Deployment in a different namespace, and 2.7.0 shipped after that PR with the gap intact.

Reproduction

We wanted to confirm the issue on the released version with default settings. The test environment was a kind cluster running Kubernetes v1.32.2, the Gateway API v1.4.0 standard CRDs, and the official Helm chart:

helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
  -n nginx-gateway --version 2.7.0

We created two tenants, each with a Gateway and an HTTPS listener, and put a marker in each certificate’s subject so the two keys couldn’t be confused:

Namespace / GatewayListenerCertificate subject
victim/vgwvictim.example.comCN=MARKER-VICTIM-PRIVATE-KEY
attacker/agwattacker.example.comCN=MARKER-ATTACKER-PRIVATE-KEY

The attacking Pod ran in the attacker namespace under a ServiceAccount named evil with no RBAC permissions:

$ kubectl auth can-i get secrets -n victim --as=system:serviceaccount:attacker:evil
no

The Pod mounted the agw-nginx-agent-tls secret that NGF had placed in its namespace, plus a projected token with the audience the control plane expects (ngf-nginx-gateway-fabric.nginx-gateway.svc). A short Go client then:

  1. connected to ngf-nginx-gateway-fabric.nginx-gateway.svc:443 with the copied certificate,
  2. sent CreateConnection with owner-name=victim_vgw-nginx and owner-type=Deployment,
  3. called Subscribe and waited for the file list,
  4. called FileService.GetFile for each file.
[*] CreateConnection asserting owner-name="victim_vgw-nginx" owner-type="Deployment"
[+] CreateConnection accepted: COMMAND_STATUS_OK
[+] UpdateDataPlaneStatus accepted
[*] Subscribed, waiting for the victim deployment's ConfigApplyRequest ...
[+] Received file overview for the impersonated deployment: 14 files

=== FILE /etc/nginx/secrets/ssl_keypair_victim_victim-tls.pem ===
-----BEGIN CERTIFICATE-----
... CN = MARKER-VICTIM-PRIVATE-KEY ...
-----END CERTIFICATE-----
[private key redacted: 2048-bit RSA, byte-identical to victim/victim-tls]

The control plane logged the connection as routine:

"Creating connection for nginx pod: poc-pod"
"Successfully connected to nginx agent","Deployment":{"name":"vgw-nginx","namespace":"victim"}

poc-pod runs in the attacker namespace, and no Pod with that name exists in victim.

To confirm the cause, we reran the same client and Pod spec, changing one variable at a time:

  • With owner-name=attacker_agw-nginx, the client received the attacker’s own key and nothing from victim, which shows the label alone selects the tenant.
  • Without the client certificate, the connection failed with tls: certificate required.
  • With a fake token, and again with a valid token issued for the wrong audience, the server returned Unauthenticated: invalid authorization.

The certificate check and the token check each did their job. What was missing was a comparison between the caller’s verified identity and the Deployment it asked for.

Impact

The CVE describes the primary impact: disclosure of TLS private keys belonging to Gateways in other namespaces. An attacker holding a Gateway’s key can impersonate its hostnames to any client whose traffic they can intercept, and those clients won’t see a certificate warning. The key remains compromised until it is rotated; upgrading NGF does not change that.

The private key isn’t the only thing exposed. An impersonating agent receives every file the control plane sends to the victim’s data plane (14 in our test). Depending on the Gateway’s configuration, that can include:

  • the certificate and private key for every HTTPS listener,
  • htpasswd files generated for basic-auth AuthenticationFilters, which are open to offline cracking,
  • files generated for JWT and OIDC authentication filters,
  • the complete nginx.conf, which maps out the victim’s backend services, routes and authentication setup.

We also found a second issue that F5’s score doesn’t cover. Subscribe takes a read lock on the Deployment’s FileLock and holds it while the initial configuration is applied, and waitForInitialConfigApply has no timeout. A fake agent that receives the configuration and never replies holds the lock indefinitely. That blocks the write lock the event handler needs at internal/controller/handler.go:305, and the same event handler serves every Gateway in the cluster.

We measured the effect with a third, unrelated tenant:

StateTime for a new HTTPRoute to reach the data plane
No one holding the lockabout 1s
Fake agent holding the lockvictim/vgw still missing after 60s; bystander/bgw still missing after 150s
Fake agent’s Pod deletedboth applied within about 1s

For as long as the fake agent stays connected, no team in the cluster can deploy a route change or a certificate renewal.

Our first run of this test came back negative. Our own client had a 90-second timeout on the Subscribe call, so it released the lock early; the numbers above come from the run after we corrected it.

We rated the issue higher than F5 did, largely because of this availability impact. F5’s published score covers confidentiality only. We haven’t verified whether 2.8.0 also adds a timeout to waitForInitialConfigApply, so if this behavior matters in your environment, test it after upgrading.

The fix

The provisioner already names each nginx Deployment’s ServiceAccount after the Deployment itself and builds owner-name as <namespace>_<name>, so for a legitimate agent the verified ServiceAccount and the claimed owner always match. Our proposed fix was to carry the namespace and name from validateToken through to CreateConnection and reject any connection where they differ from the claimed owner. That single comparison prevents one tenant from connecting as another.

We also recommended two hardening changes: a timeout on the initial configuration apply, so one unresponsive agent can’t hold the lock, and a separate client certificate per Deployment in place of one shared certificate copied into every namespace.

Recommendations

Upgrade to NGINX Gateway Fabric 2.8.0.

If other teams or workloads could create Gateways or run Pods in the cluster while you were running 2.0.0 through 2.7.x, rotate your Gateway TLS certificates. Rotate any credentials referenced by AuthenticationFilters as well, such as basic-auth passwords and OIDC client secrets. There’s no reliable way to prove after the fact that they weren’t read.

To look for past abuse, search the control plane logs for Creating connection for nginx pod: <name> and confirm that the named Pod exists in the namespace of the Deployment reported on the following line. A mismatch is the signature of this attack.

Until each Deployment has its own client certificate, treat permission to create a Gateway as a sensitive privilege, since it also hands out the shared agent certificate.

Disclosure timeline

  • 7 September 2026: reported privately to the NGINX Gateway Fabric maintainers
  • 30 September 2026: CVE-2026-96615 reserved by F5
  • 14 October 2026: NGINX Gateway Fabric 2.8.0 released; CVE-2026-96615 and K000162610 published

Thanks to the NGINX Gateway Fabric maintainers and F5 for the fix and the coordinated release.

References

If you run shared Kubernetes clusters and want to know where your tenant boundaries actually hold, that’s part of our penetration testing work.

Know what an attacker would reach first.

Tell us what you run and what worries you. We will come back with a scope, a fixed price and the earliest week we can start.