Divulgación · Escritura arbitraria en la nube
Cuando el cliente decide dónde guardar: subida arbitraria a un storage
Un cliente tenía un problema de diseño que aparece una y otra vez en cualquier sitio que permita subir archivos: ¿quién decide dónde se guarda el archivo? Si la respuesta es "el navegador", cualquiera puede escribir donde no debería.
El punto de entrada
El frontend subía imágenes a un bucket de almacenamiento en la nube y lo
hacía, como es habitual, con URLs prefirmadas: el navegador le pedía
al backend una URL de subida válida por tiempo limitado y luego hacía un
PUT directo al bucket. El endpoint era:
POST https://www.<dominio>/api/upload-media/get-presigned-urls
Content-Type: application/json
{"fileNames":["foto.jpg"],"contentTypes":["image/jpeg"],"bucketPath":"..."}
Ahí está el detalle: el cliente mandaba bucketPath, la carpeta de
destino. Y el servidor lo usaba tal cual para construir la
clave del objeto, sin validar nada: ni que la carpeta existiera, ni que
perteneciera al usuario, ni siquiera que no contuviera ../.
Qué terminaba firmando el servidor
Con mi propia cuenta (registro gratuito) el servidor firmaba cualquier ruta que le pidiera:
| bucketPath enviado | Clave firmada |
|---|---|
<carpeta> | <carpeta>/<ts>-test.html |
compressed/<listing-de-otro-usuario> | carpeta de otro vendedor |
<carpeta>/<publicacion-ajena> | carpeta de una publicación ajena |
../../static | firmada sin sanitizar |
Y no restringía el Content-Type: podía pedir la firma para
text/html. Los identificadores de publicaciones son
públicos (aparecen en la propia web), así que apuntar a carpetas de terceros
no requería adivinar nada.
La prueba de concepto
La prueba fue deliberadamente mínima y no destructiva: un único archivo HTML nuevo de 96 bytes, en una clave que no existía, sin sobrescribir nada.
# 1) Pedir la URL prefirmada para una carpeta arbitraria como text/html
curl -sk -b cookies.txt -H "Content-Type: application/json" \
-d '{"fileNames":["test.html"],"contentTypes":["text/html"],"bucketPath":"<carpeta>"}' \
"https://www.<dominio>/api/upload-media/get-presigned-urls"
# -> [{ "url": "https://<bucket>.s3.../<carpeta>/...-test.html?...", ... }]
# 2) Subir el archivo
curl -sk -X PUT -H "Content-Type: text/html" --data-binary @poc.html "URL_PREFIRMADA"
# -> HTTP 200
# 3) Verificar cómo se sirve
curl -sk -D - "https://assets.<dominio>/<carpeta>/...-test.html"
# -> HTTP/2 200
# content-type: text/html <-- se renderiza como página
El archivo quedaba servido como HTML ejecutable desde el subdominio de assets, el mismo que sirve todas las imágenes del sitio.
Impacto real
- Phishing bajo marca oficial: una réplica del login servida desde el dominio legítimo, con certificado válido, es indistinguible de la real.
- Inyección en carpetas ajenas: plantar archivos en publicaciones o perfiles de otros usuarios (IDs públicos).
- Hosting de contenido arbitrario (malware, "facturas", QR fraudulentos) con URLs que parecen de la marca.
- Abuso de costos: subidas masivas facturadas al bucket y a la CDN.
Lo que la vulnerabilidad no permitía
Un buen informe no infla el impacto. Verifiqué los límites reales:
- No se podían sobrescribir objetos existentes: el servidor anteponía un
<timestamp>-a la clave, y el bucket tenía versionado activo. - No se robaba sesión por XSS: las cookies eran host-only y
HttpOnly, en un origen distinto del de assets. - Solo se firmaba
PUT: no había lectura, borrado ni listado. - La carpeta
compressed/pasaba por un pipeline que re-encodaba la imagen y neutralizaba el HTML (solo esa carpeta).
Cómo se corrige
La raíz es una sola: el cliente no debe decidir la ruta. El servidor tiene que:
- Decidir el prefijo. Eliminar
bucketPathdel contrato o mapearlo server-side a un enum cerrado según el recurso y la sesión. - Verificar propiedad del recurso antes de firmar.
- Restringir el
Content-Typea imágenes y videos; rechazartext/htmlyimage/svg+xml. - Sanitizar el nombre de archivo y servir lo subido por
usuarios desde un dominio separado con
Content-Disposition: attachment.