PYSEC-2026-3675

Vulnerability from pysec - Published: 2026-08-19 11:56 - Updated: 2026-08-19 12:16
VLAI
Details

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/<id>

Affected code

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). <VICTIM_CERT_ID> is any certificate id readable via GET /api/1/certificates.

# 1. Upload an attacker-controlled cert that "replaces" the victim
curl -sS -X POST "<TARGET_BASE_URL>/api/1/certificates/upload" \
  -H "Authorization: Bearer <AUTH_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "attacker-replacement",
        "owner": "attacker@example.com",
        "body": "-----BEGIN CERTIFICATE-----\n<ATTACKER_CERT_PEM>\n-----END CERTIFICATE-----",
        "privateKey": "-----BEGIN PRIVATE KEY-----\n<ATTACKER_KEY_PEM>\n-----END PRIVATE KEY-----",
        "replaces": [{"id": <VICTIM_CERT_ID>}]
      }'

# 2. Observe victim.notify is now false and victim is queued for rotation
curl -sS "<TARGET_BASE_URL>/api/1/certificates/<VICTIM_CERT_ID>" \
  -H "Authorization: Bearer <AUTH_TOKEN>" | jq '.notify, .replaced'

# 3. On the next certificate_rotate Celery beat tick, the attacker cert is
#    deployed to every endpoint that was serving <VICTIM_CERT_ID>.

Static-trace validation command from the source report:

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.

Impacted products
Name purl
lemur pkg:pypi/lemur

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "lemur",
        "purl": "pkg:pypi/lemur"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.5.0"
            },
            {
              "fixed": "1.9.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "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"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-71308",
    "GHSA-cfh6-pv5c-38jv"
  ],
  "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\u0027s 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\u0027s 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) \u2014 `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) \u2014 `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) \u2014 only `StrictRolePermission().can()` gates `/certificates/upload`; no check on `data[\u0027replaces\u0027]`\n- [`lemur/certificates/models.py:506`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/models.py#L506) \u2014 `@event.listens_for(Certificate.replaces, \u0027append\u0027)` 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) \u2014 `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) \u2014 `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) \u2014 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) \u2014 `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\u0027s 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 \u0027{\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      }\u0027\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 \u0027.notify, .replaced\u0027\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 \u0027replaces\u0027 lemur/certificates/schemas.py lemur/certificates/views.py lemur/schemas.py \\\n  \u0026\u0026 grep -n \u0027CertificatePermission\u0027 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.",
  "id": "PYSEC-2026-3675",
  "modified": "2026-08-19T12:16:27.385205Z",
  "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"
    }
  ],
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Lemur: Unchecked `replaces[]` lets any user silence notifications and hijack auto-rotation for arbitrary certificates"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…