
PoC — seguir link simbólico para divulgação de conteúdo fora do repositório via search_text no Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textStatus do CVE: solicitado, aguardando atribuição. Esta descoberta é publicada como GHSA-6vhf-4wcm-2r83. Após a atribuição do CVE, este repositório é renomeado
CVE-YYYY-NNNNN-gortex-PoCe este banner é substituído pelo link do CVE.
| Pesquisador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (Médio) |
| Fraqueza | CWE-59, CWE-200 |
search_textUma falha de resolução inadequada de links (CWE-59) no indexador de repositório do gortex permite que uma entrada de arquivo com symlink apontando para fora da raiz do repositório indexado seja silenciosamente percorrida, indexada em conteúdo e posteriormente servida literalmente — inclusive para um alvo totalmente fora da raiz — pelo sink de releitura ao vivo da ferramenta MCP search_text, que nunca aplica a verificação de confinamento guardSymlinkWithinRepo do próprio projeto.
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25), o HEAD do repositório no momento da auditoria. Confirmado ainda presente na última versão marcada v0.62.0, ou seja, após a correção do não relacionado GHSA-w42c-h7hr-f67p (v0.55.0) ter sido aplicada.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (Médio)
AV:L — corresponde ao próprio precedente de pontuação deste projeto para sua superfície MCP (GHSA-w42c-h7hr-f67p também usou AV:L): a implantação padrão é um daemon MCP local acessado via stdio pelo processo do agente no mesmo host; AV:N só se aplicaria ao modo opt-in --http-addr.UI:R — requer que o cliente MCP (um agente LLM, ou um humano via gortex call) emita uma chamada search_text / search_text regexp:true. Um literal amplo (por exemplo, um único caractere comum) ou uma regex correspondente (.) contra um repositório indexado contendo o symlink plantado é suficiente para despejar o arquivo alvo — sem necessidade de conhecimento secreto de seu conteúdo — então é uma barra baixa, mas é uma ação distinta da mera indexação.C:H — divulgação de conteúdo de arquivo arbitrário, sem limites pelas fronteiras do repositório, de qualquer coisa que o usuário do SO do daemon possa ler (chaves SSH, arquivos de credenciais de nuvem, repositórios de inquilinos irmãos em um daemon multi-repo, etc.), retornado literalmente na resposta da ferramenta.I:N / A:N — este é um bug puramente de caminho de leitura; nada é escrito ou tornado indisponível.internal/indexer/indexer.go:2549-2604, a caminhada primária de admissão de 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 semântica de Lstat, então um diretório com symlink é naturalmente protegido — d.IsDir() é falso para ele e nunca é recursado. Uma entrada de arquivo com symlink não recebe proteção equivalente: nada aqui (nem em shouldExclude/shouldPruneDir, internal/indexer/indexer.go:5724-5813, que apenas correspondem a padrões de caminho no estilo gitignore) chama d.Type()&os.ModeSymlink, os.Lstat ou filepath.EvalSymlinks na entrada de arquivo antes de enfileirá-la. Um arquivo chamado, por exemplo, pwn.go cujo alvo é /etc/passwd, ~/.ssh/id_rsa ou um repositório de inquilino irmão é admitido puramente porque seu nome corresponde a uma extensão registrada (effectiveLanguage, internal/parser DetectLanguageContent, baseado em caminho). Essa lista exata de arquivos percorridos torna-se idx.fileMtimes (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text relê arquivos ao vivo sem guarda de confinamentointernal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) constrói o buscador de trigramas diretamente a partir das chaves 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) e :55-86/:102-176 (Grep/GrepRegexp) então fazem:
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 seguem symlinks por padrão (sem O_NOFOLLOW). Nem essas funções, nem internal/mcp/tools_search_text.go:35-126 (handleSearchText, o handler MCP que chama s.indexer.GrepText/GrepRegexp), jamais chamam resolveFilePath ou guardSymlinkWithinRepo. Essas duas funções de confinamento existem e estão corretamente conectadas a todos os outros caminhos de serviço de conteúdo — internal/mcp/tools_fileops.go (read_file, write_file/edit_file a partir da correção v0.55.0), internal/mcp/tools_coding.go:919 (get_symbol_source), internal/mcp/tools_lsp.go:264, internal/mcp/tools_export.go:95 — mas o caminho de grep de trigramas do search_text é um sink separado que nunca recebeu a mesma guarda. grep -rn guardSymlinkWithinRepo em toda a árvore confirma zero referências em tools_search_text.go, grep.go ou trigram/searcher.go.
Isto é genuinamente distinto de GHSA-w42c-h7hr-f67p:
resolveFilePath isentando caminhos absolutos + write_file/edit_file/move_inline nunca chamando guardSymlinkWithinRepo (corrigido em v0.55.0).search_text, não read_file/get_symbol_source) serve seu conteúdo através de um sink (internal/search/trigram) que independentemente nunca chama guardSymlinkWithinRepo. A correção do caminho de escrita não tocou em nenhum desses dois locais.
Isto corresponde ao padrão de ferramenta irmã já confirmado em ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) e vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m): uma caminhada de descoberta de arquivos sem proteção de symlink, emparelhada com um sink de serviço de conteúdo que independentemente carece de uma guarda de confinamento.Rastreamento estático (referências arquivo:linha acima) está completo e é reproduzível por inspeção. A verificação dinâmica foi tentada e está genuinamente bloqueada por uma lacuna de toolchain neste sandbox, documentada honestamente em vez de mascarada: