
PoC — symlink following per la divulgazione di contenuti al di fuori del repository tramite search_text in Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textStato CVE: richiesto, in attesa di assegnazione. Questa segnalazione è pubblicata come GHSA-6vhf-4wcm-2r83. All'assegnazione del CVE questo repository viene rinominato
CVE-YYYY-NNNNN-gortex-PoCe questo banner viene sostituito con il link al CVE.
| Ricercatore | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (Medium) |
| Debolezza | CWE-59, CWE-200 |
search_textUna falla di risoluzione impropria dei link (CWE-59) nell'indexer dei repository di gortex consente a una voce di file symlink che punta al di fuori della radice del repository indicizzato di essere silenziosamente attraversata, indicizzata nei contenuti e successivamente servita testualmente — anche verso un target completamente fuori dalla radice — dal sink di ri-lettura live del tool MCP search_text, che non applica mai il controllo di confinamento guardSymlinkWithinRepo proprio del progetto.
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25), la HEAD del repository al momento dell'audit. Confermato ancora presente nell'ultima release taggata v0.62.0, ovvero dopo che il fix per il non correlato GHSA-w42c-h7hr-f67p (v0.55.0) è stato integrato.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (Medium)
AV:L — coerente con il precedente di scoring dello stesso progetto per la sua superficie MCP (GHSA-w42c-h7hr-f67p usava anch'esso AV:L): il deployment predefinito è un daemon MCP locale raggiunto via stdio dal processo agente sullo stesso host; AV:N si applicherebbe solo alla modalità opt-in --http-addr.UI:R — richiede che il client MCP (un agente LLM, o un umano tramite gortex call) emetta una chiamata search_text / search_text regexp:true. Un letterale ampio (ad esempio un singolo carattere comune) o una regex corrispondente (.) contro un repo indicizzato contenente il symlink piantato è sufficiente a estrarre il file target — nessuna conoscenza segreta del suo contenuto è necessaria — quindi è una barriera bassa, ma è un'azione distinta dalla mera indicizzazione.C:H — divulgazione di contenuto arbitrario di file, non limitata dai confini del repo, di qualsiasi cosa l'utente OS del daemon possa leggere (chiavi SSH, file di credenziali cloud, repo di tenant adiacenti in un daemon multi-repo, ecc.), restituito testualmente nella risposta del tool.I:N / A:N — questo è un bug puro del percorso di lettura; nulla viene scritto o reso indisponibile.internal/indexer/indexer.go:2549-2604, il walk primario di ammissione al 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 usa la semantica di Lstat, quindi una directory symlink è naturalmente protetta — d.IsDir() è falso per essa e non viene mai ricorsa. Una voce di file symlink non riceve protezione equivalente: nulla qui (né in shouldExclude/shouldPruneDir, internal/indexer/indexer.go:5724-5813, che fanno match solo su pattern di percorso in stile gitignore) chiama d.Type()&os.ModeSymlink, os.Lstat, o filepath.EvalSymlinks sulla voce di file prima di accodarla. Un file chiamato ad esempio pwn.go il cui target è /etc/passwd, ~/.ssh/id_rsa, o un repo di tenant adiacente viene ammesso puramente perché il suo nome corrisponde a un'estensione registrata (effectiveLanguage, internal/parser DetectLanguageContent, basato sul percorso). Questa esatta lista di file attraversati diventa idx.fileMtimes (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text ri-legge i file live senza alcun guard di confinamentointernal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) costruisce il trigram searcher direttamente dalle chiavi di 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) e :55-86/:102-176 (Grep/GrepRegexp) fanno poi:
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 seguono i symlink per impostazione predefinita (nessun O_NOFOLLOW). Né queste funzioni, né internal/mcp/tools_search_text.go:35-126 (handleSearchText, l'handler MCP che chiama s.indexer.GrepText/GrepRegexp), chiamano mai resolveFilePath o guardSymlinkWithinRepo. Quelle due funzioni di confinamento esistono e sono correttamente collegate in ogni altro percorso che serve contenuti — internal/mcp/tools_fileops.go (read_file, write_file/edit_file a partire dal fix v0.55.0), internal/mcp/tools_coding.go:919 (get_symbol_source), internal/mcp/tools_lsp.go:264, internal/mcp/tools_export.go:95 — ma il percorso di trigram grep di search_text è un sink separato a cui non è mai stato dato lo stesso guard. grep -rn guardSymlinkWithinRepo sull'intero albero conferma zero riferimenti in tools_search_text.go, grep.go, o trigram/searcher.go.
Questo è genuinamente distinto da GHSA-w42c-h7hr-f67p:
resolveFilePath che esenta i percorsi assoluti + write_file/edit_file/move_inline che non chiamano mai guardSymlinkWithinRepo (corretto in v0.55.0).search_text, non read_file/get_symbol_source) serve il suo contenuto attraverso un sink (internal/search/trigram) che indipendentemente non chiama mai guardSymlinkWithinRepo. Il fix del percorso di scrittura non ha toccato nessuna di queste due posizioni.
Questo corrisponde al pattern di tool affini già confermato in ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) e vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m): un walk di scoperta file senza protezione symlink, abbinato a un sink che serve contenuti che indipendentemente manca di un guard di confinamento.La traccia statica (riferimenti file:riga sopra) è completa e riproducibile per ispezione. La verifica dinamica è stata tentata ed è genuinamente bloccata da una lacuna della toolchain in questa sandbox, documentata onestamente anziché mascherata: