
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 — раскрытие содержимого произвольных файлов, не ограниченное границами репозитория, всего, что может прочитать OS-пользователь демона (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() для неё ложно, и в неё никогда не происходит рекурсивный спуск. Запись файла, являющегося символической ссылкой, не получает эквивалентной защиты: ничто здесь (ни в 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) строит триграммный поисковик непосредственно из ключей 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 в 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): обход обнаружения файлов без защиты от символических ссылок в паре с приёмником обслуживания содержимого, который независимо лишён проверки ограничения.