
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:
$ 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
Todo extrator de linguagem tree-sitter neste código (incluindo o extrator Go simples, github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") requer cgo para compilar — não há caminho de código puramente Go para registrar sequer uma única linguagem, então parser.Registry não pode ser construído sem um compilador C funcional. Este sandbox não tem gcc/cc/clang no $PATH e nenhum root sem senha (sudo -l exige uma senha interativa) para instalar um via apt-get install gcc (presente no cache do apt, gcc-16/kali-last-snapshot, mas inacessível sem privilégio). Esta é uma limitação do ambiente, não uma descoberta sobre o gortex.
O próprio teste PoC — escrito contra o padrão de harness de teste existente do projeto (internal/mcp/tools_search_text_test.go's TestSearchText, e a mesma forma newSingleRepoServer/callTool que o próprio PoC do aviso anterior usou) — é deixado no lugar em internal/mcp/poc_symlink_search_disclosure_test.go para o mantenedor executar em um ambiente com uma 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.
}
Dado o rastreamento em nível de código-fonte acima — cada passo nomeado por função exata e nenhuma chamada de confinamento em qualquer lugar no caminho de filepath.WalkDir através de trigram.Build/Grep até a resposta de handleSearchText — isto é apresentado como estaticamente rastreado, não dinamicamente confirmado, conforme a regra fundamental desta auditoria contra fabricar evidências de PoC.
Qualquer repositório indexado contendo um symlink cujo alvo resolve para fora da raiz desse repositório (plantado por um contribuidor malicioso, presente em um repositório de dependência/plugin clonado, ou introduzido via um PR de cadeia de suprimentos que o daemon posteriormente indexa) permite que um cliente MCP — uma chamada normal de search_text, ou uma com injeção de prompt/adversarial, mesmo modelo de ameaça que GHSA-w42c-h7hr-f67p — leia o conteúdo literal desse arquivo alvo, independentemente da guarda de confinamento que o projeto de outra forma impõe em read_file e get_symbol_source. Em um daemon multi-repo (internal/indexer/multi.go:3525-3562, que distribui a chamada idêntica sem guarda por repositório rastreado) isto também cruza fronteiras de inquilino/repositório dentro da mesma instância de daemon.
internal/indexer/indexer.go:2549) admite uma entrada de arquivo com symlink sem verificação de Lstat/ModeSymlink.search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) servem o conteúdo resolvido por symlink sem verificação de confinamento guardSymlinkWithinRepo, ao contrário de todos os outros sinks de leitura no mesmo código.Aplique o próprio guardSymlinkWithinRepo existente do projeto (internal/mcp/tools_fileops.go:353) — ou uma verificação equivalente baseada em Lstat — em ambos os pontos:
filepath.WalkDir (internal/indexer/indexer.go:2549), rejeite ou pule um DirEntry de arquivo cujo d.Type()&os.ModeSymlink != 0 (espelhando a proteção implícita que Walk já dá a diretórios), ou resolva filepath.EvalSymlinks(path) e confirme o confinamento dentro de absRoot antes de anexar a files.trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go), ou no local de chamada de handleSearchText, aplique a mesma verificação de confinamento de caminho real no estilo guardSymlinkWithinRepo já usada por read_file/get_symbol_source/tools_lsp/tools_export antes de retornar o Text de uma correspondência.
Qualquer uma das correções isoladamente fecha isto; ambas juntas correspondem à postura de confinamento que o resto do código já mantém para si.Dostxodjayev Abdullox (GitHub: @squeeze440)
O Relato Privado de Vulnerabilidades (PVR) está habilitado e confirmado em zzet/gortex; o fluxo padrão de rascunho de aviso do GitHub Security Advisory (GHSA) se aplica, consistente com a divulgação anterior do projeto GHSA-w42c-h7hr-f67p.