
PoC — traversée de chemin via un champ de domaine dérivé d'un LLM non assaini dans OpenLore (GHSA-5j8x-q7q6-58j5, CVE-2026-87001, CVSS 4.7).
domain non assaini dérivé du LLMStatut CVE : demandé, en attente d'attribution. Cette découverte est publiée sous GHSA-5j8x-q7q6-58j5. À l'attribution de la CVE, ce dépôt est renommé
CVE-YYYY-NNNNN-openlore-PoCet cette bannière est remplacée par le lien CVE.
| Chercheur | Dostxodjayev Abdullox (@squeeze440) |
| Avis | GHSA-5j8x-q7q6-58j5 |
| CVSS 3.1 | 4.7 (Moyen) |
| Faiblesse | CWE-22, CWE-73 |
domain non assaini dérivé du LLMRésumé : Un attaquant qui contrôle le contenu d'un dépôt analysé par openlore generate peut amener le pipeline de génération de spécifications OpenSpec à écrire du Markdown influencé par l'attaquant vers un chemin arbitraire du système de fichiers en dehors du projet cible, car la valeur domain renvoyée par l'appel d'extraction LLM de l'étape 3 est utilisée telle quelle pour construire le chemin de sortie d'une spécification, sans assainissement de traversée ni vérification de confinement avant fs.writeFile.
OpenLore (clay-good/OpenLore), paquet npm openlore, pipeline de génération de spécifications (openlore generate / le générateur et l'écrivain au format OpenSpec). Il ne s'agit pas du chemin déterministe analyze/orient/garde-fou MCP — c'est le seul chemin de code du projet qui place intentionnellement un LLM dans la boucle de génération, conformément au README/spécification du projet lui-même (« un LLM n'est utilisé que là où la génération est inévitable (rédaction de spécifications), jamais dans le chemin de récupération ou de garde-fou »).
Commit Git 1f2de00dfe75530efb256320c374dce36607ee70 (2026-07-31), version package.json 2.1.7.
4.7 (Moyen) — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
AV:L — le déclencheur est une invocation locale en ligne de commande (openlore generate) contre un répertoire sur disque, et non un service réseau.AC:H — l'exploitation nécessite que l'opérateur exécute la commande generate adossée au LLM (et non le chemin de garde-fou par défaut) avec un fournisseur configuré, et nécessite que l'injection de prompt indirecte du dépôt analysé amène réellement le modèle à émettre une chaîne de traversée dans le champ domain — une condition que l'attaquant influence mais ne contrôle pas entièrement.UI:R — l'opérateur doit choisir d'exécuter la génération de spécifications contre le dépôt malveillant.I:H / C:N / A:N — le bug est une primitive d'écriture avec un contenu partiellement templaté par l'attaquant atterrissant à un chemin choisi par l'attaquant en dehors du projet ; il n'a pas été démontré qu'il lit des données confidentielles ou dégrade de manière fiable la disponibilité, ces métriques sont donc conservativement à N.ExtractedService.domain (src/types/pipeline.ts:105) est renseigné directement depuis l'appel LLM de l'étape 3, auquel est fourni le contenu brut des fichiers du dépôt analysé et qui est invité à renvoyer un tableau JSON de services :
src/core/generator/stages/stage3-services.ts:45-52 construit le prompt à partir de fileChunks[i] (texte source littéral du dépôt analysé) et appelle pipeline.llm.completeJSON<ExtractedService[]>(..., STAGE3_SERVICE_SCHEMA).src/core/generator/schemas.ts:100 — STAGE3_SERVICE_SCHEMA.items.properties.domain est { type: 'string' } : pas de pattern, pas d'enum, pas de liste d'autorisation. Toute chaîne renvoyée par le modèle est acceptée.src/core/generator/openspec-format-generator.ts:161 — const domainName = service.domain || this.inferDomain(...) utilise cette chaîne telle quelle.src/core/generator/openspec-format-generator.ts:563 — path: \openspec/specs/${domain.name.toLowerCase()}/spec.md`l'interpole directement dans le chemin de sortie déclaré de la spécification. À noter quenormalizeDomainName() (src/core/generator/openspec-compat.ts:655) existe dans le graphe de dépendances du même fichier et EST appliqué ailleurs (src/core/generator/openspec-writer.ts:167`, pour la comparaison des répertoires de domaines obsolètes) — mais il n'est jamais appelé ici, au seul endroit où la valeur devient réellement un chemin du système de fichiers.src/core/generator/openspec-writer.ts:248 — const fullPath = join(this.rootPath, spec.path); utilise le simple node:path.join, qui résout lexicalement les segments ../, sans aucun appel au safeJoin() propre au projet (src/utils/path-confinement.ts) par lequel toutes les autres surfaces de chemin non fiable de ce codebase (les gestionnaires MCP, /api/skeleton et /api/spec-requirements de openlore view) doivent passer conformément à openspec/specs/mcp-security/spec.md.src/core/generator/openspec-writer.ts:277 / :294 effectuent ensuite writeFile(fullPath, spec.content, 'utf-8'), et ensureDir() (openspec-writer.ts:497-499) exécute mkdir(dirname(filePath), { recursive: true }) sur le même chemin non confiné, créant tout répertoire intermédiaire manquant sur le chemin de sortie de la racine du projet.Effet net : une valeur domain telle que ../../../../../../tmp/x survit intacte depuis la réponse JSON du LLM jusqu'à un véritable appel writeFile, atterrissant entièrement en dehors du projet analysé. C'est exactement le modèle de menace « le contenu du dépôt redirige une écriture en dehors de la racine du projet » que le propre openspec/specs/mcp-security/spec.md du projet définit explicitement et durcit sur les surfaces MCP/serve/view (safeJoin, confinement tenant compte des liens symboliques, porte de couverture des paramètres de chemin) — la surface générateur/écrivain n'a tout simplement pas de porte équivalente.
Aucun appel LLM réel n'a été effectué (la conformité du modèle à une instruction injectée est probabiliste et ne constitue pas la partie intéressante de ce bug) ; à la place, la PoC appelle les véritables OpenSpecFormatGenerator et OpenSpecWriter non modifiés d'OpenLore avec un PipelineResult dont l'unique champ ExtractedService.domain contient exactement la forme de chaîne que STAGE3_SERVICE_SCHEMA autorise aujourd'hui un LLM à renvoyer, et exactement ce qu'un commentaire dans un fichier source ordonnant « définir domain à ../../../.../tmp/x » pourrait susciter à l'étape 3 (qui transmet le contenu brut des fichiers au modèle).
Script : ~/engagements/openlore/source/poc-domain-traversal.ts (racine du dépôt du clone testé), exécuté contre un projet jetable à ~/engagements/openlore/evidence/victim-project :
cd ~/engagements/openlore/source
npx tsx poc-domain-traversal.ts
Résultat : OpenSpecFormatGenerator.generateSpecs() (non modifié) émet une spécification de domaine avec
path: "openspec/specs/../../../../../../../../../../../../../../../../../../../../tmp/openlore_traversal_proof/spec.md",
et OpenSpecWriter.writeSpecs() (non modifié) l'écrit — atterrissant à /tmp/openlore_traversal_proof/spec.md, entièrement en dehors de la racine victim-project, tandis que victim-project/openspec/specs/ ne contient jamais que les deux répertoires légitimes overview/architecture.