
PoC — la importación de adjuntos copia archivos desde rutas locales no aprobadas en ZotLit (GHSA-4qh7-66xv-h329, CVE-2026-87000, CVSS 5.5).
Estado del CVE: solicitado, pendiente de asignación. Este hallazgo se publica como GHSA-4qh7-66xv-h329. Al asignarse el CVE, este repositorio se renombra
CVE-YYYY-NNNNN-zotlit-PoCy este banner se reemplaza con el enlace del CVE.
| Investigador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-4qh7-66xv-h329 |
| CVSS 3.1 | 5.5 (Medio) |
| Debilidad | CWE-73, CWE-200 |
Resumen
El control externo del nombre de archivo o la ruta en la función de importación de adjuntos de AidenLx ZotLit (aidenlx/zotlit) 1.1.12 permite a un atacante que controla una biblioteca de Zotero compartida/sincronizada divulgar archivos locales arbitrarios del sistema de archivos de la víctima en el vault de Obsidian de la víctima mediante una ruta de adjunto linked_file manipulada.
Producto
ZotLit — plugin de Obsidian para la integración con Zotero (aidenlx/zotlit, id del plugin zotlit, versión del manifiesto 1.1.12)
Versión probada
Commit de Git 41e60aaa6178629b34f8a42d104e757594b72435 (2026-07-31)
CVSS v3.1 estimado
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (Medio)
Métricas no obvias: AV:L — la explotación ocurre cuando el proceso local de Obsidian/zotlit de la víctima procesa metadatos de elementos de Zotero proporcionados por el atacante (entregados mediante una biblioteca compartida/sincronizada), la misma convención usada para errores de "archivo malicioso procesado por una aplicación local", aunque la entrega en sí pueda ocurrir a través de una red (biblioteca de grupo compartida, archivo de exportación enviado por correo electrónico). UI:R — la víctima debe importar/sincronizar la biblioteca maliciosa en Zotero y ejecutar la función de importación de notas de ZotLit (o la inserción de anotaciones/citas) en una nota que haga referencia al adjunto manipulado; esto es un uso rutinario de la funcionalidad principal del plugin, habilitada por defecto (attachment.import es true por defecto), no una acción inusual. I:N/A:N — este hallazgo es únicamente una primitiva de lectura/copia; no se afirma un path traversal del lado del destino (véase Detalles para una observación relacionada pero no verificada).
Detalles
ZotLit lee los metadatos de los adjuntos directamente de la base de datos SQLite de Zotero (o de una biblioteca sincronizada/compartida), incluida la columna de texto libre itemAttachments.path, y confía completamente en ella al resolver desde dónde leer un adjunto "vinculado":
packages/db/src/lib/zt-path.ts:62-63 — attachmentAbsPath(), caso "linked-absolute":
case "linked-absolute":
return parsed.path;
Para las filas con linkMode: 2 (linked_file) cuyo path no lleva el marcador de directorio base attachments:, parsed.path es la cadena sin procesar de la base de datos devuelta tal cual como la ruta absoluta del sistema de archivos que se va a leer — sin lista de permitidos, sin confinamiento al directorio de datos de Zotero ni a ninguna ubicación aprobada por el usuario.
packages/db/src/lib/context/zt-template-attach.ts:101-106 — attachmentFilename(), caso "linked-absolute":
case "linked-absolute":
return basename(path.path);
El nombre de archivo usado para construir la copia en el vault se deriva de esa misma ruta controlada por el atacante mediante basename(), por lo que el nombre de archivo de destino está influenciado por el atacante pero carece de separadores de ruta (seguro frente a traversal en esta rama).
apps/obsidian/src/services/note-import/note-parser.ts:403-430 — se invoca resolveEmbeddedImage() al convertir la anotación de imagen incrustada de una nota de literatura de Zotero en Markdown de Obsidian (una acción rutinaria y predeterminada de la función "Import Note" de ZotLit). Resuelve sourcePath = attachmentAbsPath(attachment, ...) y llama a deps.resolveLink({ sourcePath, vaultName: \${attachment.key}-${filename}` })` — encolando una copia de cualquier ruta absoluta elegida por el atacante en el vault.
apps/obsidian/src/services/attachment-import/service.ts:125-155 — AttachmentImportBatch.resolveLink() encola { source: sourcePath, dest: <vault attachment folder>/<key>-<filename> }, condicionado únicamente por la configuración attachment.import, cuyo valor predeterminado es true (apps/obsidian/src/services/settings/schema.ts:123).
apps/obsidian/src/lib/copy-attachments.ts:43-60 — copyAttachment() realiza la lectura+escritura real sin ninguna validación de ruta: stat(source) y luego copyFile(source, dest).
Efecto neto: un atacante que pueda introducir un elemento de Zotero linked_file (linkMode 2) en la biblioteca de la víctima — por ejemplo, una biblioteca de grupo compartida de Zotero, una exportación .rdf/.json/Better BibTeX que la víctima importe, o una biblioteca sincronizada sobre la que el atacante tenga acceso de escritura — puede establecer la ruta del adjunto de ese elemento a cualquier archivo del disco de la víctima (~/.ssh/id_rsa, almacenes de credenciales del navegador, otros vaults, archivos .env, etc.). En el momento en que la víctima ejecuta la importación de notas de ZotLit (o renderiza/incrusta esa anotación) con la configuración predeterminada attachment.import: true, ZotLit copia silenciosamente el contenido de ese archivo en el vault de Obsidian de la víctima bajo un nombre predecible (<attachmentKey>-<basename>). Dado que los vaults se sincronizan, se confirman en git o se publican de forma rutinaria, esto traslada datos a los que el atacante nunca podría acceder de otro modo a una ubicación que el atacante (o cualquier otra persona con acceso al destino de sincronización) puede leer.
Observación relacionada y no verificada (no forma parte del PoC de este hallazgo): la rama hermana "storage" de attachmentFilename() (zt-template-attach.ts:103-104) devuelve el sufijo de ruta sin procesar sin la llamada a basename() aplicada a las otras dos ramas. Si esto es explotable de forma independiente depende de cómo el propio mecanismo de sincronización de almacenamiento de Zotero nombra los archivos descargados localmente, lo cual queda fuera de este repositorio y no se verificó — se señala aquí únicamente como una brecha de endurecimiento que vale la pena cerrar por consistencia.
Prueba de concepto
Verificación dinámica: las funciones vulnerables exactas (parseAttachmentPath, attachmentAbsPath, attachmentFilename, copyAttachments/copyAttachment/writeCopy/destMatches, isErrno, reflink) se extrajeron literalmente (con archivo:línea citados arriba, lógica sin cambios) del código fuente distribuido y se ejecutaron directamente en Node, porque no había disponible una instalación completa del workspace de pnpm sin conexión en este sandbox (la cadena de herramientas corepack/pnpm está rota sin conexión). La única sustitución fue cambiar la llamada al logger LogTape por console.warn — solo cosmético, sin efecto alguno en el flujo de control. Script: ~/engagements/zotlit/evidence/poc-verify.mjs.