
PoC — تتبع الروابط الرمزية للكشف عن محتوى خارج المستودع عبر search_text في Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textحالة CVE: مطلوبة، بانتظار الإسناد. تم نشر هذا الاكتشاف باسم 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يسمح خلل في حل الروابط (CWE-59) في مفهرس المستودعات الخاص بـ gortex بمرور مدخلة ملف مرتبطة رمزياً تشير إلى خارج جذر المستودع المفهرس بصمت، وفهرسة محتواها، ثم تقديمه لاحقاً كما هو — بما في ذلك إلى هدف خارج الجذر بالكامل — عبر مصرف إعادة القراءة الحية لأداة MCP المسماة search_text، والتي لا تطبّق أبداً فحص الحجز guardSymlinkWithinRepo الخاص بالمشروع نفسه.
zzet/gortex — https://github.com/zzet/gortex
الالتزام b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25)، وهو HEAD المستودع وقت التدقيق. تأكد أنه لا يزال موجوداً في أحدث إصدار موسوم v0.62.0، أي بعد أن استقر إصلاح الاستشارة غير ذات الصلة GHSA-w42c-h7hr-f67p (v0.55.0).
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 5.5 (متوسط)
AV:L — يتوافق مع سابقة التقييم الخاصة بهذا المشروع لواجهة MCP الخاصة به (GHSA-w42c-h7hr-f67p استخدم أيضاً AV:L): النشر الافتراضي هو خادم MCP محلي يُوصَل إليه عبر stdio بواسطة عملية الوكيل على نفس المضيف؛ AV:N لا ينطبق إلا على وضع --http-addr الاختياري.UI:R — يتطلب أن يصدر عميل MCP (وكيل LLM، أو إنسان عبر gortex call) استدعاء search_text / search_text regexp:true. يكفي نص حرفي واسع (مثل حرف شائع واحد) أو تعبير نمطي مطابق (.) مقابل مستودع مفهرس يحتوي على الرابط الرمزي المزروع لإفراغ الملف الهدف — دون الحاجة إلى معرفة سرية بمحتواه — لذا فهذا عتبة منخفضة، لكنه إجراء متميز عن مجرد الفهرسة.C:H — الكشف عن محتوى ملفات عشوائية، بلا حدود تحددها حدود المستودع، لأي شيء يستطيع مستخدم نظام التشغيل الخاص بالخادم قراءته (مفاتيح SSH، ملفات بيانات الاعتماد السحابية، مستودعات مستأجرين آخرين في خادم متعدد المستودعات، إلخ)، يُعاد كما هو في استجابة الأداة.I:N / A:N — هذا خلل في مسار القراءة فقط؛ لا يُكتب شيء ولا يُجعل شيء غير متاح.internal/indexer/indexer.go:2549-2604، مسار قبول المجموعة الأساسي:
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، لذا فإن المجلد المرتبط رمزياً محمي بطبيعته — d.IsDir() تكون false له ولا يُتكرر الدخول إليه أبداً. أما مدخلة الملف المرتبط رمزياً فلا تحصل على حماية مكافئة: لا شيء هنا (ولا في shouldExclude/shouldPruneDir، internal/indexer/indexer.go:5724-5813، التي تطابق فقط أنماط مسارات بنمط gitignore) يستدعي d.Type()&os.ModeSymlink أو os.Lstat أو filepath.EvalSymlinks على مدخلة الملف قبل إدراجها في قائمة الانتظار. ملف باسم مثل pwn.go هدفه /etc/passwd أو ~/.ssh/id_rsa أو مستودع مستأجر آخر يُقبل لمجرد أن اسمه يطابق امتداداً مسجلاً (effectiveLanguage، internal/parser DetectLanguageContent، قائم على المسار). قائمة الملفات الممرورة هذه بالضبط تصبح idx.fileMtimes (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text يعيد قراءة الملفات الحية دون حارس حجزinternal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) يبني باحث الـ trigram مباشرة من مفاتيح 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) و: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 الروابط الرمزية افتراضياً (بدون O_NOFOLLOW). لا هذه الدالة ولا internal/mcp/tools_search_text.go:35-126 (handleSearchText، معالج MCP الذي يستدعي s.indexer.GrepText/GrepRegexp) يستدعي أبداً resolveFilePath أو guardSymlinkWithinRepo. هاتان دالتا الحجز موجودتان وموصولتان بشكل صحيح في كل مسار آخر يقدّم المحتوى — internal/mcp/tools_fileops.go (read_file، write_file/edit_file اعتباراً من إصلاح v0.55.0)، internal/mcp/tools_coding.go:919 (get_symbol_source)، internal/mcp/tools_lsp.go:264، internal/mcp/tools_export.go:95 — لكن مسار grep بالـ trigram في search_text مصرف منفصل لم يُمنح نفس الحارس أبداً. يؤكد grep -rn guardSymlinkWithinRepo عبر الشجرة عدم وجود أي إشارات في tools_search_text.go أو grep.go أو trigram/searcher.go.
هذا متميز حقاً عن GHSA-w42c-h7hr-f67p:
resolveFilePath يعفي المسارات المطلقة + write_file/edit_file/move_inline لا تستدعي أبداً guardSymlinkWithinRepo (أُصلح في v0.55.0).search_text، وليس read_file/get_symbol_source) تقدّم محتواه عبر مصرف (internal/search/trigram) لا يستدعي بشكل مستقل أبداً guardSymlinkWithinRepo. لم يمس إصلاح مسار الكتابة أي من هذين الموقعين.
هذا يطابق نمط الأدوات الشقيقة المؤكد بالفعل في ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) وvitali87/code-graph-rag (GHSA-85gg-2gfq-q95m): مسار اكتشاف ملفات دون حماية من الروابط الرمزية، مقترن بمصرف يقدّم المحتوى يفتقر بشكل مستقل إلى حارس حجز.التتبع الساكن (مراجع file:line أعلاه) كامل وقابل لإعادة الإنتاج بالفحص. تمت محاولة التحقق الديناميكي وهو محجوب فعلاً بفجوة في سلسلة الأدوات في هذه البيئة المعزولة، موثّق بأمانة بدلاً من التغطية عليه:
$ 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
كل مستخرج لغة tree-sitter في قاعدة الشيفرة هذه (بما في ذلك مستخرج Go العادي، github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") يتطلب cgo للترجمة — لا يوجد مسار شيفرة Go خالص لتسجيل لغة واحدة حتى، لذا لا يمكن بناء parser.Registry دون مترجم C عامل. لا تحتوي هذه البيئة المعزولة على gcc/cc/clang في $PATH ولا صلاحية root بدون كلمة مرور (sudo -l يطلب كلمة مرور تفاعلية) لتثبيت أحدها عبر apt-get install gcc (موجود في ذاكرة apt المؤقتة، gcc-16/kali-last-snapshot، لكنه غير قابل للوصول دون صلاحية). هذا قيد بيئي، وليس اكتشافاً عن gortex.
اختبار PoC نفسه — المكتوب وفق نمط أداة الاختبار الموجودة في المشروع نفسه (internal/mcp/tools_search_text_test.go's TestSearchText، ونفس شكل newSingleRepoServer/callTool الذي استخدمته PoC الاستشارة السابقة) — متروك في مكانه عند internal/mcp/poc_symlink_search_disclosure_test.go ليُشغّله المشرف في بيئة تحتوي على سلسلة أدوات 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.
}
بالنظر إلى التتبع على مستوى الشيفرة أعلاه — كل خطوة مذكورة بالدالة الدقيقة ولا يوجد استدعاء حجز في أي مكان على المسار من filepath.WalkDir عبر trigram.Build/Grep إلى استجابة handleSearchText — يُقدَّم هذا على أنه متتبَّع ساكناً، غير مؤكد ديناميكياً، وفق القاعدة الأساسية لهذا التدقيق ضد تلفيق أدلة PoC.
أي مستودع مفهرس يحتوي على رابط رمزي هدفه يُحلّ خارج جذر ذلك المستودع (مزروع بواسطة مساهم خبيث، أو موجود في مستودع تبعية/إضافة مستنسخ، أو مُدخَل عبر PR في سلسلة التوريد يفهرسه الخادم لاحقاً) يتيح لعميل MCP — استدعاء search_text عادي، أو استدعاء محقون بالتنبيه/عدائي، بنفس نموذج التهديد الخاص بـ GHSA-w42c-h7hr-f67p — قراءة المحتوى الحرفي لملف الهدف ذلك، بغض النظر عن حارس الحجز الذي يفرضه المشروع خلاف ذلك على read_file وget_symbol_source. في خادم متعدد المستودعات (internal/indexer/multi.go:3525-3562، الذي يوزّع الاستدعاء غير المحروس المطابق لكل مستودع متتبع) يعبر هذا أيضاً حدود المستأجرين/المستودعات داخل نفس نسخة الخادم.
internal/indexer/indexer.go:2549) يقبل مدخلة ملف مرتبطة رمزياً دون فحص Lstat/ModeSymlink.search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) تقدّم المحتوى المحلول عبر الرابط الرمزي دون فحص الحجز guardSymlinkWithinRepo، بخلاف كل مصرف قراءة آخر في نفس قاعدة الشيفرة.طبّق guardSymlinkWithinRepo الموجود في المشروع نفسه (internal/mcp/tools_fileops.go:353) — أو فحصاً مكافئاً قائماً على Lstat — عند النقطتين:
filepath.WalkDir (internal/indexer/indexer.go:2549)، ارفض أو تخطَّ مدخلة ملف DirEntry التي d.Type()&os.ModeSymlink != 0 (محاكياً الحماية الضمنية التي يمنحها Walk للمجلدات بالفعل)، أو حلّل filepath.EvalSymlinks(path) وأكّد الاحتواء داخل absRoot قبل الإضافة إلى files.trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go)، أو عند موقع استدعاء handleSearchText، طبّق نفس فحص احتواء المسار الحقيقي بنمط guardSymlinkWithinRepo المستخدم بالفعل بواسطة read_file/get_symbol_source/tools_lsp/tools_export قبل إعادة Text الخاص بالمطابقة.
أي إصلاح بمفرده يغلق هذا؛ وكلاهما معاً يطابق وضع الحجز الذي تلتزم به بقية قاعدة الشيفرة بالفعل.Dostxodjayev Abdullox (GitHub: @squeeze440)
الإبلاغ الخاص عن الثغرات (PVR) مُفعَّل ومؤكد على zzet/gortex؛ ينطبق تدفق مسودة الاستشارة القياسي لـ GitHub Security Advisory (GHSA)، بما يتوافق مع إفصاح المشروع السابق GHSA-w42c-h7hr-f67p.