Multipart/form-data a menudo se percibe como un «transporte para archivos»: el navegador ensambló las partes, el servidor las analizó y luego sigue la lógica de negocio. Pero precisamente en la intersección del formato, las especificaciones y las bibliotecas surgen efectos inesperados. El atacante aprovecha matices: campos duplicados, interpretación ambigua del boundary, parámetros especiales en Content-Disposition, nombres enmarañados con «corchetes». Resultado: la validación pasa, pero al sistema llega algo muy distinto de lo esperado.
Qué es multipart/form-data y dónde tiene puntos débiles
multipart/form-data divide el contenido en partes (parts) separadas por una cadena límite (boundary). Cada parte tiene cabeceras (normalmente Content-Disposition con name y, al subir, filename; a veces Content-Type) y un cuerpo: sea un valor de texto o los bytes de un archivo.
Los errores y discrepancias suelen referirse a:
- el reconocimiento de las fronteras (comportamiento frente a CRLF extra/faltantes, espacios, fronteras anidadas);
- el tratamiento de parámetros de cabecera (
filename,filename*, comillas, escape, espacios, codificaciones); - campos duplicados (qué hacer si llegan varios
role=...); - interpretación de nombres como
user[role](para unos es una estructura anidada, para otros — solo una cadena); - confiar en el declarado
Content-Typeen lugar de verificar el contenido; - límites de recursos (muchas partes pequeñas, cabeceras enormes, ficheros temporales).
A continuación — un ejemplo de datos «en crudo» de una petición como referencia:
POST /upload HTTP/1.1
Host: example.test
Content-Type: multipart/form-data; boundary=----AaB03x
Content-Length: 348
------AaB03x
Content-Disposition: form-data; name="title"
Foto de las vacaciones
------AaB03x
Content-Disposition: form-data; name="file"; filename="beach.jpg"
Content-Type: image/jpeg
<bytes JPEG>
------AaB03x--
Qué entendemos por inyección Multipart/Form-Data
Por «inyección» aquí entenderemos cualquier forma de construir el cuerpo multipart para que el analizador del servidor vea una estructura o valores distintos a los que espera el código de aplicación. Los objetivos son: eludir la validación, suplantar un campo clave, introducir un archivo con nombre/tipo inesperado, agotar recursos del parser.
Movidas que se usan con más frecuencia:
- Nombres duplicados — distintas bibliotecas deciden de formas diferentes cuál es el valor «principal»;
- Juego con las fronteras — CRLF adicionales, espacios, «escalones» antes del boundary, separadores anidados;
- Parámetros de cabecera —
filename*con RFC 5987, comillas atípicas, espacios alrededor del «=»; - Nombres como «estructuras» —
user[role],files[], índices mezclados; - Desajuste de tipo — se declara un MIME y la firma indica otro;
- Carga al parser — muchas partes diminutas, cabeceras gigantes, matrices profundas de claves.
PHP: peculiaridades del parseo y trampas típicas
En PHP el análisis lo realizan el motor y el SAPI: el desarrollador recibe $_POST y $_FILES. Es cómodo, pero hay matices:
- Conversión de nombres en arreglos. Claves como
user[role],files[]se transforman en estructuras anidadas. Si la profundidad no está limitada, se pueden romper las expectativas de validación. - Duplicados de nombres. Un mismo nombre puede convertirse en una lista de valores; el orden no siempre es obvio y depende de la versión y del SAPI.
- Nombres de archivos. Usar el nombre tal como llegó es arriesgado por colisiones y path traversal. Se deben renombrar los archivos y conservar el nombre «humano» solo como metadato verificado.
- Límites.
post_max_size,upload_max_filesize,max_file_uploads,max_input_vars,max_input_time— sin ellos el servidor se sobrecarga fácilmente.
Ejemplo de suplantación de estructura mediante profundidad de claves:
------AaB03x
Content-Disposition: form-data; name="user[role]"
user
------AaB03x
Content-Disposition: form-data; name="user[role][is_admin]"
1
------AaB03x--
Si el código esperaba la cadena user[role], pero llegó una construcción anidada, la verificación puede fallar, mientras que más abajo en la pila el valor se usa como «verdadero».
Python: comportamiento por defecto de Flask/Django/FastAPI y otros
En Python no hay un comportamiento único para todos los frameworks. Panorama general:
- Duplicados. En Flask/Werkzeug y Django hay acceso a la lista de valores (
getlist()). Pero muchos toman el primer valor conget()sin considerar la estrategia de selección. - Nombres «array». La mayoría de pilas Python tratan el nombre como cadena, sin desplegar automáticamente
[]. Esto reduce la magia, pero portar código «del mundo PHP» sin revisarlo suele generar errores. - Codificaciones de parámetros.
filename*(RFC 5987) se soporta de forma desigual; detalles de escape y comillas son fuente de bugs límite. - Bufferización. Implementaciones WSGI/ASGI bufferizan las partes de maneras distintas; sin límites es fácil provocar un «denegación de servicio lento».
Técnica de ataque: ejemplos breves y demostrativos
Campo duplicado: valores distintos para la validación y para la lógica
Enviamos un mismo nombre dos veces: uno «seguro» para la validación y otro «peligroso» para entrar en la lógica.
------AaB03x
Content-Disposition: form-data; name="role"
user
------AaB03x
Content-Disposition: form-data; name="role"
admin
------AaB03x--
En PHP esto puede convertirse en un arreglo; en Flask/Django hay que usar getlist(), de lo contrario la elección del valor depende de la implementación. La falta de coherencia ofrece la posibilidad de «presionar el botón correcto».
Fronteras y líneas vacías: cuando el parser «pierde» una parte
Líneas vacías extra o espacios antes del boundary pueden conducir a lecturas distintas de la estructura.
Content-Type: multipart/form-data; boundary=----AaB03x
------AaB03x
Content-Disposition: form-data; name="meta"
ok
------AaB03x
Content-Disposition: form-data; name="file"; filename="x.php"
Content-Type: application/octet-stream
------AaB03x--
Un parser interpretará los espacios como parte del cuerpo anterior; otro los verá como un separador válido. La validación verá «solo meta», mientras que el archivo real se filtrará más abajo en la pila.
filename* frente a filename: cuál prevalece
Si los filtros se fijan en filename y la biblioteca mira filename*, se puede imponer un nombre mediante RFC 5987:
Content-Disposition: form-data; name="file"; filename="safe.txt"; filename*=UTF-8''..%2F..%2Fvar%2Fwww%2Fhtml%2Fshell.php
La protección correcta es ignorar por completo rutas provistas por el usuario y guardar el archivo con un nombre generado.
Caso habitual en migraciones: user[role][0]
------AaB03x
Content-Disposition: form-data; name="user[role][0]"
admin
------AaB03x--
Para PHP esto es un arreglo, y parte de la lógica puede esperar una cadena. La discrepancia de expectativas es una brecha útil para saltarse la validación.
Desajuste entre MIME y contenido real
Declarar image/png en la cabecera pero poner un PDF en el cuerpo —truco viejo pero aún efectivo donde se confía en la cabecera de la parte y no en la firma del archivo.
Content-Disposition: form-data; name="avatar"; filename="me.png"
Content-Type: image/png
%PDF-1.7
...
Muchas partes pequeñas: DoS por gran número de partes
Miles de partes cortas con cabeceras largas, muchos ficheros temporales, aumento del consumo de CPU y disco — y la aplicación deja de responder. Si no hay límites en número de partes y tamaños, el escenario se reproduce sin complicaciones.
Cómo probarlo: herramientas y enfoque
Mantenga a mano un conjunto de herramientas: es más fácil verificar hipótesis cuando puede construir el cuerpo «en crudo» y ver exactamente qué recibe el servidor.
- curl — operaciones básicas con multipart: duplicados, boundary explícitos, cuerpos «en crudo»;
- Burp Suite — Repeater/Intruder para edición manual y fuzzing;
- httpbin y webhook.site — para ver qué se envió realmente;
- Postman y HTTPie — construcción de formularios complejos y pruebas automáticas con colecciones.
Algunos trucos con curl:
# Campos duplicados: --form se puede repetir
curl -i -X POST https://target/upload
-F "role=user"
-F "role=admin"
-F "file=@beach.jpg;type=image/jpeg"
# Boundary explícito y cuerpo «en crudo»
curl -i -X POST [https://target/upload](https://target/upload)
-H 'Content-Type: multipart/form-data; boundary=----AaB03x'
--data-binary @payload.txt
Lista de comprobación de protección (versión corta)
Conjunto de reglas que cierra los huecos principales antes incluso de la lógica de aplicación:
- Duplicados. Para campos críticos — rechazar (HTTP 400) si hay duplicados. Para otros — estrategia explícita (por ejemplo, «tomar el primero y registrar los demás»).
- Listas blancas de nombres de campos, tipos esperados y valores permitidos.
- Nombres de archivos. Nunca use el nombre del cliente como ruta en disco. Guarde con su propio identificador (UUID); el «original» solo como metadato limpio.
- Verificación de contenido por firmas (magic numbers) y tamaño; el MIME de la cabecera es secundario.
- Límites: tamaño del cuerpo, número de partes, tamaño de cabeceras, profundidad de claves. Para endpoints «pesados» — ajustes separados y más estrictos.
- Streaming y backpressure: no mantenga archivos grandes en memoria; escriba en flujo a disco/nube.
- Codificaciones y escape de parámetros de
Content-Disposition; codificaciones incorrectas — rechazo. - Registros de anomalías: registre duplicados, partes vacías, parámetros demasiado largos, picos de 4xx.
- Actualizaciones regulares de bibliotecas de parseo y evitar soluciones caseras propias.
PHP: configuraciones prácticas y tácticas
Empiece por la configuración y el manejo estricto de la entrada:
post_max_size,upload_max_filesize,max_file_uploads,max_input_vars,max_input_time— establezca valores según su carga y escenarios;upload_tmp_dir— volumen separado con cuotas, sin permiso de ejecución, con monitorización de espacio;- Renombre los archivos al guardarlos y conserve el nombre original solo como metadato depurado;
- Limite la profundidad de «arreglos» en nombres de claves y patrones aceptables (
files[], no más de N elementos); - Verifique el contenido con
finfoy leyendo «bytes mágicos», no confíe en$_FILES['type']; - En frameworks (Symfony/Laravel) use las abstracciones recomendadas; no lea
php://inputdirectamente sin necesidad.
Python: configuraciones prácticas y tácticas
Puntos de control — proxy inverso, servidor de aplicaciones y código del framework:
- A nivel de proxy (por ejemplo, Nginx) establezca
client_max_body_size,client_body_timeout, buffers de cabeceras; - En la aplicación limite tamaño del cuerpo y número de partes (middlewares para Starlette/FastAPI, ajustes en Django);
- Trabaje con listas de valores (
getlist()) y defina una política frente a duplicados; - Guarde archivos en streaming y con su propio nombre, verifique firmas;
- Use validadores de esquemas (Pydantic/Marshmallow) después de su propio procesamiento «sanitizado» de la entrada;
- Separe endpoints: subidas — por separado y con límites y registro más estrictos.
Política sobre duplicados: formulela una vez y asegúrela con tests
La causa más frecuente de incidentes es la indefinición, no el comportamiento del parser. Para campos clave elija una de dos opciones: prohibir duplicados o procesarlos de forma determinista (por ejemplo, tomar solo el primero). Documente esto en el contrato del API y añada pruebas automáticas con ejemplos «sucios».
Mini-tests: cómo fijar reglas en el código
A continuación — un esbozo de prueba para una aplicación ASGI: verificamos que los duplicados de un campo crítico se cortan en la entrada.
<!-- pseudocódigo: la idea se puede trasladar fácilmente a su entorno -->
def test_reject_duplicates(client):
boundary = "----AaB03x"
body = f"""------AaB03x
Content-Disposition: form-data; name="role"
user
------AaB03x
Content-Disposition: form-data; name="role"
admin
------AaB03x--"""
r = client.post(
"/upload",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
data=body.encode("utf-8"),
)
assert r.status_code == 400
De forma similar se pueden probar límites de tamaño, número de partes y el manejo correcto de filename*.
Proxy inverso y servidor: dónde cortar lo innecesario
Parte de los problemas es más sencillo resolverlos antes de la aplicación — en Nginx/Apache y el servidor de aplicaciones:
- Nginx:
client_max_body_size,client_body_timeout,client_body_buffer_size,large_client_header_buffers; unlocationseparado para subidas con límites estrictos; - Apache:
LimitRequestBody,LimitRequestFieldSize,LimitRequestFields; - Uvicorn/Gunicorn: parámetros de workers y timeouts; limitar tamaños vía middleware/configuración.
Qué monitorizar: indicadores rápidos de problemas
Aun sin un ataque claro es útil detectar anomalías:
- picos en número de partes por petición y proporción de partes «vacías»;
- duplicados de nombres en campos críticos (
role,price,plan, etc.); - parámetros
filename/filename*inusualmente largos y frecuencia de codificaciones no estándar; - aumento de 4xx en endpoints de subida, incremento del tiempo de parseo;
- crecimiento del uso de disco en el directorio de ficheros temporales.
FAQ breve
¿Hay que prohibir filename*? No. Simplemente no use su contenido para la ruta en disco. Guarde con su propio nombre y conserve el original como metadato verificado.
¿Los duplicados de campos son siempre un error? No siempre. Para casillas y etiquetas una lista de valores es normal. Es importante separar esos campos de los críticos y aplicar políticas diferentes.
¿Es suficiente mirar la extensión del archivo? No. Verifique la firma y los límites, y también el comportamiento de los procesos aguas abajo (por ejemplo, generadores de miniaturas).
¿Conviene escribir un parser multipart propio? No. Mantener todos los casos límite es difícil; es más seguro actualizar y configurar bibliotecas maduras.
Enlaces útiles y referencias
- Especificación del formato: RFC 7578 (multipart/form-data), histórico RFC 2388.
- Parámetros con codificaciones: RFC 5987 (
filename*y otros). - PHP: Carga de archivos.
- Werkzeug/Flask: MultiDict.
- Django: File Uploads.
- Starlette/FastAPI: Requests & File Uploads, Request Files.
- Herramientas: curl, Burp Suite, HTTPie, httpbin, webhook.site.
- OWASP: Unrestricted File Upload.
En lugar de conclusión: póngase de acuerdo con su código
Las inyecciones en multipart se sostienen sobre suposiciones implícitas. Elíminelas: defina la política sobre duplicados, fije los nombres y tipos aceptables, active límites y registro de anomalías, añada algunas pruebas automáticas «sucias». Esto cierra la mayor parte de las vulnerabilidades en la entrada. El resto depende de actualizaciones regulares de bibliotecas y del manejo cuidadoso de los archivos de usuario.