
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.