Advisory Details
Title: Authenticated backup import and MCP stdio configuration allow host Python zipapp execution in AstrBot
Description:
Summary
An authenticated AstrBot dashboard user with access to backup import and MCP management can achieve host-level Python code execution as the AstrBot process user. The issue is caused by a dangerous feature combination: backup import restores attacker-controlled files into the live data/ tree, while MCP stdio validation still allows python3 with a positional script/archive argument. By importing a crafted backup that restores data/t2i_templates/issue7169_payload.pyz and then adding an MCP stdio server with command=python3 and args=["data/t2i_templates/issue7169_payload.pyz"], the attacker can make AstrBot execute the restored zipapp.
Details
The reachable attack chain uses only documented dashboard HTTP APIs.
First, the backup import flow accepts a user-uploaded ZIP and later imports it into AstrBot's runtime data directories. In astrbot/core/backup/constants.py, get_backup_directories() explicitly exposes t2i_templates as a restorable directory. During import, AstrBotImporter._import_directories() iterates over manifest-declared directories and copies archive members from directories/<dir_name>/... into the corresponding live target directory under the AstrBot root. That means an uploaded backup can legitimately restore an attacker-controlled file to data/t2i_templates/issue7169_payload.pyz.
Second, the MCP management API accepts user-supplied stdio launch parameters. ToolsRoute.add_mcp_server() calls validate_mcp_stdio_config() and then immediately runs a live connection test through test_mcp_server_connection(). The intended guardrails are incomplete:
python3 is in the default stdio allowlist.
- Python stdio argument validation blocks inline code flags such as
-c, but it does not reject positional filesystem script paths such as data/t2i_templates/issue7169_payload.pyz.
MCPClient.connect_to_server() then passes the validated config directly into mcp.StdioServerParameters(...) and mcp.stdio_client(...), which launches the subprocess.
This creates the following end-to-end chain:
- Log into the dashboard and upload a crafted backup ZIP.
- Import a backup whose
manifest.json declares directories: ["t2i_templates"] and whose archive contains directories/t2i_templates/issue7169_payload.pyz.
- The importer restores that file to
data/t2i_templates/issue7169_payload.pyz.
- Submit
POST /api/tools/mcp/add with command: "python3" and args: ["data/t2i_templates/issue7169_payload.pyz"].
- AstrBot launches
python3 data/t2i_templates/issue7169_payload.pyz.
- Python executes the zipapp's
__main__.py before MCP initialization fails.
The attached PoC proves real execution by writing a marker file, data/issue7169_mcp_allowlist_bypass_marker.txt, from inside the zipapp. A control run that follows the same HTTP flow without restoring the zipapp does not create the marker.
PoC
Prerequisites
- A running AstrBot instance reachable over HTTP.
- A valid authenticated dashboard session.
- Access to the backup management APIs and MCP management APIs.
- Affected release verified on AstrBot
v4.25.2.
- Python environment with
requests available for the PoC scripts.
- The attack requires only normal dashboard functionality; no source patching, mocking, or direct filesystem access is required.
Reproduction Steps
- Download the shared helper script from: exp_common.py
- Download the exploit script from: verification_test.py
- Download the control script from: control-no-restored-payload.py
- Start AstrBot in an isolated runtime root, for example:
PYTHONPATH=/root/project/xclaw-project/AstrBot ASTRBOT_ROOT=/root/project/xclaw-project/AstrBot/llm-enhance/cve-finding/RCE/Issue-AstrBot-7169-backup-import-python3-zipapp-exp/runtime_root ASTRBOT_DASHBOARD_INITIAL_PASSWORD='AstrBotTest123' DASHBOARD_HOST=127.0.0.1 ASTRBOT_DASHBOARD_HOST=127.0.0.1 /root/project/xclaw-project/AstrBot/.venv/bin/python /root/project/xclaw-project/AstrBot/main.py
- Run the exploit PoC:
python3 verification_test.py
- The exploit script will:
- authenticate to
/api/auth/login
- build a backup ZIP containing
directories/t2i_templates/issue7169_payload.pyz
- upload it through
/api/backup/upload
- validate it through
/api/backup/check
- import it through
/api/backup/import
- wait for
/api/backup/progress completion
- call
/api/tools/mcp/add with command=python3 and args=["data/t2i_templates/issue7169_payload.pyz"]
- Verify that
runtime_root/data/issue7169_mcp_allowlist_bypass_marker.txt is created.
- Run the control script:
python3 control-no-restored-payload.py
- Confirm that the control path does not create the marker file even though the MCP API still returns
Connection closed.
Log of Evidence
Exploit run (verification_run.log):
Verification mode: End-to-End
Login succeeded with bearer token length=133
Uploaded backup filename=issue7169_variant_backup_20260531_001715.zip
Backup pre-check: {"valid": true, "can_import": true, "version_status": "match", "backup_version": "4.25.2", "current_version": "4.25.2", "backup_time": "2026-05-31T00:00:00+00:00", "confirm_message": "", "warnings": [], "error": "", "backup_summary": {"tables": ["main_db", "kb_metadata", "kb_documents"], "has_knowledge_bases": false, "has_config": false, "directories": ["t2i_templates"]}}
Import task started task_id=ded17de0-6aaa-4745-b9a0-947e612fd51d
Import completed: {"task_id": "ded17de0-6aaa-4745-b9a0-947e612fd51d", "type": "import", "status": "completed", "result": {"success": true, "imported_tables": {}, "imported_files": {"attachments": 0}, "imported_directories": {"t2i_templates": 1}, "warnings": [], "errors": []}}
Restored payload path=/root/llm-project-python/AstrBot/llm-enhance/cve-finding/RCE/Issue-AstrBot-7169-backup-import-python3-zipapp-exp/runtime_root/data/t2i_templates/issue7169_payload.pyz exists=True sha256=d4fe0f743d2c91ec1519559df0cb49b499e9fd2ec65fce818163e3b9c84ffb7f
MCP add response: {"status": "error", "message": "MCP connection test failed: Connection closed", "data": null}
Marker path=/root/llm-project-python/AstrBot/llm-enhance/cve-finding/RCE/Issue-AstrBot-7169-backup-import-python3-zipapp-exp/runtime_root/data/issue7169_mcp_allowlist_bypass_marker.txt exists=True content='zipapp executed\n'
Source/sink summary: backup upload/import -> data/t2i_templates/issue7169_payload.pyz -> POST /api/tools/mcp/add -> python3 positional script arg
Control run (control_run.log):
Verification mode: End-to-End
Uploaded control backup filename=issue7169_control_backup_20260531_001727.zip
Backup pre-check: {"valid": true, "can_import": true, "version_status": "match", "backup_version": "4.25.2", "current_version": "4.25.2", "backup_time": "2026-05-31T00:00:00+00:00", "confirm_message": "", "warnings": [], "error": "", "backup_summary": {"tables": ["main_db", "kb_metadata", "kb_documents"], "has_knowledge_bases": false, "has_config": false, "directories": ["t2i_templates"]}}
Import completed: {"task_id": "3245ac13-0ea4-4daa-b905-45fb702615f5", "type": "import", "status": "completed", "result": {"success": true, "imported_tables": {}, "imported_files": {"attachments": 0}, "imported_directories": {"t2i_templates": 1}, "warnings": [], "errors": []}}
Control restored payload exists=False path=/root/llm-project-python/AstrBot/llm-enhance/cve-finding/RCE/Issue-AstrBot-7169-backup-import-python3-zipapp-exp/runtime_root/data/t2i_templates/issue7169_payload.pyz
MCP add response: {"status": "error", "message": "MCP connection test failed: Connection closed", "data": null}
Control marker exists=False path=/root/llm-project-python/AstrBot/llm-enhance/cve-finding/RCE/Issue-AstrBot-7169-backup-import-python3-zipapp-exp/runtime_root/data/issue7169_mcp_allowlist_bypass_marker.txt
Impact
This is an authenticated remote code execution chain through the dashboard management plane. A dashboard user who can import backups and add MCP stdio servers can execute arbitrary Python code as the AstrBot process user on the host. The reachable impact includes disclosure of application secrets and configuration, tampering with data under AstrBot's runtime directories, persistence through modified runtime content, and further local pivoting within the privileges of the AstrBot service account.
Affected products
- Ecosystem: pip
- Package name: AstrBot
- Affected versions: <= 4.25.2
- Patched versions:
Severity
- Severity: High
- Vector string: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
Weaknesses
- CWE: CWE-94: Improper Control of Generation of Code ('Code Injection')
Occurrences
| Permalink |
Description |
|
async def add_mcp_server(self): |
|
try: |
|
server_data = await request.json |
|
|
|
name = server_data.get("name", "") |
|
|
|
# 检查必填字段 |
|
if not name: |
|
return Response().error("Server name cannot be empty").__dict__ |
|
|
|
# 移除特殊字段并检查配置是否有效 |
|
has_valid_config = False |
|
server_config = {"active": server_data.get("active", True)} |
|
|
|
# 复制所有配置字段 |
|
for key, value in server_data.items(): |
|
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段 |
|
if key == "mcpServers": |
|
try: |
|
server_config = _extract_mcp_server_config( |
|
server_data["mcpServers"] |
|
) |
|
except ValueError as e: |
|
return Response().error(f"{e!s}").__dict__ |
|
else: |
|
server_config[key] = value |
|
has_valid_config = True |
|
|
|
if not has_valid_config: |
|
return ( |
|
Response() |
|
.error("A valid server configuration is required") |
|
.__dict__ |
|
) |
|
|
|
try: |
|
validate_mcp_stdio_config(server_config) |
|
except ValueError as e: |
|
return Response().error(f"{e!s}").__dict__ |
|
|
|
config = self.tool_mgr.load_mcp_config() |
|
|
|
if name in config["mcpServers"]: |
|
return Response().error(f"Server {name} already exists").__dict__ |
|
|
|
try: |
|
await self.tool_mgr.test_mcp_server_connection(server_config) |
|
except Exception as e: |
|
logger.error(traceback.format_exc()) |
|
return Response().error(f"MCP connection test failed: {e!s}").__dict__ |
|
ToolsRoute.add_mcp_server() accepts user-supplied MCP stdio configuration, validates it, and immediately performs a live connection test with the attacker-controlled command and arguments. |
|
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset( |
|
{ |
|
"python", |
|
"python3", |
|
"py", |
|
"node", |
|
"npx", |
|
"npm", |
|
"pnpm", |
|
"yarn", |
|
"bun", |
|
"bunx", |
|
"deno", |
|
"uv", |
|
"uvx", |
|
} |
|
) |
|
The default stdio allowlist explicitly permits interpreter launchers including python3, which is the entry point used in the exploit chain. |
|
def _validate_stdio_args(command_name: str, args: object) -> None: |
|
if args is None: |
|
return |
|
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args): |
|
raise ValueError("MCP stdio args must be a list of strings.") |
|
|
|
for arg in args: |
|
if "\x00" in arg or "\r" in arg or "\n" in arg: |
|
raise ValueError("MCP stdio args cannot contain control characters.") |
|
|
|
if command_name.startswith("python") or command_name == "py": |
|
if any( |
|
arg == "-c" |
|
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg) |
|
for arg in args |
|
): |
|
raise ValueError( |
|
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed." |
|
) |
|
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"): |
|
if any( |
|
arg in _JS_INLINE_CODE_FLAGS |
|
or arg == "eval" |
|
or ( |
|
arg.startswith("-") |
|
and not arg.startswith("--") |
|
and any(c in arg for c in "ep") |
|
) |
|
for arg in args |
|
): |
|
raise ValueError( |
|
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed." |
|
) |
|
elif command_name == "docker": |
|
denied = [] |
|
for i, arg in enumerate(args): |
|
if arg in _DENIED_DOCKER_ARGS: |
|
denied.append(arg) |
|
elif ( |
|
arg in {"--network", "--net", "--pid", "--ipc"} |
|
and i + 1 < len(args) |
|
and args[i + 1] == "host" |
|
): |
|
denied.append(f"{arg} {args[i + 1]}") |
|
if denied: |
|
raise ValueError( |
|
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}." |
|
) |
|
|
|
|
|
def validate_mcp_stdio_config(config: dict) -> None: |
|
"""Validate stdio MCP config before any subprocess can be spawned.""" |
|
cfg = _prepare_config(config.copy()) |
|
if "url" in cfg: |
|
return |
|
|
|
command = cfg.get("command") |
|
if not isinstance(command, str) or not command.strip(): |
|
raise ValueError("MCP stdio server requires a non-empty command.") |
|
if _SHELL_META_RE.search(command): |
|
raise ValueError("MCP stdio command contains unsafe shell metacharacters.") |
|
|
|
command_name = _normalize_stdio_command_name(command) |
|
if command_name in _DENIED_STDIO_COMMANDS: |
|
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.") |
|
|
|
allowed = _get_stdio_command_allowlist() |
|
if command_name not in allowed: |
|
allowed_display = ", ".join(sorted(allowed)) |
|
raise ValueError( |
|
f"MCP stdio command `{command_name}` is not allowed. " |
|
f"Allowed commands: {allowed_display}. " |
|
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher." |
|
) |
|
|
|
_validate_stdio_args(command_name, cfg.get("args")) |
|
_validate_stdio_args() blocks inline code flags such as -c but still allows positional Python script/archive paths, and validate_mcp_stdio_config() accepts the configuration as long as the command name is allowlisted. |
|
else: |
|
validate_mcp_stdio_config(cfg) |
|
cfg = _prepare_stdio_env(cfg) |
|
server_params = mcp.StdioServerParameters( |
|
**cfg, |
|
) |
|
|
|
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None: |
|
# Handle MCP service error logs |
|
if isinstance(msg, mcp.types.LoggingMessageNotificationParams): |
|
if msg.level in ( |
|
"warning", |
|
"error", |
|
"critical", |
|
"alert", |
|
"emergency", |
|
): |
|
log_msg = f"[{msg.level.upper()}] {str(msg.data)}" |
|
self.server_errlogs.append(log_msg) |
|
|
|
stdio_transport = await self.exit_stack.enter_async_context( |
|
mcp.stdio_client( |
|
server_params, |
|
errlog=LogPipe( |
|
level=logging.INFO, |
|
logger=logger, |
|
identifier=f"MCPServer-{name}", |
|
callback=callback, |
|
MCPClient.connect_to_server() passes the validated stdio configuration directly into mcp.StdioServerParameters(...) and mcp.stdio_client(...), causing AstrBot to launch the attacker-controlled python3 data/t2i_templates/issue7169_payload.pyz subprocess. |
|
def get_backup_directories() -> dict[str, str]: |
|
"""获取需要备份的目录列表 |
|
|
|
使用 astrbot_path 模块动态获取路径,支持通过环境变量 ASTRBOT_ROOT 自定义根目录。 |
|
|
|
Returns: |
|
dict: 键为备份文件中的目录名称,值为目录的绝对路径 |
|
""" |
|
return { |
|
"plugins": get_astrbot_plugin_path(), # 插件本体 |
|
"plugin_data": get_astrbot_plugin_data_path(), # 插件数据 |
|
"config": get_astrbot_config_path(), # 配置目录 |
|
"t2i_templates": get_astrbot_t2i_templates_path(), # T2I 模板 |
|
"webchat": get_astrbot_webchat_path(), # WebChat 数据 |
|
"temp": get_astrbot_temp_path(), # 临时文件 |
|
get_backup_directories() defines t2i_templates as a valid restorable backup directory, making it a supported target for attacker-controlled file restoration. |
|
backed_up_dirs = manifest.get("directories", []) |
|
backup_directories = get_backup_directories() |
|
|
|
for dir_name in backed_up_dirs: |
|
if dir_name not in backup_directories: |
|
result.add_warning(f"未知的目录类型: {dir_name}") |
|
continue |
|
|
|
target_dir = Path(backup_directories[dir_name]) |
|
archive_prefix = f"directories/{dir_name}/" |
|
|
|
file_count = 0 |
|
|
|
try: |
|
# 获取该目录下的所有文件 |
|
dir_files = [ |
|
name |
|
for name in zf.namelist() |
|
if name.startswith(archive_prefix) and name != archive_prefix |
|
] |
|
|
|
if not dir_files: |
|
continue |
|
|
|
# 备份现有目录(如果存在) |
|
if target_dir.exists(): |
|
backup_path = Path(f"{target_dir}.bak") |
|
if backup_path.exists(): |
|
shutil.rmtree(backup_path) |
|
shutil.move(str(target_dir), str(backup_path)) |
|
logger.debug(f"已备份现有目录 {target_dir} 到 {backup_path}") |
|
|
|
# 创建目标目录 |
|
target_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
# 解压文件 |
|
for name in dir_files: |
|
try: |
|
# 计算相对路径 |
|
rel_path = name[len(archive_prefix) :] |
|
if not rel_path: # 跳过目录条目 |
|
continue |
|
|
|
target_path = target_dir / rel_path |
|
# Validate path is within target directory (CWE-22) |
|
if not _validate_path_within(target_path, target_dir): |
|
result.add_warning(f"文件路径越界,已跳过: {name}") |
|
continue |
|
|
|
if zf.getinfo(name).is_dir(): |
|
ensure_dir(target_path) |
|
continue |
|
|
|
target_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
|
with zf.open(name) as src, open(target_path, "wb") as dst: |
|
dst.write(src.read()) |
|
file_count += 1 |
|
AstrBotImporter._import_directories() restores archive members from directories/<dir_name>/... into the live target directory, which is how the uploaded backup places the zipapp under data/t2i_templates/issue7169_payload.pyz. |
Advisory Details
Title: Authenticated backup import and MCP stdio configuration allow host Python zipapp execution in AstrBot
Description:
Summary
An authenticated AstrBot dashboard user with access to backup import and MCP management can achieve host-level Python code execution as the AstrBot process user. The issue is caused by a dangerous feature combination: backup import restores attacker-controlled files into the live
data/tree, while MCP stdio validation still allowspython3with a positional script/archive argument. By importing a crafted backup that restoresdata/t2i_templates/issue7169_payload.pyzand then adding an MCP stdio server withcommand=python3andargs=["data/t2i_templates/issue7169_payload.pyz"], the attacker can make AstrBot execute the restored zipapp.Details
The reachable attack chain uses only documented dashboard HTTP APIs.
First, the backup import flow accepts a user-uploaded ZIP and later imports it into AstrBot's runtime data directories. In
astrbot/core/backup/constants.py,get_backup_directories()explicitly exposest2i_templatesas a restorable directory. During import,AstrBotImporter._import_directories()iterates over manifest-declared directories and copies archive members fromdirectories/<dir_name>/...into the corresponding live target directory under the AstrBot root. That means an uploaded backup can legitimately restore an attacker-controlled file todata/t2i_templates/issue7169_payload.pyz.Second, the MCP management API accepts user-supplied stdio launch parameters.
ToolsRoute.add_mcp_server()callsvalidate_mcp_stdio_config()and then immediately runs a live connection test throughtest_mcp_server_connection(). The intended guardrails are incomplete:python3is in the default stdio allowlist.-c, but it does not reject positional filesystem script paths such asdata/t2i_templates/issue7169_payload.pyz.MCPClient.connect_to_server()then passes the validated config directly intomcp.StdioServerParameters(...)andmcp.stdio_client(...), which launches the subprocess.This creates the following end-to-end chain:
manifest.jsondeclaresdirectories: ["t2i_templates"]and whose archive containsdirectories/t2i_templates/issue7169_payload.pyz.data/t2i_templates/issue7169_payload.pyz.POST /api/tools/mcp/addwithcommand: "python3"andargs: ["data/t2i_templates/issue7169_payload.pyz"].python3 data/t2i_templates/issue7169_payload.pyz.__main__.pybefore MCP initialization fails.The attached PoC proves real execution by writing a marker file,
data/issue7169_mcp_allowlist_bypass_marker.txt, from inside the zipapp. A control run that follows the same HTTP flow without restoring the zipapp does not create the marker.PoC
Prerequisites
v4.25.2.requestsavailable for the PoC scripts.Reproduction Steps
PYTHONPATH=/root/project/xclaw-project/AstrBot ASTRBOT_ROOT=/root/project/xclaw-project/AstrBot/llm-enhance/cve-finding/RCE/Issue-AstrBot-7169-backup-import-python3-zipapp-exp/runtime_root ASTRBOT_DASHBOARD_INITIAL_PASSWORD='AstrBotTest123' DASHBOARD_HOST=127.0.0.1 ASTRBOT_DASHBOARD_HOST=127.0.0.1 /root/project/xclaw-project/AstrBot/.venv/bin/python /root/project/xclaw-project/AstrBot/main.pypython3 verification_test.py/api/auth/logindirectories/t2i_templates/issue7169_payload.pyz/api/backup/upload/api/backup/check/api/backup/import/api/backup/progresscompletion/api/tools/mcp/addwithcommand=python3andargs=["data/t2i_templates/issue7169_payload.pyz"]runtime_root/data/issue7169_mcp_allowlist_bypass_marker.txtis created.python3 control-no-restored-payload.pyConnection closed.Log of Evidence
Exploit run (
verification_run.log):Control run (
control_run.log):Impact
This is an authenticated remote code execution chain through the dashboard management plane. A dashboard user who can import backups and add MCP stdio servers can execute arbitrary Python code as the AstrBot process user on the host. The reachable impact includes disclosure of application secrets and configuration, tampering with data under AstrBot's runtime directories, persistence through modified runtime content, and further local pivoting within the privileges of the AstrBot service account.
Affected products
Severity
Weaknesses
Occurrences
AstrBot/astrbot/dashboard/routes/tools.py
Lines 121 to 170 in 0e973bd
ToolsRoute.add_mcp_server()accepts user-supplied MCP stdio configuration, validates it, and immediately performs a live connection test with the attacker-controlled command and arguments.AstrBot/astrbot/core/agent/mcp_client.py
Lines 27 to 43 in 0e973bd
python3, which is the entry point used in the exploit chain.AstrBot/astrbot/core/agent/mcp_client.py
Lines 154 to 229 in 0e973bd
_validate_stdio_args()blocks inline code flags such as-cbut still allows positional Python script/archive paths, andvalidate_mcp_stdio_config()accepts the configuration as long as the command name is allowlisted.AstrBot/astrbot/core/agent/mcp_client.py
Lines 490 to 517 in 0e973bd
MCPClient.connect_to_server()passes the validated stdio configuration directly intomcp.StdioServerParameters(...)andmcp.stdio_client(...), causing AstrBot to launch the attacker-controlledpython3 data/t2i_templates/issue7169_payload.pyzsubprocess.AstrBot/astrbot/core/backup/constants.py
Lines 66 to 80 in 0e973bd
get_backup_directories()definest2i_templatesas a valid restorable backup directory, making it a supported target for attacker-controlled file restoration.AstrBot/astrbot/core/backup/importer.py
Lines 887 to 944 in 0e973bd
AstrBotImporter._import_directories()restores archive members fromdirectories/<dir_name>/...into the live target directory, which is how the uploaded backup places the zipapp underdata/t2i_templates/issue7169_payload.pyz.