
PoC — Gortex में search_text के माध्यम से रेपो से बाहर की सामग्री के प्रकटीकरण हेतु symlink फ़ॉलोइंग (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5)।
search_text के माध्यम से Out-of-Repo Content DisclosureCVE स्थिति: अनुरोध किया गया, असाइनमेंट लंबित। यह निष्कर्ष GHSA-6vhf-4wcm-2r83 के रूप में प्रकाशित किया गया है। CVE असाइनमेंट पर इस रिपॉज़िटरी का नाम बदलकर
CVE-YYYY-NNNNN-gortex-PoCकर दिया जाएगा और इस बैनर को CVE लिंक से बदल दिया जाएगा।
| शोधकर्ता | Dostxodjayev Abdullox (@squeeze440) |
| सलाह | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (मध्यम) |
| कमज़ोरी | CWE-59, CWE-200 |
search_text के माध्यम से Out-of-Repo Content Disclosuregortex के रिपॉज़िटरी indexer में एक अनुचित लिंक रिज़ॉल्यूशन दोष (CWE-59) एक symlinked file प्रविष्टि को, जो indexed रिपॉज़िटरी रूट के बाहर इंगित करती है, चुपचाप walk होने, content-indexed होने, और बाद में verbatim रूप से serve होने की अनुमति देता है — जिसमें पूरी तरह से out-of-root लक्ष्य भी शामिल है — search_text MCP टूल के live re-read sink द्वारा, जो कभी प्रोजेक्ट के स्वयं के guardSymlinkWithinRepo confinement check को लागू नहीं करता।
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25), ऑडिट के समय रिपॉज़िटरी HEAD। नवीनतम tagged release v0.62.0 पर भी अभी भी मौजूद होने की पुष्टि की गई, अर्थात् असंबंधित GHSA-w42c-h7hr-f67p (v0.55.0) के fix के बाद भी।
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (मध्यम)
AV:L — इस प्रोजेक्ट के अपने MCP सरफेस के लिए scoring precedent से मेल खाता है (GHSA-w42c-h7hr-f67p ने भी AV:L का उपयोग किया): डिफ़ॉल्ट deployment एक local MCP daemon है जिसे उसी होस्ट पर agent प्रक्रिया द्वारा stdio के माध्यम से पहुँचा जाता है; AV:N केवल opt-in --http-addr मोड पर लागू होगा।UI:R — इसके लिए MCP client (एक LLM agent, या gortex call के माध्यम से एक मानव) को search_text / search_text regexp:true कॉल जारी करने की आवश्यकता होती है। एक व्यापक literal (जैसे एक सामान्य वर्ण) या एक मेल खाता regex (.) जो planted symlink वाली indexed repo के विरुद्ध है, target फ़ाइल को dump करने के लिए पर्याप्त है — इसकी सामग्री का कोई गुप्त ज्ञान आवश्यक नहीं — इसलिए यह एक निम्न बाधा है, लेकिन यह केवल indexing से एक अलग क्रिया है।C:H — मनमानी-फ़ाइल-सामग्री का disclosure, repo सीमाओं से अपरिबद्ध, जो भी daemon का OS उपयोगकर्ता पढ़ सकता है (SSH keys, cloud credential files, multi-repo daemon में sibling tenants की repos, आदि), टूल प्रतिक्रिया में verbatim लौटाया गया।I:N / A:N — यह एक शुद्ध read-path बग है; कुछ भी लिखा या अनुपलब्ध नहीं किया जाता।internal/indexer/indexer.go:2549-2604, प्राथमिक 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 Lstat semantics का उपयोग करता है, इसलिए एक symlinked directory स्वाभाविक रूप से सुरक्षित है — इसके लिए d.IsDir() false है और इसमें कभी recurse नहीं किया जाता। एक symlinked file प्रविष्टि को कोई समकक्ष सुरक्षा नहीं मिलती: यहाँ कुछ भी (और न ही shouldExclude/shouldPruneDir में, internal/indexer/indexer.go:5724-5813, जो केवल gitignore-style path patterns से मेल खाते हैं) फ़ाइल प्रविष्टि को queue करने से पहले d.Type()&os.ModeSymlink, os.Lstat, या filepath.EvalSymlinks को कॉल नहीं करता। pwn.go नाम की एक फ़ाइल जिसका लक्ष्य /etc/passwd, ~/.ssh/id_rsa, या एक sibling tenant repo है, केवल इसलिए स्वीकार की जाती है क्योंकि इसका नाम एक पंजीकृत extension से मेल खाता है (effectiveLanguage, internal/parser DetectLanguageContent, path-based)। यह ठीक walked-file सूची idx.fileMtimes बन जाती है (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano)।
search_text sink बिना किसी confinement guard के live फ़ाइलों को फिर से पढ़ता हैinternal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) सीधे idx.fileMtimes की keys से trigram searcher बनाता है:
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) और :55-86/:102-176 (Grep/GrepRegexp) फिर यह करते हैं:
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 डिफ़ॉल्ट रूप से symlinks का अनुसरण करते हैं (कोई O_NOFOLLOW नहीं)। न तो कोई फ़ंक्शन, और न ही internal/mcp/tools_search_text.go:35-126 (handleSearchText, वह MCP handler जो s.indexer.GrepText/GrepRegexp को कॉल करता है), कभी resolveFilePath या guardSymlinkWithinRepo को कॉल करता है। वे दो confinement फ़ंक्शन मौजूद हैं और हर अन्य content-serving path में सही ढंग से wired हैं — internal/mcp/tools_fileops.go (read_file, write_file/edit_file 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 — लेकिन search_text का trigram grep path एक अलग sink है जिसे कभी वही guard नहीं दिया गया। पूरे tree में grep -rn guardSymlinkWithinRepo tools_search_text.go, grep.go, या trigram/searcher.go में शून्य संदर्भों की पुष्टि करता है।
यह वास्तव में GHSA-w42c-h7hr-f67p से अलग है:
resolveFilePath absolute paths को छूट देता है + write_file/edit_file/move_inline कभी guardSymlinkWithinRepo को कॉल नहीं करते (v0.55.0 में fixed)।search_text, न कि read_file/get_symbol_source) अपनी सामग्री को एक sink (internal/search/trigram) के माध्यम से serve करता है जो स्वतंत्र रूप से कभी guardSymlinkWithinRepo को कॉल नहीं करता। write-path fix ने इन दोनों स्थानों में से किसी को भी नहीं छुआ।
यह sibling-tool पैटर्न से मेल खाता है जिसकी पुष्टि पहले ही ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) और vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m) में की जा चुकी है: symlink सुरक्षा के बिना एक file-discovery walk, जिसके साथ एक content-serving sink जोड़ा गया है जिसमें स्वतंत्र रूप से confinement guard का अभाव है।Static trace (ऊपर file:line संदर्भ) पूर्ण है और निरीक्षण द्वारा पुनरुत्पादनीय है। Dynamic verification का प्रयास किया गया और यह इस sandbox में एक toolchain gap द्वारा वास्तव में अवरुद्ध है, जिसे छिपाने के बजाय ईमानदारी से प्रलेखित किया गया है:
$ 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
इस codebase में प्रत्येक tree-sitter language extractor (सादे Go extractor सहित, github.com/tree-sitter/tree-sitter-go का bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") को compile करने के लिए cgo की आवश्यकता होती है — एक भी भाषा को register करने के लिए कोई pure-Go code path नहीं है, इसलिए parser.Registry को काम करने वाले C compiler के बिना नहीं बनाया जा सकता। इस sandbox में $PATH पर कोई gcc/cc/clang नहीं है और apt-get install gcc के माध्यम से एक स्थापित करने के लिए कोई passwordless root नहीं है (sudo -l एक interactive password की मांग करता है) (apt cache में मौजूद, gcc-16/kali-last-snapshot, लेकिन privilege के बिना अगम्य)। यह एक environment सीमा है, gortex के बारे में कोई निष्कर्ष नहीं।
PoC test स्वयं — प्रोजेक्ट के अपने मौजूदा test harness पैटर्न के विरुद्ध लिखा गया (internal/mcp/tools_search_text_test.go का TestSearchText, और वही newSingleRepoServer/callTool आकार जो पूर्व advisory के अपने PoC ने उपयोग किया) — maintainer के लिए C toolchain वाले वातावरण में चलाने के लिए internal/mcp/poc_symlink_search_disclosure_test.go पर छोड़ दिया गया है:
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.
}
ऊपर दिए गए source-level trace को देखते हुए — प्रत्येक चरण का नाम सटीक फ़ंक्शन द्वारा और filepath.WalkDir से trigram.Build/Grep के माध्यम से handleSearchText की प्रतिक्रिया तक के path पर कहीं भी कोई confinement कॉल नहीं — इसे statically traced, dynamically confirmed नहीं के रूप में प्रस्तुत किया गया है, इस ऑडिट के PoC साक्ष्य को गढ़ने के विरुद्ध ground rule के अनुसार।
कोई भी indexed रिपॉज़िटरी जिसमें एक symlink है जिसका लक्ष्य उस repo के रूट के बाहर resolve होता है (एक दुर्भावनापूर्ण contributor द्वारा planted, एक cloned dependency/plugin repo में मौजूद, या एक supply-chain PR के माध्यम से पेश किया गया जिसे daemon बाद में index करता है) एक MCP client को — एक सामान्य search_text कॉल, या एक prompt-injected/adversarial कॉल, वही threat model जो GHSA-w42c-h7hr-f67p का है — उस target फ़ाइल की verbatim सामग्री पढ़ने देता है, चाहे प्रोजेक्ट अन्यथा read_file और get_symbol_source पर जो confinement guard लागू करता है। एक multi-repo daemon में (internal/indexer/multi.go:3525-3562, जो प्रत्येक tracked repo के लिए समान unguarded कॉल को fan out करता है) यह उसी daemon instance के भीतर tenant/repo सीमाओं को भी पार करता है।
internal/indexer/indexer.go:2549) बिना किसी Lstat/ModeSymlink check के एक symlinked file प्रविष्टि को स्वीकार करता है।search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) symlink-resolved सामग्री को बिना किसी guardSymlinkWithinRepo confinement check के serve करते हैं, जो उसी codebase में हर अन्य read sink के विपरीत है।प्रोजेक्ट के अपने मौजूदा guardSymlinkWithinRepo (internal/mcp/tools_fileops.go:353) — या एक समकक्ष Lstat-आधारित check — को दोनों बिंदुओं पर लागू करें:
filepath.WalkDir callback (internal/indexer/indexer.go:2549) में, एक फ़ाइल DirEntry को अस्वीकार या छोड़ें जिसका d.Type()&os.ModeSymlink != 0 है (उस अंतर्निहित सुरक्षा को प्रतिबिंबित करते हुए जो Walk पहले से directories को देता है), या filepath.EvalSymlinks(path) को resolve करें और files में जोड़ने से पहले absRoot के भीतर containment की पुष्टि करें।trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) में, या handleSearchText call site पर, एक match का Text लौटाने से पहले वही guardSymlinkWithinRepo-शैली real-path containment check लागू करें जो पहले से read_file/get_symbol_source/tools_lsp/tools_export द्वारा उपयोग किया जाता है।
अकेले कोई भी fix इसे बंद कर देता है; दोनों मिलकर उस confinement posture से मेल खाते हैं जिसे शेष codebase पहले से बनाए रखता है।Dostxodjayev Abdullox (GitHub: @squeeze440)
Private Vulnerability Reporting (PVR) zzet/gortex पर सक्षम और पुष्ट है; मानक GitHub Security Advisory (GHSA) draft-advisory प्रवाह लागू होता है, जो प्रोजेक्ट के पूर्व GHSA-w42c-h7hr-f67p disclosure के अनुरूप है।