{"id":"PYSEC-2026-3675","summary":"Lemur: Unchecked `replaces[]` lets any user silence notifications and hijack auto-rotation for arbitrary certificates","details":"## Summary\n\nRepo under test: https://github.com/Netflix/lemur\n\nThe 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.\n\nAny 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.\n\n## Affected route\n\n`POST /api/1/certificates`\n`POST /api/1/certificates/upload`\n`PUT /api/1/certificates/\u003cid\u003e`\n\n## Affected code\n\n- [`lemur/certificates/schemas.py:402`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L402) — `replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)` accepted on create/upload/edit\n- [`lemur/schemas.py:152`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L152) — `AssociatedCertificateSchema` resolves any certificate by id/name via `fetch_objects(Certificate, data)` with no permission check\n- [`lemur/certificates/views.py:651`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L651) — only `StrictRolePermission().can()` gates `/certificates/upload`; no check on `data['replaces']`\n- [`lemur/certificates/models.py:506`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/models.py#L506) — `@event.listens_for(Certificate.replaces, 'append')` sets `value.notify = False` on the victim\n- [`lemur/certificates/service.py:277`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L277) — `get_all_pending_reissue()` filters `not_(Certificate.replaced.any())`, excluding the victim\n- [`lemur/certificates/cli.py:347`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/cli.py#L347) — `request_rotation(endpoint, endpoint.certificate.replaced[0], message, commit)` deploys the attacker cert\n- [`lemur/common/celery.py:638`](https://github.com/Netflix/lemur/blob/main/lemur/common/celery.py#L638) — periodic `certificate_rotate` task runs `cli_certificate.rotate(..., commit=True)`\n- [`lemur/deployment/service.py:17`](https://github.com/Netflix/lemur/blob/main/lemur/deployment/service.py#L17) — `endpoint.source.plugin.update_endpoint(endpoint, new_cert)` pushes to ELB/CloudFront/ACM/K8s\n\n## Impact\n\nAn authenticated insider or holder of a stolen low-privilege token can, without holding any role on a target certificate:\n\n1. Upload a self-signed or attacker-minted certificate listing arbitrary high-value production certificate IDs in `replaces`.\n2. Immediately disable expiration notifications and auto-reissue for those production certificates.\n3. 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.\n\nMinimum 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.\n\n## Root cause\n\n`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.\n\n## Validated evidence\n\nStatic trace, confirmed by code inspection (validation status: `CONFIRMED`):\n\n- `replaces` is accepted in `CertificateInputSchema` / `CertificateUploadInputSchema` and resolved via `fetch_objects(Certificate, ...)` with no per-object authorization.\n- `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`.\n- The Celery `certificate_rotate` task and `cli.rotate()` consume `Endpoint.replaced.any()` unconditionally and deploy `replaced[0]` with `commit=True`.\n\n## Proof of concept / reproducer\n\nStatus: reconstructed from source report (static control-flow trace; not executed against a live CA).\n\nPreconditions: attacker is an authenticated Lemur user holding any role other than `read-only` (default `StrictRolePermission` config). `\u003cVICTIM_CERT_ID\u003e` is any certificate id readable via `GET /api/1/certificates`.\n\n```bash\n# 1. Upload an attacker-controlled cert that \"replaces\" the victim\ncurl -sS -X POST \"\u003cTARGET_BASE_URL\u003e/api/1/certificates/upload\" \\\n  -H \"Authorization: Bearer \u003cAUTH_TOKEN\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"name\": \"attacker-replacement\",\n        \"owner\": \"attacker@example.com\",\n        \"body\": \"-----BEGIN CERTIFICATE-----\\n\u003cATTACKER_CERT_PEM\u003e\\n-----END CERTIFICATE-----\",\n        \"privateKey\": \"-----BEGIN PRIVATE KEY-----\\n\u003cATTACKER_KEY_PEM\u003e\\n-----END PRIVATE KEY-----\",\n        \"replaces\": [{\"id\": \u003cVICTIM_CERT_ID\u003e}]\n      }'\n\n# 2. Observe victim.notify is now false and victim is queued for rotation\ncurl -sS \"\u003cTARGET_BASE_URL\u003e/api/1/certificates/\u003cVICTIM_CERT_ID\u003e\" \\\n  -H \"Authorization: Bearer \u003cAUTH_TOKEN\u003e\" | jq '.notify, .replaced'\n\n# 3. On the next certificate_rotate Celery beat tick, the attacker cert is\n#    deployed to every endpoint that was serving \u003cVICTIM_CERT_ID\u003e.\n```\n\nStatic-trace validation command from the source report:\n\n```bash\ngrep -n 'replaces' lemur/certificates/schemas.py lemur/certificates/views.py lemur/schemas.py \\\n  && grep -n 'CertificatePermission' lemur/certificates/views.py\n```\n\nSource 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).\n\n## Suggested fix\n\nBefore 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`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1677)); 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.","aliases":["CVE-2026-71308","GHSA-cfh6-pv5c-38jv"],"modified":"2026-08-19T12:45:11.230354189Z","published":"2026-08-19T11:56:28.471176Z","references":[{"type":"WEB","url":"https://github.com/Netflix/lemur/security/advisories/GHSA-cfh6-pv5c-38jv"},{"type":"WEB","url":"https://github.com/Netflix/lemur/commit/286874535160952143b0afe2d356642669f9d4c6"},{"type":"PACKAGE","url":"https://github.com/Netflix/lemur"},{"type":"WEB","url":"https://github.com/Netflix/lemur/releases/tag/v1.9.3"},{"type":"PACKAGE","url":"https://pypi.org/project/lemur"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-cfh6-pv5c-38jv"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-71308"}],"affected":[{"package":{"name":"lemur","ecosystem":"PyPI","purl":"pkg:pypi/lemur"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.5.0"},{"fixed":"1.9.3"}]}],"versions":["0.11.0","0.8.0","0.8.1","0.9.0","1.0.0","1.1.0","1.2.0","1.3.1","1.3.2","1.4.0","1.5.0","1.6.0","1.7.0","1.8.0","1.8.1","1.8.2","1.9.0","1.9.1","1.9.2"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/lemur/PYSEC-2026-3675.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H"}]}