
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 :
$ CGO_ENABLED=1 go test ./internal/mcp/... -run TestPoC_SymlinkFollow_SearchTextDisclosesOutOfRepoContent -v
mcp [build failed]
cgo: C compiler "gcc" not found: exec: "gcc": executable file not found in $PATH
Chaque extracteur de langage tree-sitter de cette base de code (y compris l'extracteur Go simple, github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go : // #cgo CFLAGS: -std=c11 -fPIC / import "C") nécessite cgo pour compiler — il n'existe aucun chemin de code purement Go pour enregistrer ne serait-ce qu'un seul langage, donc parser.Registry ne peut pas être construit sans un compilateur C fonctionnel. Ce bac à sable n'a ni gcc/cc/clang sur $PATH ni root sans mot de passe (sudo -l exige un mot de passe interactif) pour en installer un via apt-get install gcc (présent dans le cache apt, gcc-16/kali-last-snapshot, mais inaccessible sans privilège). Il s'agit d'une limitation d'environnement, non d'une conclusion sur gortex.
Le test PoC lui-même — écrit selon le modèle de harnais de test existant du projet (internal/mcp/tools_search_text_test.go's TestSearchText, et la même forme newSingleRepoServer/callTool que le PoC de l'avis précédent utilisait) — est laissé en place à internal/mcp/poc_symlink_search_disclosure_test.go pour que le mainteneur puisse l'exécuter dans un environnement disposant d'une chaîne d'outils C :
func TestPoC_SymlinkFollow_SearchTextDisclosesOutOfRepoContent(t *testing.T) {
outsideDir := t.TempDir()
secretPath := filepath.Join(outsideDir, "secret.txt")
const secretMarker = "GORTEX-POC-SECRET-OUTSIDE-REPO-9f3a1c"
os.WriteFile(secretPath, []byte("BEGIN OUTSIDE-REPO FILE\n"+secretMarker+"\nEND OUTSIDE-REPO FILE\n"), 0o600)
repoDir := t.TempDir()
os.WriteFile(filepath.Join(repoDir, "main.go"), []byte("package app\n\nfunc Hello() {}\n"), 0o644)
os.Symlink(secretPath, filepath.Join(repoDir, "pwn.go")) // symlink INSIDE repo -> OUTSIDE target
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
idx.Index(repoDir) // walk admits pwn.go, no symlink check
srv := NewServer(eng, g, idx, nil, zap.NewNop(), nil)
res := callTool(t, srv, "search_text", map[string]any{"query": secretMarker})
// asserts: res.IsError == false, exactly 1 match, Path == "pwn.go",
// Text contains secretMarker — i.e. the OUTSIDE file's real content.
}
Compte tenu de la trace au niveau source ci-dessus — chaque étape nommée par fonction exacte et aucun appel de confinement nulle part sur le chemin depuis filepath.WalkDir en passant par trigram.Build/Grep jusqu'à la réponse de handleSearchText — ceci est présenté comme tracé statiquement, non confirmé dynamiquement, conformément à la règle fondamentale de cet audit contre la fabrication de preuves PoC.
Tout dépôt indexé contenant un lien symbolique dont la cible se résout en dehors de la racine de ce dépôt (planté par un contributeur malveillant, présent dans un dépôt de dépendance/plugin cloné, ou introduit via une PR de chaîne d'approvisionnement que le démon indexe ensuite) permet à un client MCP — un appel search_text normal, ou un appel injecté par prompt/adversarial, même modèle de menace que GHSA-w42c-h7hr-f67p — de lire le contenu verbatim de ce fichier cible, indépendamment de la garde de confinement que le projet applique par ailleurs sur read_file et get_symbol_source. Dans un démon multi-dépôts (internal/indexer/multi.go:3525-3562, qui déploie l'appel identique non gardé par dépôt suivi), cela franchit également les frontières de locataires/dépôts au sein de la même instance de démon.
internal/indexer/indexer.go:2549) admet une entrée de fichier symbolique sans vérification Lstat/ModeSymlink.search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) servent le contenu résolu par lien symbolique sans vérification de confinement guardSymlinkWithinRepo, contrairement à tous les autres puits de lecture de la même base de code.Appliquer la fonction guardSymlinkWithinRepo déjà existante du projet (internal/mcp/tools_fileops.go:353) — ou une vérification équivalente basée sur Lstat — aux deux points :
filepath.WalkDir (internal/indexer/indexer.go:2549), rejeter ou ignorer une DirEntry de fichier dont d.Type()&os.ModeSymlink != 0 (reproduisant la protection implicite que Walk accorde déjà aux répertoires), ou résoudre filepath.EvalSymlinks(path) et confirmer le confinement dans absRoot avant de l'ajouter à files.trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go), ou au site d'appel de handleSearchText, appliquer la même vérification de confinement par chemin réel de style guardSymlinkWithinRepo déjà utilisée par read_file/get_symbol_source/tools_lsp/tools_export avant de renvoyer le Text d'une correspondance.
L'un ou l'autre correctif seul ferme cette faille ; les deux ensemble correspondent à la posture de confinement que le reste de la base de code s'impose déjà.Dostxodjayev Abdullox (GitHub : @squeeze440)
Le signalement privé de vulnérabilités (PVR) est activé et confirmé sur zzet/gortex ; le flux standard de brouillon d'avis de sécurité GitHub (GHSA) s'applique, conformément à la divulgation antérieure GHSA-w42c-h7hr-f67p du projet.