# CVE-2026-123710: Red Hat Quay service key signing-key takeover, detection pack

CybaVerse detection and review pack for CVE-2026-123710 in Red Hat Quay: an unauthenticated client can use Quay's service key
server (`/keys`) to install its own approved signing key with no expiry, then use registry tokens signed with it against any
repository.

**Mitigate now: restrict `/keys` at the proxy or load balancer so only trusted internal components can reach it.** Red Hat
listed no fixed build when this pack was written (11 Nov 2026). Deploy the fix as soon as Red Hat ships one.

The KQL in this pack has NOT been run against a live tenant. It parses offline against an assumed schema subset. The Sigma
rules convert offline only. Test and tune everything before you rely on it.

Facts checked on 11 Nov 2026 between 16:04 and 16:20 GMT against the primary sources listed at the end.

## Verified facts

| Item | Value | Source |
|---|---|---|
| CNA | Red Hat | cve.org |
| Title | "Quay: red hat quay: unauthenticated registry signing-key takeover via service key rotation" | cve.org |
| Severity | Red Hat rating: Critical; Red Hat CVSS 3.1 base score 9.8, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | Red Hat CVE page, cve.org |
| Score status | the Red Hat CVE page says "The following CVSS metrics and score provided are preliminary and subject to review." | Red Hat CVE page |
| Weakness | CWE-1287, Improper Validation of Specified Type of Input | cve.org, Red Hat |
| Fix state | every listed product is "Affected" with no advisory; Red Hat's VEX file marks the remediation "none_available"; there is no fixed build | Red Hat CVE page, Red Hat VEX |
| Red Hat timeline | reported to Red Hat 6 Nov 2026 09:44 GMT; made public 11 Nov 2026 14:00 GMT | cve.org, Red Hat VEX |
| CVE record | reserved 6 Nov 2026 10:23 GMT; published 11 Nov 2026 15:02 GMT | cve.org |
| NVD | Awaiting Analysis; published 11 Nov 2026 15:17 GMT; the only CVSS metric is Red Hat's 9.8, listed by NVD as type Primary | NVD |
| Bugzilla | 2568072, status NEW, priority and severity urgent, created 6 Nov 2026 10:22 GMT | Red Hat Bugzilla |
| CISA KEV | not in KEV catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT), which predates the CVE | CISA KEV |
| Credit | Red Hat | Red Hat CVE page, cve.org |

Credit: Red Hat. Red Hat's records say the issue was discovered by Red Hat.

### Affected products, per stream

| Product | Image (Red Hat package name) | State | Advisory |
|---|---|---|---|
| Red Hat Quay 3 | quay/quay-rhel8 | Affected | none |
| Red Hat Quay 3 | quay/quay-rhel9 | Affected | none |
| Red Hat Quay 5 | quay/quay-rhel9 | Affected | none |

Red Hat's page adds: "Unless explicitly stated as not affected, all previous versions of packages in any minor update stream
of a product listed here should be assumed vulnerable". Red Hat's statement: "Deployments where /keys is reachable by untrusted
clients are affected."

### What Red Hat says happens

Red Hat's description, quoted: "A flaw was found in Red Hat Quay. An unauthenticated client can obtain an anonymous registry
token with an audience it controls, present that token to the service key rotation endpoint, and install its own approved
signing key with no expiry. Registry tokens signed with that key are accepted for any repository, allowing read and write
access to private images and replacement of repository tags."

Red Hat's statement adds that key rotation checks only that the JWT "is signed by an already approved service key with the
expected audience and issuer, without checking that the token is a rotation credential", that "An anonymous token minted by
/v2/auth satisfies that check", and that installed keys "inherit approval and can be created without an expiry".

The public Bugzilla entry adds detail (Red Hat's words, summarised): `/v2/auth` mints such a token for anonymous callers when
`FEATURE_ANONYMOUS_ACCESS` is enabled, taking the audience from the client's `service` parameter; the attacker PUTs its own JWK
to `/keys/services/quay/keys/<kid>`; the new key copies the signer's approval and is stored with no expiration when the
`expiration` parameter is left out; rotation also deletes the legitimate instance key, and the attacker key can delete
replacement instance keys. Bugzilla says this was confirmed against master 9226d3a5 and the 3.19-unstable image.

### Checked in Quay's own source code

We read Quay's public source (github.com/quay/quay, master at 7a361d8c, committed 11 Nov 2026 14:32 GMT):

* The key server is registered at `/keys` (`web.py`). Its routes (`endpoints/keyserver/__init__.py`):
  `GET /keys/services/<service>/keys`, `GET /keys/services/<service>/keys/<kid>`, `GET /keys/services/<service>/keys/<kid>/status`,
  `PUT /keys/services/<service>/keys/<kid>` (create, or rotate when signed by another key; optional `expiration` and `rotation`
  query parameters) and `DELETE /keys/services/<service>/keys/<kid>`.
* At that commit `_validate_jwt` still checks only the RS256 signature, the audience (`PREFERRED_URL_SCHEME://SERVER_HOSTNAME`)
  and the issuer (the service name), and `replace_service_key` copies the signer's approval to the new key and deletes the
  signer (`data/model/service_keys.py`). This matches Red Hat's description; it is not a statement about any shipped build.
* `/v2/auth` reads the audience from `request.args.get("service")` (`endpoints/v2/v2auth.py`). Registry clients are told
  `service="<SERVER_HOSTNAME>"` in the `WWW-Authenticate` header (`auth/registry_jwt_auth.py`), and registry tokens are
  validated against `SERVER_HOSTNAME` (`util/security/registry_jwt.py`). `FEATURE_ANONYMOUS_ACCESS` defaults to true in
  `config.py`. The default instance key service is `quay` (`INSTANCE_SERVICE_KEY_SERVICE`).
* Audit log event kinds for service keys (`initdb.py`): `service_key_create`, `service_key_approve`, `service_key_delete`,
  `service_key_modify`, `service_key_extend`, `service_key_rotate`. The key server logs `service_key_create`, `service_key_rotate`
  (metadata: kid, signer_kid, service, name, expiration_date, user_agent, ip) and `service_key_delete` (metadata: kid,
  signer_kid, service, name, user_agent, ip; no expiration_date field). The superuser API logs create, approve, modify, extend
  and delete.
* Tag and push event kinds used by the checker: `push_repo`, `create_tag`, `move_tag`, `delete_tag`, `revert_tag`,
  `change_tag_expiration`.
* Superuser API: `GET /api/v1/superuser/keys` lists all service keys (`{"keys": [...]}` with kid, service, name, jwk, metadata,
  created_date, expiration_date, rotation_duration, approval); `/api/v1/superuser/approvedkeys/<kid>` approves; `GET
  /api/v1/superuser/logs` returns `{"logs": [...]}`. Log exports end with a `{"terminator": true}` entry.
* Quay's image sends nginx and gunicorn output to stdout (`Dockerfile`, `conf/supervisord.conf.jnj`), with nginx access lines in
  the `lb_logs` format (`conf/nginx/http-base.conf.jnj`). Quay's bundled nginx config sends `/keys` to the web app through a
  shared regex location with no restriction (`conf/nginx/server-base.conf.jnj`).

## Advice

### Red Hat's mitigation (Red Hat)

Quoted: "Restrict access to the /keys endpoint at the proxy or load balancer so only trusted internal components can reach it.
Review audit logs for service_key_rotate and service_key_delete events with a null expiration_date, and remove any unrecognised
approved service keys. Deleting a rogue key is not durable until a fixed build is deployed, because the anonymous request can
install a new key."

The audit log review and the removal of unrecognised keys are Red Hat's steps. The checker in this pack helps with them; the
steps are not ours. Red Hat's page lists no fixed build and no advisory yet.

### Ours (CybaVerse, OUR ADVICE)

* Block `/keys` from untrusted networks now, at every proxy, load balancer, route or ingress in front of Quay. Quay's own
  bundled nginx does not restrict it. In nginx, a plain prefix `location /keys` is overridden by a matching regex location;
  use `location ^~ /keys/` or a regex location placed first. Run `scripts/check-quay-cve-2026-123710.sh --proxy-config` on the
  config as a first pass, then test.
* Check internet exposure: from outside your network, request `GET /keys/services/quay/keys` on every Quay host name and
  confirm it is blocked. Check OpenShift routes and cloud load balancers too.
* Deploy the fix when Red Hat ships it, and recheck the service keys afterwards.
* If a rogue key turns up: treat every repository tag as untrusted until you have compared its digest with the digest your
  build system pushed, and review pushes and tag moves since the key appeared (Q6, Q6b and the checker's tag event counts).
  Remove the key as Red Hat says, but block `/keys` first: Red Hat says deleting a rogue key does not last until a fixed build
  is deployed. Check that the legitimate instance key is still present; Bugzilla says rotation deletes it.
* Keep a list of the services and kids your deployment uses, so an unknown approved key stands out.

## Pack contents

| Path | What |
|---|---|
| `kql/redhat-quay-cve-2026-123710-hunting.kql` | 18 KQL hunts for Defender XDR and Sentinel |
| `sigma/*.yml` | 5 Sigma rules (all OUR PATTERN) |
| `sigma/converted/kusto_*.txt` | offline sigma-cli conversions for five pipelines |
| `scripts/check-quay-cve-2026-123710.sh` | read-only checker, bash (POSIX awk JSON parser) |
| `scripts/Check-QuayCve2026123710.ps1` | read-only checker, PowerShell 7 (same output as the bash checker) |
| `tests/` | fixtures (FIXTURE_ONLY), cases, bash and Pester tests, KQL parse check, `run_all.sh` gates |

## KQL hunts

Q1 to Q1f are INVENTORY. Q2 onwards are OUR PATTERNS, built from Red Hat's description and Quay's source. No IOCs have been
published and none of these hunts prove exploitation. Request bodies and Authorization headers are usually not logged, so the
JWK and the token are not visible; the hunts use URLs, methods, query strings and client addresses. Service key audit events
live in Quay's audit log (the database by default), not in container output: use the checker on an export for those.

| ID | Label | Table | Looks for |
|---|---|---|---|
| Q1 | INVENTORY | ContainerInventory | quay-rhel8 and quay-rhel9 containers |
| Q1b | INVENTORY | KubePodInventory | pods and containers named quay (operator naming UNVERIFIED) |
| Q1c | INVENTORY | ContainerLogV2 | pods writing Quay output |
| Q1d | INVENTORY | DeviceProcessEvents | Quay processes (/quay-registry/, quay-entrypoint.sh, gunicorn_registry.py) |
| Q1e | INVENTORY | DeviceTvmSoftwareInventory | Quay in Defender inventory (likely empty) |
| Q1f | INVENTORY (ingest check) | DeviceTvmSoftwareVulnerabilitiesKB | whether Defender knows the CVE yet |
| Q2 | OUR PATTERN | CommonSecurityLog | /keys/services/ requests from non-private sources |
| Q2b | OUR PATTERN | AzureDiagnostics | the same for Application Gateway or Front Door (columns UNVERIFIED) |
| Q2c | OUR PATTERN | ContainerLogV2 | PUT and DELETE to /keys/services/ in Quay's nginx lines |
| Q2d | OUR PATTERN | ContainerLog | the same for legacy ContainerLog |
| Q3 | OUR PATTERN | CommonSecurityLog | /v2/auth with a URL in the service parameter |
| Q3b | OUR PATTERN | AzureDiagnostics | the same |
| Q3c | OUR PATTERN | ContainerLogV2 | the same in Quay's nginx lines |
| Q4 | OUR PATTERN | ContainerLogV2 | key server error lines (JWT validation failure and similar) |
| Q5 | OUR PATTERN | ContainerLogV2 | a URL-audience /v2/auth request followed by a /keys PUT within an hour |
| Q6 | OUR PATTERN | ContainerLogV2 | manifest PUTs (tag pushes) from addresses new in 14 days |
| Q6b | OUR PATTERN | CommonSecurityLog | the same at the WAF or proxy |
| Q7 | OUR PATTERN | ContainerLogV2 | non-GET calls to the superuser service key API |

The "URL in the service parameter" pattern (Q3 to Q3c, Q5, and two Sigma rules) is our inference from Quay's source: registry
clients send the bare host name, while the key server wants `scheme://host`. It is UNVERIFIED that an attack always looks like
this, or that no legitimate client sends a URL there.

## Sigma rules

All rules are OUR PATTERN, status experimental, and pass `sigma check` with 0 issues.

| Rule | Logsource | Looks for |
|---|---|---|
| `web_quay_keyserver_put_delete.yml` | webserver | PUT or DELETE to /keys/services/ |
| `web_quay_keyserver_access.yml` | webserver | any request under /keys/ (exposure; filter trusted clients) |
| `web_quay_v2auth_url_audience.yml` | webserver | /v2/auth with a URL in service |
| `proxy_quay_keyserver_put_delete.yml` | proxy | PUT or DELETE to /keys/services/ |
| `proxy_quay_v2auth_url_audience.yml` | proxy | /v2/auth with a URL in service (c-uri or c-uri-query) |

Conversion with sigma-cli (Kusto backend), offline:

| Pipeline | Result |
|---|---|
| none | all 5 rules convert (generic field names) |
| microsoft_xdr | none convert: no table for webserver or proxy |
| microsoft_365_defender | none convert: same gaps |
| sentinel_asim | the 2 proxy rules convert to imWebSession; the 3 webserver rules do not |
| azure_monitor | none convert: same gaps |

For the gaps, use the KQL hunts (Q2 to Q3c). In the ASIM conversion of the proxy URL-audience rule, c-uri and c-uri-query both map
to Url, so the condition repeats; it is harmless.

## Checker

Strictly read-only: it reads files an admin exports and hands over. It opens no network connections, runs nothing and writes
nothing. There is no version verdict, because there is no fixed build.

```
bash scripts/check-quay-cve-2026-123710.sh --keys keys.json --logs superuser-logs.json --proxy-config nginx.conf
bash scripts/check-quay-cve-2026-123710.sh --keys keys.json --services quay,clair     # services you expect to own keys
pwsh scripts/Check-QuayCve2026123710.ps1 --keys keys.json
```

Inputs:

* `--keys`: output of `GET /api/v1/superuser/keys`, or a JSON array of servicekey rows from a database export (`approval_id` is
  accepted in place of `approval`).
* `--logs`: output of `GET /api/v1/superuser/logs` or a log export (`{"logs": [...]}`), or a JSON array. Service key events
  have no namespace, so use the superuser logs and cover the time since the deployment was first reachable.
* `--proxy-config`: one nginx or HAProxy config file (OUR HEURISTIC: comments stripped, includes and separate server blocks not
  followed).

| Verdict | Meaning | Exit |
|---|---|---|
| SUSPICIOUS | an approved key with no expiration or for a service outside `--services`; a `service_key_rotate` with no expiration; a `service_key_delete` signed by a different key | 1 |
| CHECK EXPOSURE | nothing SUSPICIOUS, but the proxy config shows no effective `/keys` restriction (NOT RESTRICTED or UNCLEAR) | 5 |
| UNKNOWN | a key could not be judged, or an export was empty | 3 |
| NO FINDINGS | nothing matched in what was supplied | 0 |
| usage or read error | bad arguments, unreadable file, invalid JSON, no keys or logs array | 4 |

NOTE lines list every other service key event and count tag and push events. EXPOSURE lines say RESTRICTED, NOT RESTRICTED or
UNCLEAR. A superuser can create a key with no expiration on purpose, so SUSPICIOUS means review it, not proof.

## Tests

`bash tests/run_all.sh` runs every gate: ShellCheck, PSScriptAnalyzer, the bash tests, Pester (including byte-for-byte parity
between the bash and PowerShell checkers), wording and link guards, sandboxed checker runs (no network, read-only filesystem,
pack sha256 unchanged), the offline KQL parse, `sigma check` and the Sigma conversions. `REGEN=1` rewrites `sigma/converted/`.

## Limits and unverified items

* The KQL has not been run against a live tenant; the Sigma conversions have not been run anywhere.
* No IOCs have been published. Client addresses, kids and host names in the fixtures are synthetic.
* UNVERIFIED: that an attack request always carries a URL in the `service` parameter (Q3 to Q3c, Q5 and two Sigma rules).
* UNVERIFIED: Quay Operator pod and container names (Q1b, Q1c and the ContainerLogV2 filters), whether the key server's error
  lines reach stdout at your log level (Q4), and AzureDiagnostics and CommonSecurityLog field use in your workspace.
* Behind a load balancer, Quay's nginx sees the balancer's address; client addresses need X-Forwarded-For or PROXY protocol.
* Our reading of the source: a logged-in user's token request also takes the audience from `service`, so turning off
  anonymous access may not be enough on its own. Red Hat does not list it as a mitigation. Restrict `/keys`.
* The proxy check is a heuristic over one file. It does not follow includes, OpenShift routes, ingress objects or cloud load
  balancer rules. Always test from an untrusted network.
* This pack makes no claim about whether the flaw is being exploited.

## Sources (primary)

* Red Hat CVE page: https://access.redhat.com/security/cve/CVE-2026-123710
* Red Hat VEX: https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-123710.json
* Red Hat Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=2568072
* CVE record: https://www.cve.org/CVERecord?id=CVE-2026-123710
* NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-123710
* Quay source: https://github.com/quay/quay (master, commit 7a361d8c)
* CISA KEV catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
