
PoC — symlink following to out-of-repo content disclosure via search_text in Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textCVE status: requested, pending assignment. This finding is published as GHSA-6vhf-4wcm-2r83. On CVE assignment this repository is renamed
CVE-YYYY-NNNNN-gortex-PoCand this banner is replaced with the CVE link.
| Researcher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (Medium) |
| Weakness | CWE-59, CWE-200 |
search_textAn improper link resolution flaw (CWE-59) in gortex's repository indexer allows a symlinked file entry pointing outside the indexed repository root to be silently walked, content-indexed, and later served verbatim — including to a fully out-of-root target — by the search_text MCP tool's live re-read sink, which never applies the project's own guardSymlinkWithinRepo confinement check.
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25), the repository HEAD at audit time. Confirmed still present at the latest tagged release v0.62.0, i.e. after the fix for the unrelated GHSA-w42c-h7hr-f67p (v0.55.0) landed.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (Medium)
AV:L — matches this project's own scoring precedent for its MCP surface (GHSA-w42c-h7hr-f67p also used AV:L): the default deployment is a local MCP daemon reached over stdio by the agent process on the same host; AV:N would only apply to the opt-in --http-addr mode.UI:R — requires the MCP client (an LLM agent, or a human via gortex call) to issue a search_text / search_text regexp:true call. A broad literal (e.g. a single common character) or a matching regex (.) against an indexed repo containing the planted symlink is sufficient to dump the target file — no secret knowledge of its contents needed — so this is a low bar, but it is a distinct action from mere indexing.C:H — arbitrary-file-content disclosure, unbounded by repo boundaries, of anything the daemon's OS user can read (SSH keys, cloud credential files, sibling tenants' repos in a multi-repo daemon, etc.), returned verbatim in the tool response.I:N / A:N — this is a pure read-path bug; nothing is written or made unavailable.internal/indexer/indexer.go:2549-2604, the primary corpus-admission walk:
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 uses Lstat semantics, so a symlinked directory is naturally protected — d.IsDir() is false for it and it is never recursed into. A symlinked file entry gets no equivalent protection: nothing here (nor in shouldExclude/shouldPruneDir, internal/indexer/indexer.go:5724-5813, which only match gitignore-style path patterns) calls d.Type()&os.ModeSymlink, os.Lstat, or filepath.EvalSymlinks on the file entry before queuing it. A file named e.g. pwn.go whose target is /etc/passwd, ~/.ssh/id_rsa, or a sibling tenant repo is admitted purely because its name matches a registered extension (effectiveLanguage, internal/parser DetectLanguageContent, path-based). This exact walked-file list becomes idx.fileMtimes (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text sink re-reads live files with no confinement guardinternal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) builds the trigram searcher directly from idx.fileMtimes' keys:
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) and :55-86/:102-176 (Grep/GrepRegexp) then do:
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 follow symlinks by default (no O_NOFOLLOW). Neither function, nor internal/mcp/tools_search_text.go:35-126 (handleSearchText, the MCP handler that calls s.indexer.GrepText/GrepRegexp), ever calls resolveFilePath or guardSymlinkWithinRepo. Those two confinement functions exist and are correctly wired into every other content-serving path — internal/mcp/tools_fileops.go (read_file, write_file/edit_file as of the v0.55.0 fix), internal/mcp/tools_coding.go:919 (get_symbol_source), internal/mcp/tools_lsp.go:264, internal/mcp/tools_export.go:95 — but search_text's trigram grep path is a separate sink that was never given the same guard. grep -rn guardSymlinkWithinRepo across the tree confirms zero references in tools_search_text.go, grep.go, or trigram/searcher.go.
This is genuinely distinct from GHSA-w42c-h7hr-f67p:
resolveFilePath exempting absolute paths + write_file/edit_file/move_inline never calling guardSymlinkWithinRepo (fixed in v0.55.0).search_text, not read_file/get_symbol_source) serves its content through a sink (internal/search/trigram) that independently never calls guardSymlinkWithinRepo. The write-path fix did not touch either of these two locations.
This matches the sibling-tool pattern already confirmed in ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) and vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m): a file-discovery walk without symlink protection, paired with a content-serving sink that independently lacks a confinement guard.Static trace (file:line references above) is complete and reproducible by inspection. Dynamic verification was attempted and is genuinely blocked by a toolchain gap in this sandbox, documented honestly rather than papered over:
$ 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
Every tree-sitter language extractor in this codebase (including the plain Go extractor, github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") requires cgo to compile — there is no pure-Go code path to register even a single language, so parser.Registry cannot be built without a working C compiler. This sandbox has no gcc/cc/clang on $PATH and no passwordless root (sudo -l demands an interactive password) to install one via apt-get install gcc (present in the apt cache, gcc-16/kali-last-snapshot, but unreachable without privilege). This is an environment limitation, not a finding about gortex.