Lemur: ACME SSRF + creator-equality IDOR lead to AWS IAM/PKI compromise
| 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. |
Exploit Code & Lab Set-up
Lemur-acme-ssrf-creator-idor.zip
<h2 data-heading="Root Cause Analysis">Root Cause Analysis</h2> <p>The SSRF sink is the load-bearing piece. <code>acme_handlers.py:161-167</code> builds the <code>directory_url</code> from user-supplied options, and <code>:188</code> and <code>:201</code> hand it to <code>ClientV2.get_directory</code> — a <code>requests</code>-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 <code>options</code> 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.</p> <p>The IDOR sink is structurally a "creators are admins of their own thing" decision that no longer holds once ownership becomes transferable. <code>views.py:734</code> was almost certainly written when certificates were considered owned-by-creator and ownership transfer was added later. The original <code>if g.current_user != cert.user:</code> branch should now be <code>if g.current_user != cert.user or cert.owner_changed_after_creation:</code> — or, better, dropped entirely and replaced with a single RBAC check against the <em>current</em> 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 <code>creator_id</code>.</p> <p>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 <code>active=True</code> removes the only human-in-the-loop gate Lemur had.</p> <h2 data-heading="Attack Scenario">Attack Scenario</h2> <pre><code class="language-mermaid">sequenceDiagram participant Attacker participant Lemur as Lemur worker participant IMDS as 169.254.169.254 participant CertDB as Lemur cert DBAttacker->>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"
</code></pre>
<h2 data-heading="Impact Assessment">Impact Assessment</h2> <p>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.</p> <p>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 <code>common_name</code> 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.</p> <p>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 <code>S:C</code> — the impact crosses out of Lemur's security authority and into AWS IAM and PKI consumer trust domains. <code>A:L</code> 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.</p> <h2 data-heading="Remediation">Remediation</h2> <p>Four changes, in priority order:</p> <ol> <li><strong>Allowlist <code>acme_url</code>.</strong> In <code>acme_handlers.py:161-167</code> reject any URL whose host is not in a deployment-pinned allowlist. The default allowlist should be <code>{acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org}</code> plus any internal ACME directory the deployment opts in to. Reject <code>169.254.0.0/16</code>, <code>127.0.0.0/8</code>, <code>10.0.0.0/8</code>, <code>172.16.0.0/12</code>, <code>192.168.0.0/16</code>, <code>fc00::/7</code>, <code>fe80::/10</code>, plus DNS names that resolve to any of those after <code>getaddrinfo</code> (with DNS-rebinding-resistant resolution: resolve once, then connect to the resolved IP).</li> </ol> <pre><code class="language-python">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") </code></pre> <ol start="2"> <li> <p><strong>Drop the creator branch from the key-fetch view.</strong> In <code>certificates/views.py:734</code>, replace the <code>if g.current_user != cert.user:</code> branch with an unconditional <code>CertificatePermission(role_service.get_by_name(cert.owner), [x.name for x in cert.roles]).can()</code> check. The cert's <em>current</em> 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.</p> </li> <li> <p><strong>Stop auto-provisioning SSO users as active.</strong> In <code>auth/views.py:300-308</code>, default new identities to <code>active=False, roles=[]</code> and require an admin invite to flip them on. Or, at minimum, gate auto-provision behind an email-domain allowlist and a default <code>read-only</code> role.</p> </li> <li> <p><strong>Audit-log the creator on every key fetch, separately from <code>g.current_user</code>.</strong> Even after the IDOR is fixed, the operator should be able to retroactively see <em>who actually pulled the key bytes</em> on every cert. Log <code>creator_id</code>, <code>current_owner</code>, <code>g.current_user.id</code>, request IP, and full URL on every read of <code>/certificates/<id>/key</code>.</p> </li> </ol> <h2 data-heading="Related Context">Related Context</h2> <h3 data-heading="External References">External References</h3> <ul> <li>CWE-918: <a href="https://cwe.mitre.org/data/definitions/918.html" class="external-link" target="_blank" rel="noopener nofollow">https://cwe.mitre.org/data/definitions/918.html</a></li> <li>CWE-639: <a href="https://cwe.mitre.org/data/definitions/639.html" class="external-link" target="_blank" rel="noopener nofollow">https://cwe.mitre.org/data/definitions/639.html</a></li> <li>CWE-285: <a href="https://cwe.mitre.org/data/definitions/285.html" class="external-link" target="_blank" rel="noopener nofollow">https://cwe.mitre.org/data/definitions/285.html</a></li> <li>CVSS 3.1 calculator: <a href="https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L" class="external-link" target="_blank" rel="noopener nofollow">https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L</a></li> <li>IMDSv1 vs IMDSv2 background: <a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-options.html" class="external-link" target="_blank" rel="noopener nofollow">https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-options.html</a> (IMDSv2 mitigates SSRF-only chains; this chain still works against any deployment still on IMDSv1, and against any HTTP fetch that the worker is allowed to make).</li> <li>Capital One IMDS SSRF post-mortem (general SSRF→IMDS playbook): public reference, illustrative only.</li> <li>Walkthrough recording: <a href="https://asciinema.org/a/CFYaoR2fxWEIdZDf" class="external-link" target="_blank" rel="noopener nofollow">https://asciinema.org/a/CFYaoR2fxWEIdZDf</a></li>Affected software
Click a technology to see all vulnerabilities affecting it.
| Platform | Package | Vulnerable versions | Fixed in |
|---|---|---|---|
| Python (PyPI) | lemur | < 1.9.2 | 1.9.2 |
References
- CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L- Weakness (CWE)
CWE-285, CWE-639, CWE-918- Platform
- Python (PyPI)
- Package
lemur- Disclosed
- 25.06.2026
- Updated
- 11.10.2026 13:17
Sources and licenses
OSV / GitHub Advisory Database - GHSA-v2wp-frmc-5q3v - CC BY 4.0
OSV / GitHub Advisory Database - PYSEC-2026-384 - PyPA Advisory Database; CC BY 4.0
Vulnerability history: lemur
All published vulnerabilities of this package (Python (PyPI)), newest first. Shows whether this is a one-off slip or a package that needs watching.
- Total entries
- 18
- First → latest
- 08.2017 08.2026
- Without a fix
- 0
- Exploited in attacks
- 0
Severity breakdown
- Critical 1
- High 9
- Medium 8
Disclosures per year
- 2017: 1
- 2018: 0
- 2019: 0
- 2020: 0
- 2021: 0
- 2022: 0
- 2023: 1
- 2024: 0
- 2025: 0
- 2026: 16
-
Lemur: ACME SSRF + creator-equality IDOR lead to AWS IAM/PKI compromise this entry
Know which vulnerabilities affect your application
With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.