GHSA-v2wp-frmc-5q3v — lemur
CRITICALGHSA-v2wp-frmc-5q3v is a critical-severity (CVSS 9.9) CWE-285 vulnerability in lemur. A fix is available for lemur — see the affected versions and patch details below.
Lemur: ACME SSRF + creator-equality IDOR lead to AWS IAM/PKI compromise
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- A successful exploit gives an attacker total control of the affected component, not partial access.
Exploitation and automatability from CISA’s SSVC triage for GHSA-v2wp-frmc-5q3v.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-v2wp-frmc-5q3v plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 379,145 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
lemurReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
| 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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | lemur | all versions | 1.9.2pip install --upgrade 'lemur==1.9.2' |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for lemur, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update lemur to 1.9.2 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-v2wp-frmc-5q3v is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-v2wp-frmc-5q3v can be triaged on real exposure rather than presence alone.
Tailored to GHSA-v2wp-frmc-5q3v. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-v2wp-frmc-5q3v in your dependencies?
O3 Security finds GHSA-v2wp-frmc-5q3v across PyPI dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.