Skip to content

[Security] Authenticated backup import and MCP stdio configuration allow host Python zipapp execution in AstrBot #8860

@YLChen-007

Description

@YLChen-007

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:

  1. Log into the dashboard and upload a crafted backup ZIP.
  2. Import a backup whose manifest.json declares directories: ["t2i_templates"] and whose archive contains directories/t2i_templates/issue7169_payload.pyz.
  3. The importer restores that file to data/t2i_templates/issue7169_payload.pyz.
  4. Submit POST /api/tools/mcp/add with command: "python3" and args: ["data/t2i_templates/issue7169_payload.pyz"].
  5. AstrBot launches python3 data/t2i_templates/issue7169_payload.pyz.
  6. 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

  1. Download the shared helper script from: exp_common.py
  2. Download the exploit script from: verification_test.py
  3. Download the control script from: control-no-restored-payload.py
  4. 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
  5. Run the exploit PoC:
    python3 verification_test.py
  6. 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"]
  7. Verify that runtime_root/data/issue7169_mcp_allowlist_bypass_marker.txt is created.
  8. Run the control script:
    python3 control-no-restored-payload.py
  9. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:coreThe bug / feature is about astrbot's core, backendarea:webuiThe bug / feature is about webui(dashboard) of astrbot.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions