
PoC — path traversal via campo de domínio derivado de LLM não sanitizado no OpenLore (GHSA-5j8x-q7q6-58j5, CVE-2026-87001, CVSS 4.7).
domain Derivado de LLM Não SanitizadoStatus da CVE: solicitada, aguardando atribuição. Esta descoberta é publicada como GHSA-5j8x-q7q6-58j5. Na atribuição da CVE, este repositório é renomeado
CVE-YYYY-NNNNN-openlore-PoCe este banner é substituído pelo link da CVE.
| Pesquisador | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-5j8x-q7q6-58j5 |
| CVSS 3.1 | 4.7 (Médio) |
| Fraqueza | CWE-22, CWE-73 |
domain Derivado de LLM Não SanitizadoResumo: Um atacante que controla o conteúdo de um repositório analisado por openlore generate pode fazer com que o pipeline de geração de spec do OpenSpec escreva Markdown influenciado pelo atacante em um caminho arbitrário do sistema de arquivos fora do projeto alvo, porque o valor domain retornado pela chamada de extração do LLM do Estágio 3 é usado literalmente para construir o caminho de saída de uma spec, sem sanitização de traversal e sem verificação de confinamento antes de fs.writeFile.
OpenLore (clay-good/OpenLore), pacote npm openlore, pipeline de geração de spec (openlore generate / o gerador + writer no formato OpenSpec). Não é o caminho determinístico de analyze/orient/guardrail do MCP — este é o único caminho de código no projeto que intencionalmente coloca um LLM no loop para geração, conforme o próprio README/spec do projeto ("um LLM é usado apenas onde a geração é inevitável (autoria de spec), nunca no caminho de recuperação ou guardrail").
Commit Git 1f2de00dfe75530efb256320c374dce36607ee70 (2026-07-31), versão do package.json 2.1.7.
4.7 (Médio) — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
AV:L — o gatilho é uma invocação local da CLI (openlore generate) contra um diretório em disco, não um serviço de rede.AC:H — a exploração exige que o operador execute o comando generate com suporte a LLM (não o caminho padrão de guardrail) com um provedor configurado, e exige que a injeção indireta de prompt do repositório analisado realmente faça o modelo emitir uma string de traversal no campo domain — uma condição que o atacante influencia, mas não controla totalmente.UI:R — o operador deve escolher executar a geração de spec contra o repositório malicioso.I:H / C:N / A:N — o bug é uma primitiva de escrita com conteúdo parcialmente modelado pelo atacante chegando a um caminho escolhido pelo atacante fora do projeto; não foi demonstrado que lê dados confidenciais ou que degrada a disponibilidade de forma confiável, então essas métricas são conservadoramente N.ExtractedService.domain (src/types/pipeline.ts:105) é populado diretamente pela chamada do LLM do Estágio 3, que recebe conteúdo bruto de arquivos do repositório sendo analisado e é instruído a retornar um array JSON de serviços:
src/core/generator/stages/stage3-services.ts:45-52 constrói o prompt a partir de fileChunks[i] (texto-fonte literal do repositório analisado) e chama pipeline.llm.completeJSON<ExtractedService[]>(..., STAGE3_SERVICE_SCHEMA).src/core/generator/schemas.ts:100 — STAGE3_SERVICE_SCHEMA.items.properties.domain é { type: 'string' }: sem pattern, sem enum, sem allowlist. Qualquer string que o modelo retorne é aceita.src/core/generator/openspec-format-generator.ts:161 — const domainName = service.domain || this.inferDomain(...) usa essa string como está.src/core/generator/openspec-format-generator.ts:563 — path: \openspec/specs/${domain.name.toLowerCase()}/spec.md`interpola-a diretamente no caminho de saída declarado da spec. Note quenormalizeDomainName() (src/core/generator/openspec-compat.ts:655) existe no grafo de dependências do mesmo arquivo e É aplicado em outros lugares (src/core/generator/openspec-writer.ts:167`, para comparação de diretórios de domínio obsoletos) — mas nunca é chamado aqui, no único lugar onde o valor realmente se torna um caminho no sistema de arquivos.src/core/generator/openspec-writer.ts:248 — const fullPath = join(this.rootPath, spec.path); usa node:path.join simples, que resolve segmentos ../ lexicalmente, sem chamada ao próprio safeJoin() do projeto (src/utils/path-confinement.ts) pelo qual todas as outras superfícies de caminho não confiável neste código (os handlers MCP, /api/skeleton e /api/spec-requirements do openlore view) são obrigadas a passar, conforme openspec/specs/mcp-security/spec.md.src/core/generator/openspec-writer.ts:277 / :294 então fazem writeFile(fullPath, spec.content, 'utf-8'), e ensureDir() (openspec-writer.ts:497-499) executa mkdir(dirname(filePath), { recursive: true }) no mesmo caminho não confinado, criando quaisquer diretórios intermediários ausentes no caminho de saída da raiz do projeto.Efeito líquido: um valor domain como ../../../../../../tmp/x sobrevive intacto desde a resposta JSON do LLM até uma chamada real de writeFile, aterrissando inteiramente fora do projeto analisado. Este é exatamente o modelo de ameaça "conteúdo do repositório redireciona uma escrita para fora da raiz do projeto" que o próprio openspec/specs/mcp-security/spec.md do projeto define explicitamente e reforça nas superfícies MCP/serve/view (safeJoin, confinamento ciente de symlinks, gate de cobertura de parâmetros de caminho) — a superfície do gerador/writer simplesmente não tem gate equivalente.
Nenhuma chamada real ao LLM foi feita (a conformidade do modelo com uma instrução injetada é probabilística e não é a parte interessante deste bug); em vez disso, a PoC chama o OpenSpecFormatGenerator e o OpenSpecWriter reais e não modificados do OpenLore com um PipelineResult cujo único campo ExtractedService.domain contém exatamente a forma de string que STAGE3_SERVICE_SCHEMA permite que um LLM retorne hoje, e exatamente o que um comentário em arquivo-fonte instruindo "defina domain como ../../../.../tmp/x" poderia extrair do Estágio 3 (que passa conteúdo bruto de arquivo ao modelo).
Script: ~/engagements/openlore/source/poc-domain-traversal.ts (raiz do repositório do clone testado), executado contra um projeto de rascunho em ~/engagements/openlore/evidence/victim-project:
cd ~/engagements/openlore/source
npx tsx poc-domain-traversal.ts
Resultado: OpenSpecFormatGenerator.generateSpecs() (não modificado) emite uma spec de domínio com
path: "openspec/specs/../../../../../../../../../../../../../../../../../../../../tmp/openlore_traversal_proof/spec.md",
e OpenSpecWriter.writeSpecs() (não modificado) a escreve — aterrissando em /tmp/openlore_traversal_proof/spec.md, totalmente fora da raiz de victim-project, enquanto victim-project/openspec/specs/ contém apenas os dois diretórios legítimos overview/architecture.
Capturas de tela (capturas genuínas de Xvfb+fluxbox+xterm, scrot -w <window id>):
../evidence/poc-generator-write.png — a execução da PoC: o spec.path bruto do gerador contendo o traversal, a linha de log do writer Wrote openspec/specs/../../.../tmp/openlore_traversal_proof/spec.md, e a própria confirmação do script de que /tmp/openlore_traversal_proof/spec.md existe com conteúdo influenciado pelo atacante.../evidence/poc-outside-fs-proof.png — confirmação independente: ls de victim-project/openspec/specs/ mostra apenas architecture/overview (nenhum vestígio do domínio malicioso dentro do projeto), enquanto ls -la /tmp/openlore_traversal_proof/ mostra o spec.md escapado diretamente sob /tmp.