
PoC — Symlink-Verfolgung zur Offenlegung von Inhalten außerhalb des Repos über search_text in Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textCVE-Status: beantragt, Zuweisung ausstehend. Dieser Befund wird als GHSA-6vhf-4wcm-2r83 veröffentlicht. Bei CVE-Zuweisung wird dieses Repository in
CVE-YYYY-NNNNN-gortex-PoCumbenannt und dieses Banner durch den CVE-Link ersetzt.
| Forscher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (Mittel) |
| Schwäche | CWE-59, CWE-200 |
search_textEin Fehler bei der unsachgemäßen Link-Auflösung (CWE-59) im Repository-Indexer von gortex ermöglicht es, dass ein symlinkter Datei-Eintrag, der auf ein Ziel außerhalb des indizierten Repository-Roots zeigt, stillschweigend durchlaufen, in den Index aufgenommen und später wörtlich ausgeliefert wird — einschließlich an ein vollständig außerhalb des Roots liegendes Ziel — durch die Live-Re-Read-Senke des MCP-Tools search_text, die niemals die eigene guardSymlinkWithinRepo-Eingrenzungsprüfung des Projekts anwendet.
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25), der Repository-HEAD zum Zeitpunkt des Audits. Bestätigt weiterhin vorhanden im neuesten getaggten Release v0.62.0, d. h. nachdem der Fix für das unabhängige GHSA-w42c-h7hr-f67p (v0.55.0) eingeflossen ist.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (Mittel)
AV:L — entspricht dem eigenen Scoring-Präzedenzfall dieses Projekts für seine MCP-Oberfläche (GHSA-w42c-h7hr-f67p verwendete ebenfalls AV:L): Die Standardbereitstellung ist ein lokaler MCP-Daemon, der über stdio vom Agent-Prozess auf demselben Host erreicht wird; AV:N würde nur für den optionalen --http-addr-Modus gelten.UI:R — erfordert, dass der MCP-Client (ein LLM-Agent oder ein Mensch über gortex call) einen search_text- / search_text regexp:true-Aufruf ausführt. Ein breites Literal (z. B. ein einzelnes häufiges Zeichen) oder eine passende Regex (.) gegen ein indiziertes Repo, das den platzierten Symlink enthält, reicht aus, um die Zieldatei auszugeben — kein geheimes Wissen über deren Inhalt erforderlich —, also eine niedrige Hürde, aber dennoch eine eigenständige Aktion gegenüber dem bloßen Indexieren.C:H — Offenlegung beliebiger Dateiinhalte, unbegrenzt durch Repo-Grenzen, von allem, was der OS-Benutzer des Daemons lesen kann (SSH-Schlüssel, Cloud-Credential-Dateien, Repos benachbarter Tenants in einem Multi-Repo-Daemon usw.), wörtlich in der Tool-Antwort zurückgegeben.I:N / A:N — dies ist ein reiner Lesepfad-Fehler; nichts wird geschrieben oder unverfügbar gemacht.internal/indexer/indexer.go:2549-2604, der primäre Korpus-Aufnahme-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 verwendet Lstat-Semantik, sodass ein symlinktes Verzeichnis natürlicherweise geschützt ist — d.IsDir() ist dafür false und es wird nie rekursiv betreten. Ein symlinkter Datei-Eintrag erhält keinen äquivalenten Schutz: Nichts hier (noch in shouldExclude/shouldPruneDir, internal/indexer/indexer.go:5724-5813, die nur gitignore-artige Pfadmuster abgleichen) ruft d.Type()&os.ModeSymlink, os.Lstat oder filepath.EvalSymlinks für den Datei-Eintrag auf, bevor er in die Warteschlange gestellt wird. Eine Datei namens z. B. pwn.go, deren Ziel /etc/passwd, ~/.ssh/id_rsa oder ein Repo eines benachbarten Tenants ist, wird allein deshalb aufgenommen, weil ihr Name auf eine registrierte Erweiterung passt (effectiveLanguage, internal/parser DetectLanguageContent, pfadbasiert). Genau diese walked-file-Liste wird zu idx.fileMtimes (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text-Senke liest Live-Dateien ohne Eingrenzungsschutz erneutinternal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) baut den Trigram-Searcher direkt aus den Schlüsseln von 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) und :55-86/:102-176 (Grep/GrepRegexp) tun dann:
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 folgen standardmäßig Symlinks (kein O_NOFOLLOW). Weder diese Funktionen noch internal/mcp/tools_search_text.go:35-126 (handleSearchText, der MCP-Handler, der s.indexer.GrepText/GrepRegexp aufruft) rufen jemals resolveFilePath oder guardSymlinkWithinRepo auf. Diese beiden Eingrenzungsfunktionen existieren und sind korrekt in jeden anderen inhaltsausliefernden Pfad eingebunden — internal/mcp/tools_fileops.go (read_file, write_file/edit_file seit dem 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 —, aber der Trigram-Grep-Pfad von search_text ist eine separate Senke, die nie denselben Schutz erhalten hat. grep -rn guardSymlinkWithinRepo über den gesamten Baum bestätigt null Referenzen in tools_search_text.go, grep.go oder trigram/searcher.go.
Dies ist tatsächlich eigenständig gegenüber GHSA-w42c-h7hr-f67p:
resolveFilePath nimmt absolute Pfade aus + write_file/edit_file/move_inline rufen nie guardSymlinkWithinRepo auf (behoben in v0.55.0).search_text, nicht read_file/get_symbol_source) liefert deren Inhalt über eine Senke (internal/search/trigram) aus, die unabhängig davon nie guardSymlinkWithinRepo aufruft. Der Schreibpfad-Fix hat keinen dieser beiden Orte berührt.
Dies entspricht dem bereits in ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) und vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m) bestätigten Schwester-Tool-Muster: ein Datei-Discovery-Walk ohne Symlink-Schutz, gepaart mit einer inhaltsausliefernden Senke, der unabhängig davon ein Eingrenzungsschutz fehlt.Statische Spur (Datei:Zeile-Referenzen oben) ist vollständig und durch Inspektion reproduzierbar. Dynamische Verifikation wurde versucht und wird tatsächlich durch eine Toolchain-Lücke in dieser Sandbox blockiert, ehrlich dokumentiert statt übertüncht:
$ 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
Jeder tree-sitter-Sprach-Extraktor in dieser Codebasis (einschließlich des reinen Go-Extraktors, github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") erfordert cgo zum Kompilieren — es gibt keinen reinen Go-Codepfad, um auch nur eine einzige Sprache zu registrieren, sodass parser.Registry ohne einen funktionierenden C-Compiler nicht gebaut werden kann. Diese Sandbox hat kein gcc/cc/clang auf $PATH und kein passwortloses Root (sudo -l verlangt ein interaktives Passwort), um eines über apt-get install gcc zu installieren (im apt-Cache vorhanden, gcc-16/kali-last-snapshot, aber ohne Privilegien unerreichbar). Dies ist eine Umgebungsbeschränkung, kein Befund über gortex.
Der PoC-Test selbst — geschrieben gegen das eigene bestehende Test-Harness-Muster des Projekts (internal/mcp/tools_search_text_test.go's TestSearchText und dieselbe newSingleRepoServer/callTool-Form, die der PoC der vorherigen Advisory verwendete) — bleibt unter internal/mcp/poc_symlink_search_disclosure_test.go liegen, damit der Maintainer ihn in einer Umgebung mit C-Toolchain ausführen kann:
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.
}
Angesichts der obigen Quellcode-Spur — jeder Schritt durch exakte Funktion benannt und kein Eingrenzungsaufruf irgendwo auf dem Pfad von filepath.WalkDir über trigram.Build/Grep bis zur Antwort von handleSearchText — wird dies als statisch nachverfolgt, nicht dynamisch bestätigt präsentiert, gemäß der Grundregel dieses Audits gegen die Fabrikation von PoC-Beweisen.
Jedes indizierte Repository, das einen Symlink enthält, dessen Ziel außerhalb des Repository-Roots aufgelöst wird (platziert von einem bösartigen Contributor, vorhanden in einem geklonten Dependency-/Plugin-Repo oder eingeführt über einen Supply-Chain-PR, den der Daemon später indiziert), ermöglicht es einem MCP-Client — ein normaler search_text-Aufruf oder ein prompt-injizierter/adversarialer, dasselbe Bedrohungsmodell wie GHSA-w42c-h7hr-f67p —, den wörtlichen Inhalt dieser Zieldatei zu lesen, ungeachtet des Eingrenzungsschutzes, den das Projekt ansonsten für read_file und get_symbol_source durchsetzt. In einem Multi-Repo-Daemon (internal/indexer/multi.go:3525-3562, der den identischen ungeschützten Aufruf pro verfolgtem Repo ausfächert) überschreitet dies zudem Tenant-/Repo-Grenzen innerhalb derselben Daemon-Instanz.
internal/indexer/indexer.go:2549) nimmt einen symlinkten Datei-Eintrag ohne Lstat/ModeSymlink-Prüfung auf.search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) liefern den symlink-aufgelösten Inhalt ohne guardSymlinkWithinRepo-Eingrenzungsprüfung aus, anders als jede andere Lese-Senke in derselben Codebasis.Wenden Sie das eigene bestehende guardSymlinkWithinRepo des Projekts (internal/mcp/tools_fileops.go:353) — oder eine äquivalente Lstat-basierte Prüfung — an beiden Stellen an:
filepath.WalkDir-Callback (internal/indexer/indexer.go:2549) einen Datei-DirEntry ablehnen oder überspringen, dessen d.Type()&os.ModeSymlink != 0 (spiegelt den impliziten Schutz, den Walk Verzeichnissen bereits gewährt), oder filepath.EvalSymlinks(path) auflösen und die Eingrenzung innerhalb absRoot bestätigen, bevor an files angehängt wird.trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) oder an der Aufrufstelle handleSearchText dieselbe guardSymlinkWithinRepo-artige Real-Path-Eingrenzungsprüfung anwenden, die bereits von read_file/get_symbol_source/tools_lsp/tools_export verwendet wird, bevor der Text eines Treffers zurückgegeben wird.
Jeder Fix allein schließt dies; beide zusammen entsprechen der Eingrenzungshaltung, die der Rest der Codebasis bereits einhält.Dostxodjayev Abdullox (GitHub: @squeeze440)
Private Vulnerability Reporting (PVR) ist auf zzet/gortex aktiviert und bestätigt; der standardmäßige GitHub Security Advisory (GHSA) Draft-Advisory-Ablauf gilt, konsistent mit der vorherigen GHSA-w42c-h7hr-f67p-Offenlegung des Projekts.