GitHub Advisoryadvisory · PyPI

GHSA-cfh6-pv5c-38jv Lemur: Unchecked `replaces[]` lets any user silence notifications and hijack auto-rotation for arbitrary certificates

HIGH Published 2026-08-18 Modified 2026-08-18 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

Weakness · CWE (2)

  • CWE-639 Authorization Bypass Through User-Controlled Key weakness
  • CWE-862 Missing Authorization weakness

References