GitHub Advisoryadvisory · PyPI
GHSA-pxmc-2ffp-8j67 Lemur: Any user can revoke arbitrary certificates at the CA by uploading a duplicate record and revoking it
HIGH
Official page ↗
- Aliases
- CVE-2026-71417
- Ecosystem
- PyPI
- Packages
- lemur
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:H
- Weaknesses
- CWE-639
- Reviewed
- 2026-08-18
Affected packages
• PyPI lemur: < 1.9.3 → fixed in 1.9.3
Description
Summary
Repo under test: https://github.com/Netflix/lemur
`PUT /api/1/certificates//revoke` authorizes the caller against the *Lemur database row* (creator == current user, or `CertificatePermission` over the row's roles) rather than the underlying CA-side certificate identity. Separately, `POST /api/1/certificates/upload` lets any user passing `StrictRolePermission` create a new `Certificate` row while freely supplying `body`, `authority` (resolved by id/name with no `AuthorityPermission` check) and `external_id`; there is no uniqueness constraint on `body`, `serial`, or `external_id`.
An attacker can therefore read a target certificate's public `body`, `authority.id`, and `external_id` via `GET /certificates/`, upload a duplicate row, and revoke that duplicate. The creator-bypass skips `CertificatePermission`, the empty-endpoints check passes because the duplicate has none, and `service.revoke()` then revokes at the CA using the attacker-supplied `body` (ACME) or `external_id` (DigiCert/Entrust/Google CA/CFSSL) under the authority's stored CA credentials — revoking the real production certificate.
Affected route
`POST /api/1/certificates/upload` → `PUT /api/1/certificates//revoke`
Affected code
• `lemur/certificates/views.py:651` — only `StrictRolePermission().can()` gates upload; no `AuthorityPermission` check
• `lemur/certificates/schemas.py:391` — `CertificateUploadInputSchema` accepts caller-supplied `authority` and `external_id`
• `lemur/schemas.py:107` — `AssociatedAuthoritySchema` resolves any authority by id/name with no permission check
• `lemur/certificates/service.py:489` — `upload()` binds the caller-supplied authority onto the new row
• `lemur/certificates/models.py:119` — only `name` is unique; `body`/`serial`/`external_id` are not, and `get_or_increase_name()` auto-suffixes on collision
• `lemur/certificates/views.py:1677` — `if g.current_user != cert.user: ... CertificatePermission ...` — creator of the duplicate row bypasses the owner check
• `lemur/certificates/views.py:1687` — `if cert.endpoints: ...` — duplicate row has no endpoints, so the deployed-cert safeguard is bypassed
• `lemur/certificates/service.py:1120` — `plugin = plugins.get(certificate.authority.plugin_name); plugin.revoke_certificate(certificate, reason)`
• `lemur/plugins/lemur_acme/acme_handlers.py:268` — ACME revokes by `certificate.body`
• `lemur/plugins/lemur_digicert/plugin.py:477`, `lemur/plugins/lemur_entrust/plugin.py:321` — commercial CAs revoke by `certificate.external_id`
• `lemur/certificates/schemas.py:290` — `CertificateOutputSchema` exposes `external_id`, `body`, `authority` to any authenticated user
Impact
A low-privileged authenticated insider (or holder of a stolen non-admin token/API key) can revoke any certificate managed by Lemur — including high-value certificates they do not own and certificates currently attached to live endpoints — directly at the issuing CA. Iterating over `GET /certificates` yields fleet-wide revocation (mass DoS of TLS endpoints) without ever passing an `AuthorityPermission` or `CertificatePermission` check on the victim certificate. This is exactly the "Revocation as DoS vector" scenario flagged in the threat model and additionally defeats the built-in "cannot revoke while attached to endpoint" safeguard.
Root cause
Revocation authority is bound to ownership of the Lemur DB row, not to the CA-side certificate identity. Because upload allows creating a second row that aliases the same CA-side certificate (same `body` / `external_id` / `authority`) without any uniqueness constraint or `AuthorityPermission` check, the attacker can manufacture a row they own and then exercise the creator-bypass on revoke. The endpoint-attached guard inspects only the duplicate row's `cert.endpoints`, which is empty.
Validated evidence
Static path trace, confirmed by code inspection (validation status: `CONFIRMED`):
• Upload is gated only by `StrictRolePermission` (default-open to any non-read-only user) and accepts attacker-chosen `authority` + `external_id` + `body` without an `AuthorityPermission` check.
• `Certificate.body` / `external_id` have no uniqueness constraint, so a duplicate row is created.
• The revoke endpoint short-circuits the owner check when caller is the row creator, and the endpoint-attached guard inspects only the duplicate row.
• Issuer plugins revoke at the CA using `body` / `external_id` taken from the duplicate row under the authority's stored CA credentials.
Proof of concept / reproducer
Status: reconstructed from source report (static control-flow trace; not executed against a live CA — revoking at a real CA is destructive).
Preconditions: attacker is an authenticated Lemur user holding any role other than `read-only`. `` is any certificate id readable via `GET /api/1/certificates`.
```bash
1. Read the victim's body / authority / external_id (exposed to any authenticated user)
curl -sS "/api/1/certificates/" \
-H "Authorization: Bearer " \
| jq '{body, external_id, authority: .authority.id}'
2. Upload a duplicate row aliasing the same CA-side certificate
curl -sS -X POST "/api/1/certificates/upload" \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"name": "victim-dup",
"owner": "[email protected]",
"body": "",
"authority": {"id": },
"externalId": ""
}'
→ returns {"id": , ...}; attacker is now cert.user of
3. Revoke the duplicate — issuer plugin revokes at the CA by body/external_id
curl -sS -X PUT "/api/1/certificates//revoke" \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"crlReason": "unspecified"}'
```
Static-trace validation command from the source report:
```bash
grep -n 'StrictRolePermission' lemur/certificates/views.py | grep -v Authority
grep -n 'cert.authority = kwargs.get' lemur/certificates/service.py
grep -n 'g.current_user != cert.user' lemur/certificates/views.py
grep -n 'certificate.external_id\|certificate.body' \
lemur/plugins/lemur_acme/acme_handlers.py \
lemur/plugins/lemur_digicert/plugin.py \
lemur/plugins/lemur_entrust/plugin.py
```
Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_050, finding cluster `lemur-revoke-via-duplicate`, 2/100 runs).
Suggested fix
Decouple CA-side revocation authority from Lemur row ownership:
1. On `POST /certificates/upload`, if `authority` is supplied enforce `AuthorityPermission` for that authority, and reject/ignore caller-supplied `external_id`.
2. Before calling `plugin.revoke_certificate`, look up all `Certificate` rows sharing the same `(authority_id, serial)` or `body` and require `CertificatePermission` on every match (and run the en…
Related
Alias · CVE (1)
- CVE-2026-71417 Lemur manages TLS certificate creation. vulnerability
Weakness · CWE (1)
- CWE-639 Authorization Bypass Through User-Controlled Key weakness