Inyección multipart/form-data en PHP y Python: cómo vulneran a los analizadores y cómo bloquear estos ataques

Inyección multipart/form-data en PHP y Python: cómo vulneran a los analizadores y cómo bloquear estos ataques

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-Type en 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 cabecerafilename* 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 con get() 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 finfo y leyendo «bytes mágicos», no confíe en $_FILES['type'];
  • En frameworks (Symfony/Laravel) use las abstracciones recomendadas; no lea php://input directamente 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; un location separado 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

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.

Alt text