Some vulnerabilities are one mistake. This one is two, in two different programs, written by two different people, and each of them is individually harmless. The gateway trusts a certificate it never verifies, which is fine because only the load balancer can set that header. The load balancer strips that header, which is fine because it strips the header. Both statements were true. They were just about different headers.
That is CVE-2026-80462, in Progress Chef Automate. It scores CVSS 10.0, the vendor advisory is three sentences long and names no endpoint, no parameter and no header, and the fix is not in the public source tree. What follows is the whole thing recovered from two stripped Go binaries and an nginx config, then confirmed against a live deployment.
If you don’t live in this corner of the world, the thirty-second version. Chef Automate is the control plane for Chef, the configuration-management system. Chef’s job is to decide what software is installed and how it is configured on every server in a fleet, then make every server match that description, continuously. Automate is the dashboard and API in front of that: compliance reporting, node inventory, scan jobs, and the identity system governing who may change any of it. It is the thing you put on port 443 so your team can look at the fleet, which means it is the thing that ends up on the internet.
So the reason this matters is not that a web app had an auth bug. It is that the web app is the steering wheel for every managed machine behind it. Chef pushes configuration; configuration is arbitrary code by definition. Anybody who becomes an administrator of Automate is, on a short delay, root on everything Automate manages.
The interesting part is not the missing certificate check. It is that the vendor had already thought about this exact attack, written a careful comment about it, and defended the wrong header name.
And the finding with the longest legs is not the bug at all. It is that on this CVE every version-based way of establishing whether you are affected is broken: the product’s own version endpoint, the vendor’s CLI, and the affected range in the advisory — the last of which does not survive a look at the vendor’s own package registry, where builds a year older than the advisory’s floor carry both halves of the vulnerability unchanged. Behavioural testing is not the thorough option here, it is the only option, which is why the detection rubric in this post is longer than the exploitation narrative.
A note on jargon. This (again) is written for people who read advisories for fun, so it does not stop to define things as it goes. There is a keywords section at the bottom that explains the lot in plain language, in roughly the order you’ll hit it.
Everything here was reproduced against binaries on a local workstation and then against a Chef Automate instance the operator deployed, licensed and owns. No third-party Automate was contacted at any point. The exposure numbers later in the post come from passive scanner indexes — Censys and FOFA — and nothing in that section touched a host.
The short version
| Field | Value |
|---|---|
| CVE | CVE-2026-80462 |
| CVSS v3.1 | 10.0 CRITICAL, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| Product | Progress Chef Automate |
| Affected (per vendor) | 4.13.516 – 4.13.519, “versions prior to 4.13.516 are NOT affected” |
| Affected (measured) | The missing verification and the non-stripping nginx are both present in vendor packages dated 2025. The lower bound does not hold — see the version section |
| Fixed | 4.13.520 |
| Class | CWE-306, missing authentication for critical function |
| KEV | Not in CISA KEV, not in VulnCheck KEV as of 2026-09-13 |
| Prior art | None found before this post. No GitHub repo, no nuclei template, no Metasploit module, no Exploit-DB entry, no technical writeup. This post ships a template; nothing existed before it |
| Root cause | crypto/x509.ParseCertificate where (*Certificate).Verify was needed, plus an nginx header allowlist that missed two header names |
S:C — scope changed — is doing real work in that vector again. The vulnerable
component is the API gateway. The impacted component is every machine Chef
manages.
And a finding the vendor’s own table does not contain, and the reason for the block immediately below: the live instance we confirmed this on reported a build number below the stated affected range. Do not scope your remediation from that table.
If you run Chef Automate, start here
The reversing is further down and it is the fun part. This is the part that is time-sensitive, so it goes first. Full reasoning for every line of it is in the detection rubric.
Do not triage this by version. Not by /api/v0/version, not by the
chef-automate CLI, and not by the advisory’s affected range. Each of those fails
for a different reason, all three are documented later, and the consequence is
that the only reliable question is behavioural.
The test is two requests, from wherever your monitoring already reaches your own instances:
A (baseline): GET /api/v0/telemetry/config
B (probe): GET /api/v0/telemetry/config
Grpc-Metadata-Grpcgateway-X-Client-Cert: <url-escaped self-signed PEM>
| A | B | verdict |
|---|---|---|
401 + "code":16 | 200, no error code | Vulnerable, and the forged identity is authorized |
401 + "code":16 | 403 + "code":7 | Vulnerable. Identity accepted, this action refused |
401 + "code":16 | identical to A | No signal, which is not the same as patched |
The path has to be one of /api/v0/…, /apis/…, /data-collector/v0 or
/compliance/profiles/, the certificate has to be URL-escaped PEM, and the header
has to be the Grpc-Metadata- prefixed spelling. Get any of those wrong and you
get a clean-looking false negative. All four traps are explained in §0.
Then the seven steps:
1. Patch to 4.13.520+. (does not evict anyone)
2. Strip Grpc-Metadata-* at your edge. (breaks the chain independently)
3. Behavioural test, two requests, per instance. (version numbers lie here)
4. Hunt audit.log: 200 + all identity fields empty + request_length > 1000.
5. Audit IAM: tokens, local users, admins team, CUSTOM owner policies.
6. Rotate: internal certs and CA, all tokens, everything in secrets inventory.
7. Take the gateway off the public internet if it has no reason to be there.
Steps 4 and 5 are the ones people skip, and they are the only two that answer “were we already compromised”, because credentials created through this bypass are ordinary Automate credentials that keep working after you patch.
What we’re even looking at
Chef Automate ships as a Habitat supervisor running about twenty services on one box. Two of them matter here.
automate-load-balancer is nginx with a Lua audit module bolted on. It
terminates TLS on 443, serves the UI, and reverse-proxies the API to the
gateway. It is the only component a stranger can reach.
automate-gateway is a Go binary, mostly grpc-gateway generated code. It
translates REST into gRPC and fans out to the other eighteen services
(authn-service, authz-service, deployment-service, secrets-service,
compliance-service and friends). Everything internal speaks mTLS with
certificates issued by the deployment’s own CA.
Automate accepts three kinds of caller identity:
- a browser session, minted by dex, carried as a JWT;
- an API token, carried in an
api-tokenheader; - a service identity, proven by a client certificate.
The third one is the subject of this post, and it has a quirk. Internal services that dial the gateway directly present their certificate in the TLS handshake, which is unremarkable. But nginx also needs a way to tell the gateway “the request I am forwarding came from a caller who presented this verified client certificate”, and TLS cannot express that across a proxy hop. So nginx copies the verified peer certificate into an HTTP header, URL-escaped, and the gateway reads the identity out of the header.
That is a perfectly ordinary pattern. It is also the pattern that eats people, because the header is only trustworthy if the thing in front of you guarantees a client could never have set it themselves.
Which nginx did guarantee. For one spelling of the header.
Why we picked it up
Seven-day freshcve --unauth --min-cvss 9 pull. The usual filter: critical,
pre-auth, network-reachable, something a customer actually runs facing the actual
internet. Libraries, CMS plugins and desktop apps get binned.
This one won on three things.
Product class. Config-management control plane. The gateway is the product’s front door, compromise is fleet-wide by design, and there are hundreds of them indexed on 443 — unlike the last candidate, where the exposure question was mostly philosophical.
The advisory is almost empty. Here is the vendor’s description, in full:
A vulnerability in the Chef Automate API gateway and identity validation path may allow an unauthenticated actor to gain elevated access to protected Chef Automate functionality under specific conditions.
No endpoint. No parameter. No header. No hint about what “specific conditions” means. That thinness is exactly why nobody had touched it, and it is the work the diff has to do.
Both artefacts are downloadable without a licence, which took some checking — see the next section.
Has somebody already done this?
All channels checked 2026-09-13. This table decays faster than anything else in the post, so treat the date as part of the claim.
| Channel | Method | Result |
|---|---|---|
| GitHub | search API, q=CVE-2026-80462 | 0 repos |
| nuclei-templates | http/cves/2026/ tree | absent |
| Metasploit | framework master, commit search | nothing |
| Exploit-DB / Sploitus | full CSV plus local searchsploit | no hits |
| CISA KEV | current catalogue | not listed |
| Web search | — | CVE-aggregator spam only. OpenCVE, THREATINT, strix.ai, offseq, securityonline. Zero technical analysis |
No PoC, no template, no writeup, no reversing. Standard caveat, which belongs in every novelty section and appears in almost none of them: absence of a public PoC is much weaker evidence than presence of one. We checked commodity channels. Private tooling does not file GitHub issues about itself.
Finding the diff boundary
The first question on any patch diff is which two artefacts to compare, and the obvious answer here is wrong in a way that would have cost a day.
Chef publishes chef-automate_linux_amd64.zip per version on
packages.chef.io. Pull 4.13.519 and 4.13.520 and diff them, obviously.
4.13.519 ETag ac8e303387a5acccb7198999eca164e5-3 20868640 bytes
4.13.520 ETag ac8e303387a5acccb7198999eca164e5-3 20868640 bytes
Byte-identical. Same ETag, same length. That zip is the chef-automate CLI,
not the product, and the CLI did not change. The URL is version-aware — 4.13.516
returns a different ETag and a bogus version 404s — so this is a real identity,
not a redirect quietly serving one file. The fix is simply not in the CLI.
There is a second consequence of that fact which matters far more than the wasted diff, and we’ll get to it in the detection section: the CLI cannot pin your version either.
The product ships as Habitat packages, and chef/automate-gateway is public on
Habitat Builder — 3172 releases, visibility: public, no auth, no licence, no
entitlement check. Its newest build timestamp matched the 4.13.520 release date
exactly.
| Side | Habitat ident | Built |
|---|---|---|
| Vulnerable | chef/automate-gateway/0.1.0/20260703061149 | 2026-07-03 |
| Patched | chef/automate-gateway/0.1.0/20260824053200 | 2026-08-24 |
https://bldr.habitat.sh/v1/depot/pkgs/chef/automate-gateway/0.1.0/20260824053200/download?target=x86_64-linux
Seven weeks between builds. Tractable.
The catch: this is a binary diff, not a source diff. github.com/chef/automate
is Apache-2.0 and public, but its last push predates the fix by two months, and
the two binaries’ embedded main-module commits — 8c81619… and 75664c2… — do
not resolve on GitHub at all. The fix was developed in an internal tree and
published only as compiled packages.
Mitigating factors: Go binaries retain function names in .gopclntab even when
stripped, both sides were built with go1.26.3, and the public repo at its
pre-fix state is a usable map for orienting inside the vulnerable binary. Which
turned out to be more than enough.
The diff
Unpack both Habitat packages. 47 files each: the binary, Swagger UI assets,
configs, lifecycle hooks, default.toml.
Exactly one file differs. bin/automate-gateway, 32,046,744 → 32,384,408
bytes. Everything else is byte-identical, which is already informative: no config
default changed, no new asset, no new hook. Whatever happened, happened in code.
Then the narrowing, in three steps, each of which removes an entire category of possibility.
Step 1: symbol inventory. 24,836 Go symbols on each side. Zero added, zero
removed. 24,209 functions belonging to chef/automate itself, on both sides.
Zero added, zero removed.
So: no new function, no deleted function, no renamed function. A 337 KB size increase with no new symbols means existing functions got longer.
Step 2: string inventory. Auth-relevant literal counts, both sides:
/api/v0 457/457, authn 393/393, authz 1976/1976, AuthenticateRequest
13/13. No new route, no new error message class, no new config key.
Step 3: per-function sizes. .gopclntab is the table Go keeps so it can
produce a stack trace, and it survives stripping. It gives you every function’s
name, entry address and end address, which means it gives you every function’s
size. Parse both, join on name, subtract.
Five changed functions out of 24,209. All five in the auth middleware.
| delta | function |
|---|---|
| +832 (512 → 1344) | gateway/middleware.subjFromCertString |
| +64 | gateway.(*Server).newGRPCServer |
| +32 | type:.eq…middleware.authInterceptor (the struct’s equality function — the struct gained a field) |
| +32 | (*authInterceptor).StreamServerInterceptor.func5 |
| +32 | middleware.headerAuthValidForClientAndPeer |
That is the whole patch. One function tripled in size, and four others moved by a single field or argument. A 24,209-function haystack reduced to five needles by arithmetic, with no disassembly yet.
What the big one now calls
Function sizes tell you where. Callee sets tell you what. Walk the call
instructions in each version of subjFromCertString and resolve the targets
through .gopclntab:
Vulnerable (0xdb1360–0xdb1560):
net/url.unescape
encoding/pem.Decode
crypto/x509.ParseCertificate
chef/automate/lib/grpc/service_authn.ServiceSubjectFromCert
runtime.stringtoslicebyte, logrus.(*Logger).WithField, logrus.(*Entry).Log
Patched (0xdda3a0–0xdda8e0) — the same, plus:
crypto/x509.(*Certificate).Verify <-- THE FIX
runtime.newobject, mapassign_faststr, convTstring, gcWriteBarrier2
logrus.(*Entry).WithFields, logrus.(*Logger).Log
The pre-patch function unescapes a string, PEM-decodes it, parses the result as an X.509 certificate, derives a service identity from it, and returns.
crypto/x509.ParseCertificate does exactly what its name says. It parses. It
decodes ASN.1 into a struct. It does not check a signature, does not consult a CA
pool, does not look at validity dates, does not care who issued the thing. It is
the difference between reading a badge and confirming your HR department issued
it.
The patch adds (*Certificate).Verify, which is the chain-validation call that
needs a root pool. That single addition explains all five deltas: the function
grew 2.6x because verification and its error path are real code; the
authInterceptor struct gained a field to hold the pool; newGRPCServer grew to
build and thread it in; the extra logrus calls are audit logging on the new
reject path.
CWE-306, landing precisely where the vendor said it would: “the API gateway and identity validation path”.
What the certificate buys you
The header value is a certificate. The thing derived from it is a subject string, and the gateway validates subjects against this regex, present verbatim in both binaries:
^(?:(?:team|user):(?:local|ldap|saml)|token|tls:service:[^:*]+):[^:*]+$
So there are four kinds of principal in Automate: users, teams, tokens, and
tls:service:<name> — internal services. ServiceSubjectFromCert (576 bytes,
unchanged by the patch) is what maps a parsed certificate to one of those
service subjects. The flaw was never in subject derivation. It was in the
complete absence of a trust check before it.
The subject format, read straight out of the vulnerable gateway’s own debug log in our lab:
auth.subjects="[tls:service:deployment-service:c5d4f1643e653c2600d4cbb5b33213ca…]"
tls:service:<certificate CN>:<certificate fingerprint>. The CN comes from the
certificate, so the attacker chooses it. deployment-service is the most
privileged identity in the deployment, and the gateway tests elsewhere for the
literal prefix tls:service:deployment-service:.
Pick a name, generate a certificate claiming it, sign it yourself. That is the first half.
The second half, which is where it gets good
A missing verification on a header nobody can set is not a vulnerability. It is a latent one. The entire severity of this CVE rests on whether a stranger on the internet can get that header to the gateway.
The load balancer is also a public Habitat package, so instead of deploying
anything we read its nginx config. Here is the pre-patch file, at the http
level, unmodified and complete with its own commentary:
# By default, we drop the X-Client-Cert header so a user cannot set it.
# If a route wishes to allow it, they must use:
# proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
#
# NOTE: this behavior of nginx `proxy_set_header` directives:
# > [`proxy_set_header`] directives are inherited from the previous level if
# > and only if there are no proxy_set_header directives defined on the
# > current level.
# Thus, if you add any `proxy_set_header` to one of the `location`s below,
# you must do *something* with the `X-Client-Cert` header.
# Don't mess it up
proxy_set_header X-Client-Cert "";
The author knew the attack. They knew a client must not be able to
set that header. They knew about nginx’s inheritance trap, where adding any
proxy_set_header to a location block silently discards every inherited one.
They wrote “Don’t mess it up” as a warning to their future colleagues.
And in the four location blocks that forward the real certificate, they get it
exactly right:
location /api/ {
rewrite /api/v1/(.*)$ /api/v0/$1 break;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
proxy_pass https://automate-gateway;
...
}
Four blocks, four correct overrides: /api/, /apis/, /data-collector/v0,
/compliance/profiles/. The header a client sends is discarded; the value the
gateway receives is nginx’s own $ssl_client_escaped_cert, which is the
certificate nginx itself verified during the handshake, or empty.
X-Client-Cert is airtight, in all three load-balancer configs we examined, the
oldest dated 2025-05-06.
grpc-gateway has other ideas about header names
automate-gateway is a grpc-gateway server, and grpc-gateway has a convenience
feature. Its DefaultHeaderMatcher takes any incoming HTTP header named
Grpc-Metadata-Foo and turns it into gRPC metadata key foo, stripping the
prefix. This exists so HTTP clients can inject arbitrary gRPC metadata without
the framework needing to know about each key in advance.
So a request carrying:
Grpc-Metadata-Grpcgateway-X-Client-Cert: <escaped PEM>
arrives inside the gateway as metadata key grpcgateway-x-client-cert, which is
precisely the key the auth middleware reads. Both key names — the plain one and
the grpcgateway- prefixed one — are present as string constants in the
vulnerable binary, because this is not a bug in the gateway. It is the framework
working as designed.
nginx never touches it. proxy_set_header X-Client-Cert "" blanks exactly one
header name, and this is a different header name. Header count of Grpc-Metadata
in the pre-patch config: zero.
The envelope marked BADGE is confiscated at reception. The envelope marked FORWARD-THIS-TO-BADGE goes straight through.
The vendor’s own confession
Here is the patched config, same place in the file, and it is worth quoting whole because it names the bug better than the advisory does:
# SECURITY FIX: Strip internal gRPC certificate metadata headers injected by
# external clients. These headers are used internally by automate-load-balancer
# to forward verified client certificates to automate-gateway. An attacker can
# inject these directly to bypass authentication (CVE: auth-bypass via forged
# X-Client-Cert metadata). They must NEVER be accepted from external requests.
#
# grpcgateway-x-client-cert : set by this nginx when forwarding a verified
# mTLS client cert — must not be accepted from clients.
# Grpc-Metadata-Grpcgateway-X-Client-Cert : the HTTP header that grpc-gateway
# strips its prefix from to produce the above.
# Grpc-Metadata-X-Client-Cert : alternate injection path via grpc-gateway
# DefaultHeaderMatcher stripping "Grpc-Metadata-" prefix.
#
# Header names are case-insensitive in HTTP; nginx proxy_set_header matching
# is case-insensitive, so these cover all casing variants.
proxy_set_header Grpc-Metadata-Grpcgateway-X-Client-Cert "";
proxy_set_header Grpc-Metadata-X-Client-Cert "";
Thirteen occurrences of Grpc-Metadata in the patched nginx.conf — the
http-level pair plus a repeat inside each of the four forwarding locations,
because of that inheritance rule the original author warned about. Two more in
the Lua audit module, as defence in depth with a comment saying so:
-- SECURITY: Strip internal gRPC certificate metadata headers from incoming
-- client requests. … This must run FIRST — before any other processing
ngx.req.clear_header("Grpc-Metadata-Grpcgateway-X-Client-Cert")
ngx.req.clear_header("Grpc-Metadata-X-Client-Cert")
ngx.req.clear_header("X-Client-Cert")
So the fix is two-sided: the gateway stops believing unverified certificates, and the load balancer stops forwarding the headers that carry them. Either change alone breaks the attack chain. Shipping both is correct, and it is the reason version-based triage on this CVE is harder than it looks.
To be fair to whoever wrote the original: the failure here is not carelessness. It is that a header allowlist written in one program was invalidated by a prefix-rewriting feature in another program, and nothing in either codebase makes that relationship visible. The nginx config cannot see grpc-gateway’s header matcher. A comment saying “don’t mess it up” cannot help you when the thing that messes it up is three layers down someone else’s dependency.
The full chain
attacker, no certificate of any kind
-> nginx :443 no client cert required to connect
-> header survives pre-patch nginx strips X-Client-Cert, not Grpc-Metadata-*
-> nginx -> gateway nginx dials with ITS OWN service cert: a trusted peer
-> prefix stripped grpc-gateway rewrites to grpcgateway-x-client-cert
-> subjFromCertString unescape, PEM-decode, parse... and never verify
-> identity granted tls:service:deployment-service:<fingerprint>
The attacker supplies no credential. nginx supplies the trust, on their behalf, correctly, using its own genuine certificate. Textbook confused deputy: nothing is stolen, nothing is cracked, the privileged component is simply asked to spend its authority on behalf of whoever is on the line.
The lab
Automate’s documented floor is 16 GB of RAM and the workstation has 15, with no
/dev/kvm available for a nested VM. So the first lab is not a deployment at all
— it is the gateway binary, twice, on loopback, with nothing behind it.
lab/
start.sh ./start.sh vuln -> :2000 build 20260703061149
./start.sh patched -> :3000 build 20260824053200
matrix.sh mTLS x header test matrix
test-vector.sh the two-request differential
fake-backend.py stands in for the 18 services behind the gateway
certs/ a lab CA and five identities
This works because of where the flaw sits. The auth interceptor runs before any
backend call, and the gateway’s null_backend_sock makes all nineteen service
binds fall back to a null backend, so it boots standalone. Backend calls then
return Unimplemented, which is not a limitation. It is an oracle, and a clean
one.
The certificates, and the one that matters
| file | role |
|---|---|
root_ca.crt | the lab CA — what the gateway is configured to trust |
service.crt | the gateway’s own TLS identity (SAN must include automate-gateway or its internal REST→gRPC dial fails) |
automate-load-balancer.crt | CA-signed peer identity — stands in for nginx |
trusted.crt | CA-signed, subject deployment-service — the control case |
forged.crt | self-signed, subject deployment-service — the attack |
trusted and forged carry an identical subject. The only difference between
them is who signed them. That is the entire experiment: same name, different
issuer, and one build cannot tell them apart.
The result
Peer is automate-load-balancer (CA-signed) on every row; the header carries a
URL-escaped PEM claiming deployment-service:
| build | header cert | response | meaning |
|---|---|---|---|
| vuln :2000 | trusted | Permission Denied (7) | accepted |
| vuln :2000 | forged | Permission Denied (7) | accepted — BUG |
| patched :3000 | trusted | Permission Denied (7) | accepted |
| patched :3000 | forged | Authenticate not implemented (12) | rejected — FIXED |
Counter-intuitively, Permission Denied is the win. gRPC code 7 means the header
certificate was accepted, a service identity was derived from it, and the request
travelled all the way to authorization — which the null backend denies because it
denies everything. Code 12 means the header was ignored entirely and the request
fell through to normal authentication, which is unimplemented in a standalone
gateway. The two are cleanly distinguishable, which is all an oracle has to be.
Preconditions the advisory never mentioned
Four things fell out of the matrix, and they materially shape the vector:
- The header is only honoured when the peer is the load balancer.
Present an
automate-gatewayclient certificate instead and the forged header is ignored on both builds.headerAuthValidForClientAndPeer— one of the five changed functions — is what gates that. - Peer certificates are properly verified. mTLS with a self-signed client certificate is rejected at the handshake, on both builds. Only the header certificate skipped validation. Both facts have to be held at once, and confusing them is the easiest way to convince yourself the bug isn’t real.
- No mTLS at all means the header is ignored. Talking straight to the gateway
with no client certificate never reaches
subjFromCertString. Which is exactly why the nginx half is load-bearing: nginx is what makes the connection privileged. fromDeploymenttests for the literal prefixtls:service:deployment-service:, which tells you which name is worth claiming.
The fake backend, and what it showed
The null backend proves acceptance but shows nothing about what happens after.
fake-backend.py is about a hundred lines of Python answering the interservice
gRPC calls, logging each one, and telling authz that everything is authorized. No
protobuf definitions needed: an empty message is a valid encoding of any protobuf
type, so hand-crafted wire bytes are enough.
Point eighteen of the gateway’s binds at it — keeping license-control-service
among them, or the licence interceptor fires first and the request never reaches
authorization — and two things become visible.
The forged certificate propagates inward. A call to
chef.automate.domain.authz.AuthorizationService/ProjectsAuthorized arrived
carrying both certificates in its metadata:
x-client-cert -> the load balancer's real peer certificate
grpcgateway-x-client-cert -> our forged deployment-service certificate
The reachable IAM surface is enumerable, because each request logs the action and resource being evaluated for the forged identity:
| endpoint | action | resource |
|---|---|---|
/apis/iam/v2/policies | iam:policies:create | iam:policies |
/apis/iam/v2/tokens | iam:tokens:create | iam:tokens |
/apis/iam/v2/users | iam:users:create | iam:users |
/api/v0/ingest/events/chef/run | ingest:nodes:create | ingest:nodes::runs |
/api/v0/secrets/search | secrets:secrets:list | secrets:secrets |
iam:policies:create is the one to note. A principal that can create IAM policies
can grant itself everything else, so it collapses any distinction between
“limited service access” and “administrator”.
And the question the lab could not answer
Every one of those requests still ended in PermissionDenied, because our authz
was a stub. So at this point we had proved the forged identity was constructed
and evaluated, and not that it was permitted.
That distinction is the whole severity of the bug, and it turns on the fingerprint suffix in the subject:
- If the deployment’s policy matches
tls:service:deployment-service:*, then any certificate with that CN is granted service privilege. Full compromise. - If it pins the exact fingerprint of the genuine service certificate, a forged certificate yields a valid-but-unknown subject, and impact is limited to whatever an identified-but-unprivileged principal may do.
We wrote that open question into the lab notes with an instruction attached: do not claim the first reading in a writeup until the policy is checked. This is the part of the process worth stealing. Write down the claim you are not yet entitled to make, in the repo, next to the evidence, where future-you will trip over it.
The live confirmation
The operator then stood up a real Chef Automate — their own deployment, their own free licence, a standalone instance on a domain they own — and pointed the tester at port 443 with no client certificate at all.
That is the configuration that matters. Mode 2, as the tooling calls it: no certificate in the handshake, one forged certificate in the header, straight through nginx like any other visitor.
Baseline, anonymous, no header:
HTTP/1.1 401 Unauthorized
{"error":"request not authenticated","code":16, ...}
gRPC code 16, Unauthenticated. Correct behaviour, and what every indexed
instance in the exposure section will give you.
The same request, with a self-signed certificate in
Grpc-Metadata-Grpcgateway-X-Client-Cert:
HTTP/1.1 200 OK
Real data. Nineteen endpoints were swept, read-only, and captured. A redacted sample, with the deployment’s own identifiers removed:
GET /api/v0/telemetry/config
{"license_id":"free-…","customer_name":"…","license_type":"free",
"telemetry_enabled":true,"deployment_id":"…","deployment_type":"Standalone"}
GET /api/v0/infra/servers
{"servers":[{"id":"…","name":"…","fqdn":"…","orgs_count":1}]}
GET /api/v0/secrets/search
{"secrets":[{"id":"…","name":"<redacted: named after the service it belongs to>",
"type":"chef-server","data":[]}],"total":1}
GET /apis/iam/v2/users
{"users":[{"name":"Local Administrator","id":"admin","membership_id":"…"}]}
401 to 200, with no credential of any kind presented. That is the CVSS 10.0
reproduced end to end: the nginx hop we had only read about in a config file
demonstrably forwards the header, and the gateway demonstrably believes it.
Note the third response. secrets/search returns the inventory of stored
credentials with data empty, so no secret material came back in the sweep. The
name is redacted above because we could not confirm it is a stock string rather
than something specific to that deployment: it does not appear in the gateway
binary or either load-balancer package, so it is most likely generated by the
service that owns it. Either way the point survives. The inventory names each
credential after the thing it unlocks, which tells an attacker exactly what exists
and what to ask for next, and POST /api/v0/secrets is in the authorized set.
The open question, answered by the target itself
Automate has an endpoint whose entire job is to tell the caller what the caller
may do. GET /apis/iam/v2/introspect returns, per path, which HTTP verbs the
current subject is authorized for.
Asked through the bypass, as tls:service:deployment-service:<our fingerprint>,
it answered:
AUTHORIZED ENDPOINTS: 136 paths
73 with a GET
76 with a write verb (POST/PUT/DELETE/PATCH)
Including, among the writes:
/apis/iam/v2/policies POST create an IAM policy
/apis/iam/v2/tokens POST mint an API token
/apis/iam/v2/users POST create a local user
/apis/iam/v2/teams POST
/apis/iam/v2/roles POST
/apis/iam/v2/projects POST
/apis/iam/v2/apply-rules POST, DELETE
/api/v0/secrets POST
/api/v0/infra/servers POST
/api/v0/nodes/delete POST
/api/v0/notifications/webhook POST
/api/v0/license/apply POST
/api/v0/ingest/events/chef/* POST
So the lab’s open question is settled, empirically, in the attacker’s favour. The forged service identity is not merely constructed and evaluated — it is authorized, on 136 paths, including every IAM creation verb that exists.
Worth being precise about the mechanism, because it is not visible in the policy
list. GET /apis/iam/v2/policies on that instance returns six ordinary policies —
Administrator, Editors, Viewers, Ingest, and two compliance ones — whose
members are team:local:*. None of them mentions a tls:service: subject at all.
The privilege therefore comes from policy that the IAM API does not expose:
Automate’s internal system policies for service subjects. We can read the
consequence through introspect and we cannot read the rule through the API, so
that is how it is stated here. The behavioural answer is unambiguous either way.
What we did not do, and what we’re not publishing
Two post-exploitation paths follow directly from that authorized set, and the repository contains drivers for both:
create-admin.py POST /apis/iam/v2/users create a local user
POST /apis/iam/v2/teams/admins/users:add
-> a browser login carrying the Administrator policy
create-token.py POST /apis/iam/v2/tokens mint a token, capture its value
POST /apis/iam/v2/policies owner policy naming token:<id>
-> then re-verify using the token alone, no forged cert
Neither is published, in shape or in full, and neither was run against anything beyond the operator’s own instance. They are described here because their existence is the single most important fact in this post for anybody who runs Chef Automate, and it has nothing to do with exploitation:
Both outputs survive the patch. A user and a token created through this bypass are legitimate Automate credentials. Upgrading to 4.13.520 closes the door and evicts nobody. If you were exposed, patching is step one of two, and step two is in the detection rubric below.
Why this is published at all
The withheld drivers are not the hard part, and it would be dishonest to imply
otherwise. This post prints the header name, the escaping rule, the four
forwarding paths, the verdict logic and the fact that POST /apis/iam/v2/users is
in the authorized set. The distance from a 200 on the probe to an administrator
account is a speed bump, not a barrier, and anybody capable of the first is capable
of the second.
So the justification is not restraint. It is that the detection test and the exploit are the same two requests, and there is no version-based substitute. The version endpoint reports a manifest number that was below the advisory’s floor on a host we exploited. The CLI is byte-identical across the fix. The advisory’s range does not survive the code argument. Strip all three and an operator asking “am I affected” has exactly one instrument left, and it is the probe.
Publishing a detection method that cannot be separated from its exploit is a real cost. Withholding it here would mean asking several hundred organisations to determine their exposure using three oracles this post demonstrates are broken, which is a larger one. That is the trade, stated plainly rather than dressed up as a principle.
What is still withheld is the assembled escalation and persistence tooling, on the narrower ground that it saves an attacker time and saves a defender nothing.
The version number does not mean what the advisory implies
One endpoint in that sweep deserves its own section, because it quietly invalidates the first thing everybody will do with this CVE.
The CNA record is unambiguous: 4.13.516 through 4.13.519 affected, versions prior to 4.13.516 not affected. So the obvious triage is to ask each box its version, compare against the range, and move on.
The argument against that lower bound is a code argument, and it stands on its own without reference to any version string.
On the gateway side, the defect is an absent call. subjFromCertString
unescapes, PEM-decodes, parses and trusts, and the patch inserts
(*Certificate).Verify into that sequence. Nothing about the vulnerable version is
new code: it is 512 bytes of middleware whose trust check simply was not there, and
the symbol inventory shows no function added or removed on either side. An absent
call does not arrive in a release. It persists until somebody adds it.
On the load balancer side, the header-stripping list is the thing that changed,
and the pre-patch list contains zero occurrences of Grpc-Metadata. The
Grpc-Metadata- prefix rewrite is grpc-gateway default behaviour, not an Automate
feature with a version history. For 4.13.515 to be safe, either the gateway would
need to have verified certificates and then stopped, or nginx would need to have
stripped those headers and then stopped. Neither is a thing that happens.
The mechanism points one way. So rather than leave it as an argument, we went and checked whether it does.
So we went and looked
Habitat Builder keeps every release ever published — 3,172 of automate-gateway,
622 of automate-load-balancer, all public, no licence. Which means the question of
whether pre-4.13.516 builds carry this bug does not have to be settled by argument.
Gateway side. subjFromCertString, across four builds spanning eleven months,
with callees resolved through .gopclntab exactly as in the original diff:
| gateway build | function size | calls x509.(*Certificate).Verify |
|---|---|---|
| 2025-09-15 | 512 bytes | no |
| 2026-06-05 | 512 bytes | no |
| 2026-07-03 (the advisory’s vulnerable era) | 512 bytes | no |
| 2026-08-24 (patched) | 1344 bytes | yes |
Byte-identical size, identical callee set, eleven months apart. The 2025-09-15
build also already carries grpcgateway-x-client-cert, x-client-cert,
tls:service: and Failed to decode header container cert, so the entire
header-derived identity path existed then in the same shape it had in July 2026.
Load balancer side, three configs:
| LB build | X-Client-Cert blanked | cert-forwarding locations | Grpc-Metadata stripping |
|---|---|---|---|
| 2025-05-06 | yes | 4 | 0 |
| 2026-07-07 | yes | 4 | 0 |
| 2026-08-24 (patched) | yes | 4 | 13 |
The 2025-05-06 config contains the same four forwarding locations, the same single
proxy_set_header X-Client-Cert "", no Grpc-Metadata stripping whatsoever, and
the same “Don’t mess it up” comment. Sixteen months before the fix, the exact
configuration this post describes was already shipping.
So this is no longer an inference. Both halves of the vulnerability are present, unchanged, in packages Progress published roughly a year before the advisory’s stated floor. “Versions prior to 4.13.516 are not affected” is not consistent with the packages Progress itself distributes.
One honest limit on the mapping: Habitat releases are dated, not stamped with a
4.13.x product version, so tying a specific package to a specific Automate
release requires the deployment manifest rather than the package. That cuts the
other way here, though — a gateway built in September 2025 cannot plausibly belong
to a release train whose 4.13.520 arrived in August 2026, and a build predating the
advisory’s floor by that margin is the whole point.
The corroboration, such as it is: the instance we confirmed the bypass against
answered {"build_timestamp":"4.13.434"} at /api/v0/version, eighty-two builds
below the advisory’s floor, minutes after returning real data on nineteen
endpoints.
That number is weaker evidence than it looks, and the weakness is worth naming
because it cuts both ways. build_timestamp is documented in the gateway’s own
embedded schema as “the manifest version” — the deployment manifest, not the
package serving requests. There is no 4.13.x string compiled into either gateway
binary; both report pkg_version="0.1.0" in their Habitat manifests. So one of two
things is true:
- The vendor’s range understates the affected population, and that host was a pre-4.13.516 build carrying the bug, exactly as the code argument predicts.
- The endpoint is not a version oracle at all, and reports something that can lag or diverge from what is actually installed.
We cannot separate those from one instance, and it does not matter, because both readings destroy the same triage path. Either the range is wrong, or the number you would check it against is meaningless. The code argument is what carries the claim; 4.13.434 is what made us go looking.
Do not scope this from the version number. Not from
/api/v0/version, not from the vendor’s range, and not from thechef-automateCLI — which, recall, is byte-identical between 519 and 520 and therefore cannot tell you which side of the fix you are on either.
If you run Chef Automate and you have not applied 4.13.520 or later, assume you are affected and test behaviour. The test is two requests and it is above the fold.
Disclosure
The claim in the section above is a claim about the vendor’s advisory being wrong in the direction that leaves people exposed, so it does not get published without a timeline attached.
2026-09-13 Candidate triaged. Diffability and novelty verified.
2026-09-13 Gateway package diff. Root cause identified: absent x509 verification.
2026-09-13 Load balancer config diff. Reachability path identified.
2026-09-13 Tier-1 differential lab. Bypass reproduced against both builds.
2026-09-14 Live confirmation against the operator's own licensed deployment.
401 -> 200, no credential. Affected-range discrepancy observed.
2026-09-17 Historical package sweep. Four automate-gateway builds (2025-09-15 to
2026-08-24) and three automate-load-balancer configs (from 2025-05-06)
pulled from Habitat Builder. Both halves present unchanged in 2025
packages: the affected-range floor does not hold.
2026-09-17 Findings not reported to Progress prior to publication.
2026-09-17 This post.
That line is not rhetorical, it is a blocker. Nothing in this research contacted Progress, and the affected-range finding is the one part of this post the vendor can act on and nobody else can — more so now that it is measured across seven packages rather than inferred from one. Before this goes out, either the timeline shows Progress was notified and given a window to correct the range, or it says plainly that they were not and why. An unanswered disclosure is a perfectly respectable line in a timeline. A missing one is not.
The bypass itself is already fixed, already public, and already has a CVE, so nothing here is a zero-day. The range is different: correcting it costs Progress an advisory edit and would tell every operator below 4.13.516 that they are in scope after all.
Exposure
Passive index reads only. Nothing in this section contacted a host.
Censys, one query, deliberately narrow:
host.services.endpoints.http.html_title: "Chef Automate"
or host.services.endpoints.http.body: "*chef-automate*"
71 hosts raw, 65 unique confirmed Chef Automate after classification. One
false positive in the entire pull, which was a website about CVEs whose body
mentioned chef-automate. Reputation label BENIGN on all of them — no honeypots
to exclude.
| distribution | |
|---|---|
| Country | US 51, India 5, Ireland 5, Netherlands 2, Singapore 2, France 1, Germany 1 |
| ASN | Amazon 43, Azure 18, DigitalOcean 3, rest singletons |
| Ports | 443 (56), 10012 (6), 80 (3), 10021 (2) |
| State (67 rows) | 63 login pages, 2 Chef Automate Setup (never bootstrapped), 1 down for maintenance, 1 body-only |
Overwhelmingly cloud-hosted. Effectively zero on-prem in the visible set. And note those two setup-state instances: an un-bootstrapped Automate on the public internet is its own separate problem.
65 is the number worth quoting, and it is not version-qualified, because per the previous section no passive version signal exists. It describes product exposure, not vulnerable exposure.
The bigger number, and why it is a footnote
Merging four operator-supplied FOFA exports on top of the Censys pull produces 672 hosts across 12 countries, of which 50 are corroborated by both scanners. FOFA sees roughly ten times what Censys sees and agrees where they overlap, so the gap is coverage rather than contradiction, and 77% of the Censys set is confirmed by FOFA.
That figure is deliberately not the headline. FOFA’s export carries no timestamp column, so liveness is unestablished for the 607 FOFA-only hosts, and a range whose floor and ceiling differ by 13x does not constrain anything useful. If you quote a number from this post, quote 65 confirmed. The merged set is in the repository, split by country, for anyone who wants to do the liveness pass that would make 672 mean something.
The fingerprints that don’t work
Worth recording, because they are the ones you would reach for first:
| candidate | hits | verdict |
|---|---|---|
software.product: "chef_automate" | 632 | Reject. Only 63 of 632 carry an Automate title. The rest is Chef Manage (238), Chef Infra Server API (168), Chef API (126), then pure noise: FortiManager, GlobalProtect, PaperCut, Confluence, ServiceNow, Ivanti |
endpoints.path: "*/dex/auth*" | 138 | Reject. Automate fronts auth with dex, but so does Kubeflow, Grafana, OpenStack. Zero overlap with the Automate body signal |
html_title: "*Automate*" | 1265 | Too broad — Progress also ships an unrelated product literally called Automate |
body: "*automate-gateway*" | 0 | The internal service name never appears in served HTML |
The sibling-product contamination is the theme. On the FOFA side the same cure
dropped 1602 rows of Chef Infra Server API, 511 of Chef API and 232 of
Sign In - Chef Manage. If you are inventorying your own estate for this CVE,
those three are what you will trip over, and none of them is the affected
component.
The favicon pivots cleanly across all three scanners — Censys stores the Shodan-compatible mmh3 hash — but 56 of 65 hosts serve it, so favicon alone under-counts by about 14%:
| scanner | query |
|---|---|
| Censys | endpoints.http.favicons.hash_sha256: "5f4080fbaa493463c163433fc3c7f57b891e43a00694557ac29c057cc7f62803" |
| Shodan | http.favicon.hash:-276759139 |
| FOFA | icon_hash="-276759139" |
Pair it with the title. And for your own inventory, don’t use a scanner at all — use the two-request test from inside your network, where the instances that are not indexed live.
Detection: a rubric
This is the part that matters if you run the software rather than break it. It is written to be worked top to bottom, and it assumes nothing about your tooling beyond having the logs Chef Automate already writes by default.
Three things make this CVE unusually tractable to detect, and one makes it unusually urgent:
- The attack requires a header that has no legitimate external use whatsoever. There is no false-positive story for a client sending it.
- The header value is a PEM certificate, so the request is 1.5–2 KB larger than any comparable legitimate request, and Automate logs request length by default.
- The gateway logs the identity it derives, including the certificate fingerprint.
- And the attacker’s persistence — a user, a token, a policy — survives your patch, so “we upgraded” is not an answer to “were we compromised”.
Confidence grades used below:
| grade | meaning |
|---|---|
| Definitive | No benign explanation. Treat as confirmed attempt or confirmed compromise |
| Strong | Benign explanations exist but are rare and enumerable |
| Suggestive | Worth correlating, not worth paging anyone over |
| Unusable | Looks like a signal, isn’t. Listed so you don’t build on it |
0. First: are you affected?
Not from the version number. From behaviour, with two requests, run from wherever your monitoring already reaches your own instances.
Request A (baseline): GET /api/v0/telemetry/config
Request B (probe): GET /api/v0/telemetry/config
Grpc-Metadata-Grpcgateway-X-Client-Cert: <url-escaped self-signed PEM>
Four rules for the probe to be valid, and missing any of them produces a silent false negative that looks exactly like “patched”:
- The path must be one nginx forwards the certificate on:
/api/v0/…,/apis/…,/data-collector/v0,/compliance/profiles/. The login page,/, and anything the UI loads never reach the code and will always look clean. - The method must match the route. grpc-gateway maps each verb to a different
gRPC method, and a mismatch returns
405before the auth interceptor runs. - The certificate must be URL-escaped PEM, because nginx’s real value comes
from
$ssl_client_escaped_certand the gateway callsnet/url.unescape. Raw PEM with newlines is not a legal header value and will not survive the trip. - The header name must be the
Grpc-Metadata-prefixed one. PlainX-Client-Certis blanked by nginx in every version, so a test using it never fires on any build, patched or not.
Verdict table:
| Request A | Request B | Verdict |
|---|---|---|
401 + "code":16 | 200, no error code | Vulnerable, and the forged identity is authorized. Worst case |
401 + "code":16 | 403 + "code":7 | Vulnerable. Identity accepted, this action refused. Still a finding |
401 + "code":16 | identical to A | No signal — see below |
| anything | 502/504/HTML | Infrastructure noise. Retest |
A “no change” result is not proof of patched. It means one of: the LB strips the header (patched), the gateway ignores it (patched), you used a path off the list, a WAF or CDN in front dropped the unknown header, or your tool is broken. Distinguishing them requires calibrating your tool against something known to be vulnerable first, which is the single most skipped step in this kind of work.
The mechanised version, and what its metadata admits
The two requests above are the whole test, and they are runnable by hand today with
curl. Both are read-only: two GETs, no state change, no write path touched, and
the certificate you send is a throwaway whose private key never leaves your
machine, because the bug is that the public half alone is accepted as proof of
identity. Pointing this at your own estate cannot change anything in it.
A nuclei template automating this comparison — using req-condition to require
that the baseline look like a genuine anonymous Automate gateway before any
difference counts — has been submitted to projectdiscovery/nuclei-templates.
Until it lands, the hand-run version above is the whole test.
Two things about that template’s metadata are worth stating here rather than in a commit message, because they are the same argument this whole post runs on.
Its verified flag says false, despite a live positive. The positive is one
deployment, owned by the operator, and the template’s baseline matcher is tuned to
the 401 that instance returns; a deployment answering 403 when anonymous would
be missed entirely. Maintainers can flip the flag after an independent run. Claiming
verification from a sample of one is how templates come to be trusted further than
their evidence reaches, which is the template-shaped version of “one host is not a
survey”.
Its favicon discovery query was removed. The mmh3 hash -276759139 is real,
Censys stores it, and it returns nothing at all on Shodan, where it appears
unindexed. A discovery query that silently matches zero hosts is worse than no
query, because it reads as an absence of exposure rather than an absence of
coverage. The template ships with http.title:"Chef Automate" instead, and 56 of
our 65 confirmed hosts serve that favicon anyway, so it would have under-counted by
about 14% even where it worked.
1. Edge detection: the header family
Grade: Definitive. No legitimate external client sends any of these.
Block and alert on all of the following, case-insensitively, on any request reaching Automate from outside:
x-client-cert
grpcgateway-x-client-cert
grpc-metadata-x-client-cert
grpc-metadata-grpcgateway-x-client-cert
Then generalise, because the specific names are an artefact of one framework’s prefix convention and the next variant costs an attacker nothing:
^grpc-metadata-.*client.*cert$ -- the family
^grpc-metadata- -- consider blocking the whole prefix
That last one deserves a thought rather than a reflex. Grpc-Metadata-* is
grpc-gateway’s documented mechanism for injecting arbitrary gRPC metadata from an
HTTP client, which means anything reachable through it is reachable by a stranger
who can spell it. If you have no client that legitimately needs to inject gRPC
metadata through your edge — and you almost certainly don’t — dropping the entire
prefix at your outermost proxy is strictly better than enumerating the two names
the vendor happened to find. This CVE is what an incomplete enumeration of that
family looks like.
A value-based signature, if your WAF inspects header values:
-----BEGIN%20CERTIFICATE----- escaped, the form that actually works
-----BEGIN CERTIFICATE----- unescaped, the form a first attempt uses
MII base64 DER prefix, in any header value
2. Retro-hunt in the logs you already have
Grade: Strong. This is the one to run first, because it works on history.
The load balancer’s default nginx access log format is called opscode, and its
last field is $request_length. It records no header names or values — so you
cannot grep for the header itself — but $request_length is the byte count of the
request line plus headers plus body as received from the client, and the forged
certificate makes that count anomalous.
Measured from our own probe: the escaped PEM value is 1,493 bytes, and the
complete GET request carrying it is 1,648 bytes. A legitimate GET /api/v0/telemetry/config is 100–250 bytes depending on user agent.
# access log, opscode format: request_length is the final field
awk '$0 ~ /"GET \/(api|apis)\// && $NF > 1000' access.log
Restrict to GETs, or to paths you know carry no body. $request_length includes
the request body, so bulk ingest POSTs to /data-collector/v0 are legitimately
enormous and will swamp an unfiltered threshold.
Two honest caveats. First, a real mTLS client does not inflate
$request_length — its certificate travels in the TLS handshake, and nginx adds
the header after the byte count is taken — so genuine certificate-authenticated
traffic does not trip this. That asymmetry is what makes the signal good. Second,
we derived the threshold from the logging configuration and our own measured
request, not from a live audit log on a production deployment. Validate it on your
own instance by sending yourself one long benign header before you tune an alert
on it.
3. The audit log, which is on by default
Grade: Definitive, and this is the best artefact in the product.
audit.logging.enabled = true is the default in these builds. When on, a Lua
module writes one JSON record per request — 32 fields — to:
/hab/svc/automate-load-balancer/data/audit.log
rotated to audit.1.log … audit.10.log
The fields that matter:
request_uri response_status request_length
user_email user_id session_id api_token_hash
source_ip user_agent timestamp
Those four identity fields are populated from the caller’s JWT session or, for
token callers, as a SHA-256 hash of the presented api-token /
x-data-collector-token. The exploit presents none of them. So:
A
200from/api/v0/*or/apis/iam/v2/*whereuser_email,user_id,session_idandapi_token_hashare all empty means the gateway authenticated a caller by something the load balancer never recorded. On a pre-4.13.520 deployment, that something is the header certificate.
As a query shape:
type = "audit"
AND request_uri MATCHES ^/(api|apis)/
AND response_status = 200
AND user_email = "" AND user_id = "" AND session_id = "" AND api_token_hash = ""
AND request_length > 1000
Drop the request_length clause to widen it; keep it to make the result
self-evidencing. Legitimate anonymous traffic exists in that log, but it answers
401, not 200.
Retention is the constraint, so do this now. Ten rotations at the configured maximum file size is all the history there is, and on a busy instance that is not long. If you have a log shipper, the audit records are the ones to keep.
4. Gateway-side: the identity it derived
Grade: Definitive, where you have the logs.
The gateway logs the subject it constructs:
auth.subjects="[tls:service:deployment-service:c5d4f1643e653c2600d4cbb5b33213ca…]"
The suffix is the certificate fingerprint, which is the detection lever. Your deployment has a small, fixed set of legitimate internal service certificates. Enumerate their fingerprints once, then:
Any
tls:service:subject whose fingerprint is not in your inventory is a forged certificate. There is no benign path to a new fingerprint except a certificate rotation, which you performed and can therefore account for.
This is a better rule than alerting on the service name, because the name is attacker-chosen and will match your real services exactly.
Caveat stated plainly: in our lab the gateway was running at debug level, which is where we observed that field. Confirm it appears at your configured log level before you depend on it — and if it doesn’t, the audit-log rule in §3 covers the same event from the other side of the hop.
5. The malformed-attempt tell
Grade: Definitive for attacker activity, independent of success.
Error strings live in the gateway binary that fire only when something hands it a header it cannot parse. Which ones you have depends on which build you are running, and the patched build is the more talkative of the two:
"Failed to decode header container cert" both builds
"Unable to unescape %s, %v" both builds
"Failed to PEM-decode cert from header" patched build only
That third string is part of the logging the fix added, which is a small bonus worth knowing: after upgrading, failed injection attempts are better recorded than they were while you were vulnerable. (Our own diff notes originally listed all three as present on both sides; checking the binaries individually says otherwise, and the binaries win.)
Nothing legitimate produces any of them. The only entity that sets this header in a
healthy deployment is nginx, from $ssl_client_escaped_cert, which is either
well-formed or empty. A malformed value means a human is iterating on the
encoding — which is precisely what the first ten minutes of exploiting this looks
like, because getting the escaping right is the fiddly part.
These are the highest-value entries in your gateway log: they catch the attempts that failed, and therefore the reconnaissance that precedes a success.
6. After the fact: what patching does not undo
Grade: Definitive on finding anything unexpected, and this is the section to act on even if everything above is clean.
The authorized set included every IAM creation verb. Credentials created through the bypass are ordinary Automate credentials and keep working after 4.13.520. So patch, then audit:
| check | endpoint | what “bad” looks like |
|---|---|---|
| API tokens | GET /apis/iam/v2/tokens | Any token you cannot attribute to a person or an integration. Our clean instance had zero |
| Local users | GET /apis/iam/v2/users | Any local user beyond your known set. A stock instance has one, admin |
| Admin team | GET /apis/iam/v2/teams, then the admins membership | Any member you did not add. admins carries the Administrator policy: role owner on * |
| Policies | GET /apis/iam/v2/policies | Any type: CUSTOM policy granting role owner, or any policy with a member of the form token:<id> |
| Service subjects | policy members | Any member starting tls:service: that you did not put there |
| Secrets | GET /api/v0/secrets/search | Rotate everything listed. The inventory was readable, so assume it was read |
| Node data | GET /api/v0/cfgmgmt/nodes, event feed | Ingest was writable. Injected or deleted node records are a real possibility |
| Notifications | GET /api/v0/notifications/rules | New webhook destinations are an exfiltration path, and POST /api/v0/notifications/webhook was authorized |
Then rotate: the deployment CA and internal service certificates (the forged identity was derived from a certificate, and your CA’s trust decision is what the patch now enforces), all API tokens, and any credential named in the secrets inventory.
On timeline, one caution. The vendor’s affected range starts at 4.13.516; §“The version number does not mean what the advisory implies” argues it should not be believed as a lower bound. When you choose how far back to hunt, use the age of the deployment, not the age of the range.
7. If you cannot patch today
You do not need the vendor’s binary to break this chain. It has two halves and either one stops it.
Strip the header family at any proxy, WAF or CDN in front of Automate. That is what the vendor’s own fix does at the load balancer:
# mirror of the 4.13.520 fix, applicable at your own edge
proxy_set_header Grpc-Metadata-Grpcgateway-X-Client-Cert "";
proxy_set_header Grpc-Metadata-X-Client-Cert "";
proxy_set_header X-Client-Cert "";
One nginx-specific trap, which is the trap that caused this CVE: proxy_set_header
directives are inherited only if the current level defines none of its own. If you
add these at the http level and any location block below has its own
proxy_set_header, that block does not get them. Repeat them in every location
that proxies to Automate. The vendor’s patched config does exactly this, thirteen
times, for exactly this reason.
Also worth doing regardless of patch state: Automate’s API gateway does not need to be reachable from the whole internet for a dashboard to work. 43 of the 65 indexed instances are on Amazon and 18 on Azure, which means the overwhelming majority of them are one security-group rule away from not being in anyone’s scan results.
8. What will not work
Listed because these look like detections and are not.
| approach | grade | why |
|---|---|---|
Version check via /api/v0/version | Unusable | Reported 4.13.434 on a confirmed-exploitable host. It is a manifest version, not a patch statement |
Version check via the chef-automate CLI zip | Unusable | Byte-identical between 4.13.519 and 4.13.520 |
| Scoping by the vendor’s affected range | Unusable as a lower bound | Vendor packages dated 2025 carry both halves unchanged: subjFromCertString at an identical 512 bytes with no Verify call, and an nginx config with zero Grpc-Metadata stripping |
| Shodan/Censys/FOFA for “am I affected” | Suggestive for discovery, Unusable for version | No passive build signal exists. The favicon pivot also under-counts by ~14% |
Alerting on tls:service:deployment-service by name | Suggestive | The name is attacker-chosen and matches your real service exactly. Key on the fingerprint |
Watching for a 403-only response | Suggestive | A deployment whose authz grants the forged identity outright answers 200. Tuning for 403 misses the more severe case, which is why the template in §0 accepts either |
| Watching for credential theft in the traffic | Unusable | Nothing is stolen. The attacker never learns a secret; the gateway spends its own authority on their behalf |
The five-minute version
This is the block from the top of the post, repeated here so the rubric closes where it opened:
1. Patch to 4.13.520+. (does not evict anyone)
2. Strip Grpc-Metadata-* at your edge. (breaks the chain independently)
3. Behavioural test, two requests, per instance. (version numbers lie here)
4. Hunt audit.log: 200 + all identity fields empty + request_length > 1000.
5. Audit IAM: tokens, local users, admins team, CUSTOM owner policies.
6. Rotate: internal certs and CA, all tokens, everything in secrets inventory.
7. Take the gateway off the public internet if it has no reason to be there.
Steps 4 and 5 are the ones people skip, and they are the only two that answer “were we already compromised”.
The part where we nearly shipped a useless test
Two mistakes worth publishing, because both are the kind that produce confident wrong answers rather than visible failures.
The vector we found first was the wrong one to test with. The gateway reads
x-client-cert, that constant is right there in the binary, and the lab
reproduced the entire bypass using it. Two builds, clean differential, forged
certificate accepted by one and rejected by the other. Done.
Except nginx blanks x-client-cert in every load-balancer config we examined,
back to May 2025, vulnerable builds included. A field test built on that header would have
returned “no change” against every host on earth, and the honest reading of “no
change” is “no signal” — so we would have concluded nothing, slowly, with a
correct-looking tool. The Grpc-Metadata- prefix only surfaced from reading the
patched nginx config and noticing it defended two names the pre-patch config had
never heard of.
The lesson generalises past this CVE: when a vendor’s fix touches two components, the component you did not diff is where the reachability answer lives. We had the gateway binary and a working lab a day before we had the config that explained why any of it mattered from outside.
And we published a caveat we then had to retract. The repository’s lab notes
said, correctly at the time, that the nginx hop was proven by reading a config and
not by watching a packet traverse it, and that mode-2 testing was “well-founded
but not yet demonstrated”. That caveat stood for about a day, until the live
deployment turned 401 into 200 and demonstrated it. Both statements are still
in the repo, in order, with dates. Leaving the superseded one visible costs
nothing and makes the confirmed one worth believing.
What is still not proven
In the interest of not doing the thing this post has twice complained about:
- We diffed two Habitat builds seven weeks apart, not two product versions. The vulnerable build is dated 2026-07-03 and the patched one 2026-08-24. Mapping those to 4.13.519 and 4.13.520 rests on the patched build’s timestamp matching the release date exactly, plus the nginx config change naming the CVE. That is strong, and it is inference.
- The privilege mechanism is read behaviourally. Introspect says the forged identity is authorized on 136 paths. The policy granting that is not visible through the IAM API, so we are reporting the consequence, not the rule.
- 65 confirmed hosts is a Censys snapshot, not a census, and it is not version-qualified. The 672-host merged figure has unverified liveness on 607 of them and is a footnote for that reason. Nothing here was probed.
- The affected-range finding is now measured rather than argued — four gateway
builds and three load-balancer configs, back to 2025 — but the mapping from
Habitat build dates to
4.13.xproduct versions is by date, not by a version string in the package. The code is demonstrably vulnerable long before the advisory’s floor; naming the exact earliest affected release number would need deployment manifests we do not have. - The
request_lengththreshold in §2 is reasoned from the logging config and one measured request, not from a production audit log. It is a starting point for tuning, not a number to hard-code. - The
auth.subjectslog line was observed at debug level in a standalone lab gateway. Confirm it at your own log level.
Keywords for the non-specialist
In roughly the order you hit them.
Chef is a configuration-management system: software that decides what is installed and how it is configured on a fleet of servers, and continuously makes each machine match that description. The descriptions are called cookbooks, and they are code, which is why control of Chef is control of the servers.
Chef Automate is the control plane and dashboard for that: compliance reporting, node inventory, scan jobs, and the identity system deciding who may change any of it. Chef Infra Server is a different product in the same family, which matters here only because it is the thing most often mistaken for Automate in scan data.
An API gateway is a single front door that receives requests and routes them to whichever internal service handles each one. nginx is the web server used here as the outermost layer, and a reverse proxy or load balancer is that role: the thing facing the internet, forwarding requests inward.
gRPC is a way for programs to call functions in other programs over a network, more compact and stricter than ordinary web requests. grpc-gateway is the adapter that lets ordinary web requests drive a gRPC service, and it is the component whose header-rewriting convenience feature carries this attack.
A certificate is a document that says “this is who I am”, signed by an authority so the reader can check the signature. X.509 is the standard format. A CA, certificate authority, is the entity whose signature makes a certificate believable — the HR department that issues badges. Self-signed means the document signs itself, which is normally worthless, and which is the entire point here: nobody checked the signature. A fingerprint is a short hash of a certificate, used as a unique name for that exact document.
Parsing a certificate means reading its contents. Verifying one means checking that a CA you trust actually signed it. The whole CVE is one program doing the first and not the second.
mTLS, mutual TLS, is an encrypted connection where both sides present certificates, rather than just the server. It is how internal services here prove their identity to one another.
A header is a labelled line of metadata at the top of a web request, such as
Accept: application/json. Clients can set any header they like, which is why a
proxy that forwards a security-relevant header must overwrite or delete whatever
the client sent. Stripping a header is deleting it; the bug is that the
stripping list was missing two names.
URL-escaping, or percent-encoding, rewrites characters that are illegal in a
particular context — a newline becomes %0A. Certificates contain newlines and
headers cannot, hence the escaping.
Authentication is establishing who somebody is; authorization is deciding what they may do. This bug is in the first, and the reason a “Permission Denied” response can still be a successful attack is that it means the first question was answered wrongly and only the second one saved you.
Unauthenticated or pre-auth means an attacker needs no account, no password and no prior access — just network reach. Privilege escalation is gaining more access than you should have; when it starts from nothing, the two terms describe the same event.
A confused deputy is this whole category of flaw: a program that legitimately holds some power is tricked into using it for somebody else. The attacker never acquires the power and never steals a credential, which is why there is nothing to find in the traffic.
IAM, identity and access management, is the subsystem holding users, teams,
roles, tokens and policies. A policy binds members to permitted actions; a
token is a long secret string a program uses instead of a password. A principal
that can create policies can grant itself anything, which is why iam:policies:create
is the escalation that matters.
CVE is the global naming scheme for publicly known vulnerabilities; CVSS is the accompanying 0–10 severity score, and this one is a 10. CWE is a classification of kinds of flaw, and CWE-306 is “missing authentication for critical function”. NVD is the US government’s vulnerability database, and CISA KEV is its catalogue of vulnerabilities known to be exploited in the wild.
Patch diffing is the technique this post is built on: take the vulnerable and fixed versions of a program and compare them, on the reasonable assumption that what the vendor changed is a map of what was broken.
A binary is a compiled program, unreadable as text. Stripped means the
names of its functions were removed to save space. .gopclntab is a table Go
leaves in every binary so it can print stack traces, and it survives stripping,
which is why function names and sizes can be recovered from a stripped Go program
— a luxury no other compiled language offers so freely.
Habitat is the packaging system Chef uses to ship its own services, and
Habitat Builder is the public registry those packages live in. A .hart file
is one such package.
A PoC, proof of concept, is code demonstrating a vulnerability is real. It is not the same as a weaponised exploit, and that distinction is why parts of this post describe tooling rather than publishing it.
nuclei is a scanner that runs YAML templates describing how to check for a specific vulnerability; nuclei-templates is the public collection of them. Shodan, Censys and FOFA are search engines for internet-connected machines: they scan continuously, record what answers, and let you query the result. A favicon hash is a fingerprint of the little icon a site serves, and a common way to identify a product across those engines.
An ASN identifies a network operator, so “Amazon 43” means 43 of the hosts sit in Amazon’s address space.
References
- NVD, CVE-2026-80462
- Progress, Critical Security Bulletin — August 2026 — Chef Automate
- Vulnrichment record, VULNRICHMENT:CVE-2026-80462
- Pre-fix source map, github.com/chef/automate (Apache-2.0; tree predates the fix)
- Packages,
chef/automate-gatewayandchef/automate-load-balanceron Habitat Builder — public, no licence required - grpc-gateway’s header convention, which is working as designed:
DefaultHeaderMatcher
All binary analysis was performed on packages downloaded from the vendor’s public registry, on an isolated workstation. The live confirmation was performed against a Chef Automate deployment the operator built, licensed and owns; no third-party instance was contacted at any point, and the exposure figures come from passive scanner indexes rather than from probing. Read-only endpoints were swept and captured; the privilege-escalation drivers described in this post are withheld, and the vendor’s fix in 4.13.520 remains the authoritative remediation.