
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): обход обнаружения файлов без защиты от символических ссылок в паре с приёмником обслуживания содержимого, который независимо лишён проверки ограничения.Статическая трассировка (ссылки файл:строка выше) является полной и воспроизводимой путём инспекции. Динамическая верификация была предпринята и действительно заблокирована пробелом в инструментальной цепочке в этой песочнице, что задокументировано честно, а не замаскировано:
$ 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-тест — написанный по собственному существующему паттерну тестового стенда проекта (TestSearchText из internal/mcp/tools_search_text_test.go и та же форма 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)
Private Vulnerability Reporting (PVR) включён и подтверждён на zzet/gortex; применяется стандартный процесс черновика GitHub Security Advisory (GHSA), согласующийся с предыдущим раскрытием GHSA-w42c-h7hr-f67p проекта.