
PoC — seguimiento de enlaces simbólicos para la divulgación de contenido fuera del repositorio mediante search_text en Gortex (GHSA-6vhf-4wcm-2r83, CVE-2026-87003, CVSS 5.5).
search_textEstado del CVE: solicitado, asignación pendiente. Este hallazgo se publica como GHSA-6vhf-4wcm-2r83. Tras la asignación del CVE, este repositorio se renombra
CVE-YYYY-NNNNN-gortex-PoCy este banner se reemplaza con el enlace del CVE.
| Investigador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-6vhf-4wcm-2r83 |
| CVSS 3.1 | 5.5 (Medio) |
| Debilidad | CWE-59, CWE-200 |
search_textUna falla de resolución incorrecta de enlaces (CWE-59) en el indexador de repositorios de gortex permite que una entrada de archivo con enlace simbólico que apunta fuera de la raíz del repositorio indexado sea recorrida silenciosamente, indexada en contenido y posteriormente servida de forma literal — incluso hacia un objetivo completamente fuera de la raíz — por el sumidero de relectura en vivo de la herramienta MCP search_text, que nunca aplica la comprobación de confinamiento guardSymlinkWithinRepo propia del proyecto.
zzet/gortex — https://github.com/zzet/gortex
Commit b5a63e25c0719c8ce935e59abf85618e2b4b1e64 (git describe: v0.62.0-35-gb5a63e25), el HEAD del repositorio en el momento de la auditoría. Se confirmó que sigue presente en la última versión etiquetada v0.62.0, es decir, después de que se aplicara la corrección del aviso no relacionado 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 (Medio)
AV:L — coincide con el propio precedente de puntuación de este proyecto para su superficie MCP (GHSA-w42c-h7hr-f67p también usó AV:L): el despliegue predeterminado es un daemon MCP local alcanzado por stdio por el proceso del agente en el mismo host; AV:N solo aplicaría al modo opcional --http-addr.UI:R — requiere que el cliente MCP (un agente LLM, o un humano mediante gortex call) emita una llamada search_text / search_text regexp:true. Un literal amplio (por ejemplo, un único carácter común) o una expresión regular coincidente (.) contra un repositorio indexado que contenga el enlace simbólico plantado es suficiente para volcar el archivo objetivo — sin necesidad de conocer su contenido secreto — por lo que el umbral es bajo, pero es una acción distinta de la mera indexación.C:H — divulgación de contenido arbitrario de archivos, sin límites por las fronteras del repositorio, de cualquier cosa que el usuario del SO del daemon pueda leer (claves SSH, archivos de credenciales en la nube, repositorios de inquilinos hermanos en un daemon multi-repositorio, etc.), devuelta de forma literal en la respuesta de la herramienta.I:N / A:N — esto es un bug puro de la ruta de lectura; no se escribe ni se deja indisponible nada.internal/indexer/indexer.go:2549-2604, el recorrido principal de admisión al corpus:
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 usa semántica de Lstat, por lo que un directorio con enlace simbólico está naturalmente protegido — d.IsDir() es falso para él y nunca se recurre en él. Una entrada de archivo con enlace simbólico no recibe protección equivalente: nada aquí (ni en shouldExclude/shouldPruneDir, internal/indexer/indexer.go:5724-5813, que solo coinciden con patrones de ruta estilo gitignore) llama a d.Type()&os.ModeSymlink, os.Lstat o filepath.EvalSymlinks sobre la entrada de archivo antes de encolarla. Un archivo llamado, por ejemplo, pwn.go cuyo objetivo sea /etc/passwd, ~/.ssh/id_rsa o un repositorio de inquilino hermano es admitido únicamente porque su nombre coincide con una extensión registrada (effectiveLanguage, internal/parser DetectLanguageContent, basado en ruta). Esta lista exacta de archivos recorridos se convierte en idx.fileMtimes (internal/indexer/indexer.go:3760: idx.fileMtimes[idx.relKey(f.path)] = f.mtimeNano).
search_text relee archivos en vivo sin ninguna protección de confinamientointernal/indexer/grep.go:17-64 (GrepText / warmTrigramSearcher) construye el buscador de trigramas directamente a partir de las claves de 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) y :55-86/:102-176 (Grep/GrepRegexp) entonces hacen:
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 siguen enlaces simbólicos por defecto (sin O_NOFOLLOW). Ni esas funciones, ni internal/mcp/tools_search_text.go:35-126 (handleSearchText, el manejador MCP que llama a s.indexer.GrepText/GrepRegexp), llaman nunca a resolveFilePath o guardSymlinkWithinRepo. Esas dos funciones de confinamiento existen y están correctamente conectadas en todas las demás rutas que sirven contenido — internal/mcp/tools_fileops.go (read_file, write_file/edit_file a partir de la corrección de v0.55.0), internal/mcp/tools_coding.go:919 (get_symbol_source), internal/mcp/tools_lsp.go:264, internal/mcp/tools_export.go:95 — pero la ruta de grep por trigramas de search_text es un sumidero separado al que nunca se le dio la misma protección. grep -rn guardSymlinkWithinRepo en todo el árbol confirma cero referencias en tools_search_text.go, grep.go o trigram/searcher.go.
Esto es genuinamente distinto de GHSA-w42c-h7hr-f67p:
resolveFilePath eximiendo rutas absolutas + write_file/edit_file/move_inline sin llamar nunca a guardSymlinkWithinRepo (corregido en v0.55.0).search_text, no read_file/get_symbol_source) sirve su contenido a través de un sumidero (internal/search/trigram) que de forma independiente nunca llama a guardSymlinkWithinRepo. La corrección de la ruta de escritura no tocó ninguna de estas dos ubicaciones.
Esto coincide con el patrón de herramientas hermanas ya confirmado en ozgurcd/gograph (GHSA-6h6w-vhgr-2hp6) y vitali87/code-graph-rag (GHSA-85gg-2gfq-q95m): un recorrido de descubrimiento de archivos sin protección de enlaces simbólicos, emparejado con un sumidero que sirve contenido que de forma independiente carece de una protección de confinamiento.El rastreo estático (referencias archivo:línea anteriores) está completo y es reproducible por inspección. La verificación dinámica se intentó y está genuinamente bloqueada por una carencia de herramientas en este sandbox, documentada honestamente en lugar de disimulada:
$ 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
Todos los extractores de lenguajes tree-sitter en este código base (incluido el extractor simple de Go, github.com/tree-sitter/tree-sitter-go's bindings/go/binding.go: // #cgo CFLAGS: -std=c11 -fPIC / import "C") requieren cgo para compilar — no hay ninguna ruta de código en Go puro para registrar siquiera un único lenguaje, por lo que parser.Registry no puede construirse sin un compilador de C funcional. Este sandbox no tiene gcc/cc/clang en $PATH ni root sin contraseña (sudo -l exige una contraseña interactiva) para instalar uno mediante apt-get install gcc (presente en la caché de apt, gcc-16/kali-last-snapshot, pero inalcanzable sin privilegios). Esto es una limitación del entorno, no un hallazgo sobre gortex.
La propia prueba PoC — escrita siguiendo el patrón del propio arnés de pruebas existente del proyecto (internal/mcp/tools_search_text_test.go's TestSearchText, y la misma forma newSingleRepoServer/callTool que usó el propio PoC del aviso anterior) — se deja en su sitio en internal/mcp/poc_symlink_search_disclosure_test.go para que el mantenedor la ejecute en un entorno con una cadena de herramientas de 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.
}
Dado el rastreo a nivel de código fuente anterior — cada paso nombrado por función exacta y sin ninguna llamada de confinamiento en ningún punto de la ruta desde filepath.WalkDir pasando por trigram.Build/Grep hasta la respuesta de handleSearchText — esto se presenta como rastreado estáticamente, no confirmado dinámicamente, conforme a la regla básica de esta auditoría contra fabricar evidencia de PoC.
Cualquier repositorio indexado que contenga un enlace simbólico cuyo objetivo resuelva fuera de la raíz de ese repositorio (plantado por un contribuyente malicioso, presente en un repositorio de dependencia/plugin clonado, o introducido mediante un PR de cadena de suministro que el daemon indexe después) permite a un cliente MCP — una llamada normal a search_text, o una inyectada por prompt/adversaria, mismo modelo de amenaza que GHSA-w42c-h7hr-f67p — leer el contenido literal de ese archivo objetivo, independientemente de la protección de confinamiento que el proyecto aplica por lo demás a read_file y get_symbol_source. En un daemon multi-repositorio (internal/indexer/multi.go:3525-3562, que replica la llamada idéntica sin protección por cada repositorio rastreado) esto también cruza fronteras de inquilino/repositorio dentro de la misma instancia del daemon.
internal/indexer/indexer.go:2549) admite una entrada de archivo con enlace simbólico sin comprobación de Lstat/ModeSymlink.search_text (internal/mcp/tools_search_text.go) → trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go) sirven el contenido resuelto por enlace simbólico sin comprobación de confinamiento guardSymlinkWithinRepo, a diferencia de todos los demás sumideros de lectura en el mismo código base.Aplique el propio guardSymlinkWithinRepo existente del proyecto (internal/mcp/tools_fileops.go:353) — o una comprobación equivalente basada en Lstat — en ambos puntos:
filepath.WalkDir (internal/indexer/indexer.go:2549), rechace u omita un DirEntry de archivo cuyo d.Type()&os.ModeSymlink != 0 (reflejando la protección implícita que Walk ya da a los directorios), o resuelva filepath.EvalSymlinks(path) y confirme la contención dentro de absRoot antes de añadirlo a files.trigram.Build/Grep/GrepRegexp (internal/search/trigram/searcher.go), o en el punto de llamada de handleSearchText, aplique la misma comprobación de contención de ruta real estilo guardSymlinkWithinRepo que ya usan read_file/get_symbol_source/tools_lsp/tools_export antes de devolver el Text de una coincidencia.
Cualquiera de las dos correcciones por sí sola cierra esto; ambas juntas igualan la postura de confinamiento que el resto del código base ya se exige a sí mismo.Dostxodjayev Abdullox (GitHub: @squeeze440)
El Reporte Privado de Vulnerabilidades (PVR) está habilitado y confirmado en zzet/gortex; se aplica el flujo estándar de borrador de aviso de seguridad de GitHub (GHSA), consistente con la divulgación previa del proyecto GHSA-w42c-h7hr-f67p.