
PoC — suivi de lien symbolique vers une divulgation de contenu hors dépôt via search_text dans Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textStatut CVE : demandé, en attente d'attribution. Cette découverte est publiée sous GHSA-6vhf-4wcm-2r83. À l'attribution de la CVE, ce dépôt sera renommé
CVE-YYYY-NNNNN-gortex-PoCet cette bannière sera remplacée par le lien CVE.
| Chercheur | Dostxodjayev Abdullox (@squeeze440) |
| Avis | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (Moyen) |
| Faiblesse | CWE-59, CWE-200 |
search_textUne faille de résolution de liens incorrecte (CWE-59) dans l'indexeur de dépôt de gortex permet à une entrée de fichier symbolique pointant en dehors de la racine du dépôt indexé d'être silencieusement parcourue, indexée en contenu, puis servie verbatim — y compris vers une cible entièrement hors racine — par le puits de relecture en direct de l'outil MCP search_text, qui n'applique jamais la vérification de confinement guardSymlinkWithinRepo propre au projet.
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe : v0.62.0-35-gb5a63e25), le HEAD du dépôt au moment de l'audit. Confirmé toujours présent dans la dernière version étiquetée v0.62.0, c'est-à-dire après l'arrivée du correctif pour l'avis non lié GHSA-w42c-h7hr-f67p (v0.55.0).
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (Moyen)
AV:L — correspond au précédent de notation propre à ce projet pour sa surface MCP (GHSA-w42c-h7hr-f67p utilisait également AV:L) : le déploiement par défaut est un démon MCP local atteint via stdio par le processus agent sur le même hôte ; AV:N ne s'appliquerait qu'au mode optionnel --http-addr.UI:R — nécessite que le client MCP (un agent LLM, ou un humain via gortex call) émette un appel search_text / search_text regexp:true. Un littéral large (par exemple un seul caractère courant) ou une regex correspondante (.) contre un dépôt indexé contenant le lien symbolique planté suffit à exfiltrer le fichier cible — aucune connaissance secrète de son contenu n'est nécessaire — c'est donc un seuil bas, mais il s'agit d'une action distincte de la simple indexation.C:H — divulgation de contenu de fichier arbitraire, sans limite par les frontières du dépôt, de tout ce que l'utilisateur OS du démon peut lire (clés SSH, fichiers d'identifiants cloud, dépôts d'autres locataires dans un démon multi-dépôts, etc.), renvoyé verbatim dans la réponse de l'outil.I:N / A:N — il s'agit d'un bug pur de chemin de lecture ; rien n'est écrit ni rendu indisponible.internal/indexer/indexer.go:2549-2604, le parcours principal d'admission au corpus :
err = filepath.WalkDir(absRoot, func(path string, d os.DirEntry, err error) error {
if err != nil { return nil }
if d.IsDir() {
if idx.shouldPruneDir(path, absRoot) { return filepath.SkipDir }
return nil
}
lang, ok := idx.effectiveLanguage(path, nil) // detects by extension; symlink name is enough
if !ok { return nil }
if idx.shouldExclude(path, absRoot, false) { return nil }
info, statErr := d.Info() // Lstat'd DirEntry.Info() — no ModeSymlink check
...
files = append(files, walkedFile{path: path, lang: lang, size: info.Size(), mtimeNano: info.ModTime().UnixNano()})
return nil
})
filepath.WalkDir utilise la sémantique Lstat, donc un répertoire symbolique est naturellement protégé — d.IsDir() est faux pour lui et il n'est jamais parcouru récursivement. Une entrée de fichier symbolique ne bénéficie d'aucune protection équivalente : rien ici (ni dans shouldExclude/shouldPruneDir, internal/indexer/indexer.go:5724-5813, qui ne font correspondre que des motifs de chemin de style gitignore) n'appelle d.Type()&os.ModeSymlink, os.Lstat ou filepath.EvalSymlinks sur l'entrée de fichier avant de la mettre en file. Un fichier nommé par exemple pwn.go dont la cible est /etc/passwd, ~/.ssh/id_rsa, ou un dépôt d'un locataire voisin est admis uniquement parce que son nom correspond à une extension enregistrée (effectiveLanguage, internal/parser DetectLanguageContent, basé sur le chemin). Cette liste exacte de fichiers parcourus devient idx.fileMtimes (internal/indexer/indexer.go:3760 : idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text relit les fichiers en direct sans garde de confinementinternal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) construit le chercheur de trigrammes directement à partir des clés de idx.fileMtimes :
rels := ... // from idx.fileMtimes, i.e. includes the symlinked file's rel path
idx.trigramSearcher = trigram.Build(root, rels)
internal/search/trigram/searcher.go:32-48 (Build) et :55-86/:102-176 (Grep/GrepRegexp) font ensuite :
content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) // Build — follows symlink
...
f, err := os.Open(filepath.Join(s.root, filepath.FromSlash(rel))) // Grep/GrepRegexp — follows symlink
os.ReadFile/os.Open suivent les liens symboliques par défaut (pas de O_NOFOLLOW). Ni ces fonctions, ni internal/mcp/tools_search_text.go:35-126 (handleSearchText, le gestionnaire MCP qui appelle s.indexer.GrepText/GrepRegexp), n'appellent jamais resolveFilePath ou guardSymlinkWithinRepo. Ces deux fonctions de confinement existent et sont correctement câblées dans tous les autres chemins servant du contenu — internal/mcp/tools_fileops.go (read_file, write_file/edit_file depuis le correctif v0.55.0), internal/mcp/tools_coding.go:919 (get_symbol_source), internal/mcp/tools_lsp.go:264, internal/mcp/tools_export.go:95 — mais le chemin de grep par trigrammes de search_text est un puits séparé qui n'a jamais reçu la même garde. grep -rn guardSymlinkWithinRepo dans tout l'arbre confirme zéro référence dans tools_search_text.go, grep.go ou trigram/searcher.go.
Ceci est véritablement distinct de GHSA-w42c-h7hr-f67p :
resolveFilePath exemptant les chemins absolus + write_file/edit_file/move_inline n'appelant jamais guardSymlinkWithinRepo (corrigé dans v0.55.0).search_text, pas read_file/get_symbol_source) sert son contenu via un puits (internal/search/trigram) qui, indépendamment, n'appelle jamais guardSymlinkWithinRepo. Le correctif du chemin d'écriture n'a touché ni l'un ni l'autre de ces deux emplacements.
Cela correspond au schéma d'outils similaires déjà confirmé dans ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) et vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m) : un parcours de découverte de fichiers sans protection contre les liens symboliques, associé à un puits servant du contenu qui, indépendamment, manque d'une garde de confinement.Trace statique (références fichier:ligne ci-dessus) est complète et reproductible par inspection. La vérification dynamique a été tentée et est véritablement bloquée par une lacune de chaîne d'outils dans ce bac à sable, documentée honnêtement plutôt que dissimulée :