
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:
$ 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
Ogni estrattore di linguaggio tree-sitter in questo codebase (incluso l'estrattore Go semplice, github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") richiede cgo per compilare — non esiste un percorso di codice puro-Go per registrare anche un solo linguaggio, quindi parser.Registry non può essere costruito senza un compilatore C funzionante. Questa sandbox non ha gcc/cc/clang nel $PATH e nessun root senza password (sudo -l richiede una password interattiva) per installarne uno tramite apt-get install gcc (presente nella cache apt, gcc-16/kali-last-snapshot, ma irraggiungibile senza privilegi). Questa è una limitazione dell'ambiente, non una scoperta su gortex.
Il test PoC stesso — scritto seguendo il pattern dell'harness di test esistente del progetto (internal/mcp/tools_search_text_test.go's TestSearchText, e la stessa forma newSingleRepoServer/callTool usata dal PoC dell'advisory precedente) — è lasciato in posizione in internal/mcp/poc_symlink_search_disclosure_test.go per il maintainer da eseguire in un ambiente con una toolchain 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.
}
Data la traccia a livello di sorgente sopra — ogni passo nominato con funzione esatta e nessuna chiamata di confinamento in nessun punto del percorso da filepath.WalkDir attraverso trigram.Build/Grep fino alla risposta di handleSearchText — questo è presentato come tracciato staticamente, non confermato dinamicamente, secondo la regola fondamentale di questo audit contro la fabbricazione di prove PoC.
Qualsiasi repository indicizzato contenente un symlink il cui target si risolve al di fuori della radice di quel repo (piantato da un contributore malevolo, presente in un repo di dipendenza/plugin clonato, o introdotto tramite una PR di supply-chain che il daemon indicizza successivamente) consente a un client MCP — una normale chiamata search_text, o una con prompt-injection/avversariale, stesso modello di minaccia di GHSA-w42c-h7hr-f67p — di leggere il contenuto testuale di quel file target, indipendentemente dal guard di confinamento che il progetto altrimenti applica a read_file e get_symbol_source. In un daemon multi-repo (internal/indexer/multi.go:3525-3562, che propaga la chiamata identica non protetta per ogni repo tracciato) questo attraversa anche i confini tra tenant/repo all'interno della stessa istanza del daemon.
internal/indexer/indexer.go:2549) ammette una voce di file symlink senza alcun controllo Lstat/ModeSymlink.search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) servono il contenuto risolto dal symlink senza alcun controllo di confinamento guardSymlinkWithinRepo, a differenza di ogni altro sink di lettura nello stesso codebase.Applicare il guardSymlinkWithinRepo già esistente del progetto (internal/mcp/tools_fileops.go:353) — o un controllo equivalente basato su Lstat — in entrambi i punti:
filepath.WalkDir (internal/indexer/indexer.go:2549), rifiutare o saltare una DirEntry di file il cui d.Type()&os.ModeSymlink != 0 (rispecchiando la protezione implicita che Walk già fornisce alle directory), oppure risolvere filepath.EvalSymlinks(path) e confermare il contenimento all'interno di absRoot prima di accodare a files.trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go), o nel call site di handleSearchText, applicare lo stesso controllo di contenimento del percorso reale in stile guardSymlinkWithinRepo già usato da read_file/get_symbol_source/tools_lsp/tools_export prima di restituire il Text di un match.
Ciascun fix da solo chiude questo problema; entrambi insieme corrispondono alla postura di confinamento che il resto del codebase già si impone.Dostxodjayev Abdullox (GitHub: @squeeze440)
Il Private Vulnerability Reporting (PVR) è abilitato e confermato su zzet/gortex; si applica il flusso standard di draft-advisory di GitHub Security Advisory (GHSA), coerente con la precedente divulgazione GHSA-w42c-h7hr-f67p del progetto.