
PoC — a importação de anexos copia arquivos de caminhos locais não aprovados no ZotLit (GHSA-4qh7-66xv-h329, CVE-2026-87000, CVSS 5.5).
Status da CVE: solicitada, aguardando atribuição. Esta descoberta é publicada como GHSA-4qh7-66xv-h329. Após a atribuição da CVE, este repositório é renomeado
CVE-YYYY-NNNNN-zotlit-PoCe este banner é substituído pelo link da CVE.
| Pesquisador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-4qh7-66xv-h329 |
| CVSS 3.1 | 5.5 (Médio) |
| Fraqueza | CWE-73, CWE-200 |
Resumo
O Controle Externo de Nome de Arquivo ou Caminho no recurso de importação de anexos do AidenLx ZotLit (aidenlx/zotlit) 1.1.12 permite que um atacante que controla uma biblioteca Zotero compartilhada/sincronizada divulgue arquivos locais arbitrários do sistema de arquivos da vítima para o cofre Obsidian da vítima por meio de um caminho de anexo linked_file manipulado.
Produto
ZotLit — plugin do Obsidian para integração com o Zotero (aidenlx/zotlit, id do plugin zotlit, versão do manifesto 1.1.12)
Versão Testada
Commit do 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 (Médio)
Métricas não óbvias: AV:L — a exploração ocorre quando o processo local do Obsidian/zotlit da vítima processa metadados de itens do Zotero fornecidos pelo atacante (entregues por meio de uma biblioteca compartilhada/sincronizada), a mesma convenção usada para bugs de "arquivo malicioso processado por aplicativo local", embora a entrega em si possa ocorrer pela rede (biblioteca de grupo compartilhada, arquivo de exportação enviado por e-mail). UI:R — a vítima deve importar/sincronizar a biblioteca maliciosa no Zotero e executar o recurso de importação de notas do ZotLit (ou de incorporação de anotações/citações) em uma nota que faça referência ao anexo manipulado; isso é uso rotineiro da funcionalidade central do plugin, habilitada por padrão (attachment.import tem valor padrão true), não uma ação incomum. I:N/A:N — esta descoberta é apenas uma primitiva de leitura/cópia; nenhum path traversal no lado do destino é alegado (consulte Detalhes para uma observação relacionada, mas não verificada).
Detalhes
O ZotLit lê metadados de anexos diretamente do banco de dados SQLite do Zotero (ou de uma biblioteca sincronizada/compartilhada), incluindo a coluna de texto livre itemAttachments.path, e confia nela completamente ao resolver de onde ler um anexo "vinculado":
packages/db/src/lib/zt-path.ts:62-63 — attachmentAbsPath(), caso "linked-absolute":
case "linked-absolute":
return parsed.path;
Para linhas com linkMode: 2 (linked_file) cujo path não carrega o placeholder de diretório base attachments:, parsed.path é a string bruta do banco de dados retornada literalmente como o caminho absoluto do sistema de arquivos a ser lido — sem allowlist, sem confinamento ao diretório de dados do Zotero ou a qualquer local aprovado pelo usuário.
packages/db/src/lib/context/zt-template-attach.ts:101-106 — attachmentFilename(), caso "linked-absolute":
case "linked-absolute":
return basename(path.path);
O nome de arquivo usado para construir a cópia no cofre é derivado desse mesmo caminho controlado pelo atacante por meio de basename(), portanto o nome de arquivo de destino é influenciado pelo atacante, mas livre de separadores de caminho (seguro contra traversal neste ramo).
apps/obsidian/src/services/note-import/note-parser.ts:403-430 — resolveEmbeddedImage() é invocado durante a conversão de uma anotação de imagem incorporada de uma nota de literatura do Zotero em Markdown do Obsidian (uma ação rotineira e padrão do recurso "Import Note" do ZotLit). Ele resolve sourcePath = attachmentAbsPath(attachment, ...) e chama deps.resolveLink({ sourcePath, vaultName: \${attachment.key}-${filename}` })` — enfileirando uma cópia de qualquer caminho absoluto escolhido pelo atacante para dentro do cofre.
apps/obsidian/src/services/attachment-import/service.ts:125-155 — AttachmentImportBatch.resolveLink() enfileira { source: sourcePath, dest: <vault attachment folder>/<key>-<filename> }, condicionado apenas à configuração attachment.import, cujo padrão é true (apps/obsidian/src/services/settings/schema.ts:123).
apps/obsidian/src/lib/copy-attachments.ts:43-60 — copyAttachment() executa a leitura+escrita real sem nenhuma validação de caminho: stat(source) e depois copyFile(source, dest).
Efeito líquido: um atacante que consiga inserir um item do Zotero linked_file (linkMode 2) na biblioteca da vítima — por exemplo, uma biblioteca de grupo compartilhada do Zotero, uma exportação .rdf/.json/Better BibTeX que a vítima importa, ou uma biblioteca sincronizada na qual o atacante tem acesso de escrita — pode definir o caminho de anexo desse item para qualquer arquivo no disco da vítima (~/.ssh/id_rsa, armazenamentos de credenciais do navegador, outros cofres, arquivos .env, etc.). No momento em que a vítima executa a importação de notas do ZotLit (ou renderiza/incorpora essa anotação) com a configuração padrão attachment.import: true, o ZotLit copia silenciosamente o conteúdo desse arquivo para o cofre Obsidian da vítima sob um nome previsível (<attachmentKey>-<basename>). Como os cofres são rotineiramente sincronizados, commitados no git ou publicados, isso move dados que o atacante jamais conseguiria alcançar de outra forma para um local que o atacante (ou qualquer outra pessoa com acesso ao destino de sincronização) pode ler.
Observação relacionada, não verificada (não faz parte do PoC desta descoberta): o ramo irmão "storage" de attachmentFilename() (zt-template-attach.ts:103-104) retorna o sufixo bruto do caminho sem a chamada basename() aplicada aos outros dois ramos. Se isso é explorável de forma independente depende de como o próprio mecanismo de sincronização de armazenamento do Zotero nomeia os arquivos baixados localmente, o que está fora deste repositório e não foi verificado — sinalizado aqui apenas como uma lacuna de hardening que vale a pena fechar por consistência.
Prova de Conceito
Verificação dinâmica: as funções vulneráveis exatas (parseAttachmentPath, attachmentAbsPath, attachmentFilename, copyAttachments/copyAttachment/writeCopy/destMatches, isErrno, reflink) foram extraídas literalmente (com arquivo:linha citados acima, lógica inalterada) do código-fonte distribuído e executadas diretamente no Node, porque uma instalação completa offline do workspace pnpm não estava disponível neste sandbox (toolchain corepack/pnpm quebrado offline). A única substituição foi trocar a chamada do logger LogTape por console.warn — apenas cosmético, efeito zero no fluxo de controle. Script: ~/engagements/zotlit/evidence/poc-verify.mjs.
Passos:
~/engagements/zotlit/evidence/victim-disk/id_rsa (conteúdo fictício, claramente marcado como simulado).{ key: "EVILKEY1", path: "<path to victim's id_rsa>", linkMode: 2 } — exatamente o formato que getAttachmentByKey retorna do banco de dados do Zotero.attachmentAbsPath() real — resolveu a origem como o arquivo da vítima, literalmente, sem validação.attachmentFilename() real — resolveu id_rsa como o basename de destino aparentemente seguro.vaultName a partir de note-parser.ts:427 (${key}-${filename}).copyAttachments() real — resultado { copied: 1, skipped: 0, missing: 0 }.fake-vault/attachments/EVILKEY1-id_rsa — conteúdo byte a byte idêntico ao arquivo original da vítima.