GitHub Advisoryadvisory · PyPI
GHSA-cfh6-pv5c-38jv Lemur: Unchecked `replaces[]` lets any user silence notifications and hijack auto-rotation for arbitrary certificates
HIGH
Official page ↗
- Aliases
- CVE-2026-71308
- Ecosystem
- PyPI
- Packages
- lemur
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
- Weaknesses
- CWE-639, CWE-862
- Reviewed
- 2026-08-18
Affected packages
• PyPI lemur: >= 0.5.0, < 1.9.3 → fixed in 1.9.3
Description
Summary
Repo under test: https://github.com/Netflix/lemur
The certificate create and upload endpoints accept a `replaces[]` (alias `replacements`) array that is resolved to live `Certificate` ORM objects with no ownership or `CertificatePermission` check on the referenced certificates. The SQLAlchemy `Certificate.replaces` append listener then immediately sets `victim.notify = False` and populates `victim.replaced`. From that point the victim certificate is excluded from auto-reissue, its expiration notifications are silenced, and the periodic `certificate_rotate` Celery task deploys the attacker's certificate (`endpoint.certificate.replaced[0]`) onto every endpoint serving the victim certificate.
Any authenticated non-read-only user can therefore silently substitute their own certificate onto production load balancers and Kubernetes secrets they hold no role on, while suppressing the legitimate certificate's lifecycle automation.
Affected route
`POST /api/1/certificates`
`POST /api/1/certificates/upload`
`PUT /api/1/certificates/`
Affected code
• `lemur/certificates/schemas.py:402` — `replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)` accepted on create/upload/edit
• `lemur/schemas.py:152` — `AssociatedCertificateSchema` resolves any certificate by id/name via `fetch_objects(Certificate, data)` with no permission check
• `lemur/certificates/views.py:651` — only `StrictRolePermission().can()` gates `/certificates/upload`; no check on `data['replaces']`
• `lemur/certificates/models.py:506` — `@event.listens_for(Certificate.replaces, 'append')` sets `value.notify = False` on the victim
• `lemur/certificates/service.py:277` — `get_all_pending_reissue()` filters `not_(Certificate.replaced.any())`, excluding the victim
• `lemur/certificates/cli.py:347` — `request_rotation(endpoint, endpoint.certificate.replaced[0], message, commit)` deploys the attacker cert
• `lemur/common/celery.py:638` — periodic `certificate_rotate` task runs `cli_certificate.rotate(..., commit=True)`
• `lemur/deployment/service.py:17` — `endpoint.source.plugin.update_endpoint(endpoint, new_cert)` pushes to ELB/CloudFront/ACM/K8s
Impact
An authenticated insider or holder of a stolen low-privilege token can, without holding any role on a target certificate:
1. Upload a self-signed or attacker-minted certificate listing arbitrary high-value production certificate IDs in `replaces`.
2. Immediately disable expiration notifications and auto-reissue for those production certificates.
3. On the next scheduled `certificate_rotate` Celery run, have the attacker's certificate pushed to every endpoint (AWS ELB/CloudFront/ACM, Kubernetes, SFTP, etc.) currently serving the victim certificate, while the legitimate certificate is detached.
Minimum impact is fleet-wide TLS denial of service equivalent to mass revocation. Where internal clients trust the substituted chain (or combined with the sub-CA finding LEMUR-BUG-07), it escalates to TLS interception. This directly violates the invariant that a user may only modify or revoke a certificate if they are its owner, a member of an owning role, or an administrator.
Root cause
`AssociatedCertificateSchema.get_object` calls `fetch_objects(Certificate, data)` and returns the ORM rows verbatim. No caller on the create/upload/edit path iterates the resolved `replaces` list to enforce `CertificatePermission` before the model assigns them, and the `Certificate.replaces` append event listener mutates the victim row (`notify = False`) as a side effect of ORM collection assignment. The direct revoke endpoint *does* enforce `CertificatePermission`, but this `replaces` path achieves an equivalent or worse outcome while bypassing it entirely.
Validated evidence
Static trace, confirmed by code inspection (validation status: `CONFIRMED`):
• `replaces` is accepted in `CertificateInputSchema` / `CertificateUploadInputSchema` and resolved via `fetch_objects(Certificate, ...)` with no per-object authorization.
• `grep -n CertificatePermission lemur/certificates/views.py` shows the check is applied to `PUT`/`DELETE`/`revoke`/`export` paths but never to the `replaces` payload of `POST /certificates` or `POST /certificates/upload`.
• The Celery `certificate_rotate` task and `cli.rotate()` consume `Endpoint.replaced.any()` unconditionally and deploy `replaced[0]` with `commit=True`.
Proof of concept / reproducer
Status: reconstructed from source report (static control-flow trace; not executed against a live CA).
Preconditions: attacker is an authenticated Lemur user holding any role other than `read-only` (default `StrictRolePermission` config). `` is any certificate id readable via `GET /api/1/certificates`.
```bash
1. Upload an attacker-controlled cert that "replaces" the victim
curl -sS -X POST "/api/1/certificates/upload" \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"name": "attacker-replacement",
"owner": "[email protected]",
"body": "-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----",
"privateKey": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----",
"replaces": [{"id": }]
}'
2. Observe victim.notify is now false and victim is queued for rotation
curl -sS "/api/1/certificates/" \
-H "Authorization: Bearer " | jq '.notify, .replaced'
3. On the next certificate_rotate Celery beat tick, the attacker cert is
deployed to every endpoint that was serving .
```
Static-trace validation command from the source report:
```bash
grep -n 'replaces' lemur/certificates/schemas.py lemur/certificates/views.py lemur/schemas.py \
&& grep -n 'CertificatePermission' lemur/certificates/views.py
```
Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_083, finding cluster `lemur-replaces-unauth`, 7/100 runs).
Suggested fix
Before persisting `replaces`/`replacements` on certificate create, upload, and edit, iterate each referenced certificate and enforce the same `CertificatePermission(owner_role, cert.roles)` check used by the revoke endpoint (`views.py:1677-1685`); reject with 403 if the caller is not creator/owner/role-member/admin for any target. Additionally, move the `value.notify = False` side effect out of the SQLAlchemy append listener so an authorization failure cannot leave a victim certificate partially mutated, and emit an `audit_log` entry whenever a certificate is marked as replaced.
Related
Alias · CVE (1)
- CVE-2026-71308 Lemur manages TLS certificate creation. vulnerability