
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: