Skip to content

[Security] AstrBot subagent tools=null handoff bypass lets member users invoke admin-restricted MCP tools #8781

@YLChen-007

Description

@YLChen-007

Advisory Details

Title: AstrBot subagent tools=null handoff bypass lets member users invoke admin-restricted MCP tools

Description:

Summary

A low-privileged member/API chat user can trigger a subagent whose tools value is null and reach admin-restricted non-builtin tools through the public POST /api/v1/chat interface. The root cause is that the subagent handoff path rebuilds its “all tools” set from raw llm_tools.func_list instead of reusing the already permission-guarded toolset. In practice, this bypasses the dashboard’s per-tool admin restriction for MCP tools and can lead to unauthorized filesystem, host, or external-service actions depending on what operators have attached.

Details

The issue appears after the per-tool permission feature was introduced on the unreleased master branch in commit ae44b912fc9153f6ecca1ecb0ac3bf0a6065e753 (feat(dashboard): add per-tool permission management for function tools (#8693)).

That feature adds _PermissionGuardedTool and updates FunctionToolManager.get_full_tool_set() so non-builtin tools are wrapped and checked at call time:

error = self._mgr._check_tool_permission(self.name, context)
if error is not None:
    return error
...
for tool in self.func_list:
    tool_set.add_tool(_PermissionGuardedTool(tool, self))

The main-agent path uses that guarded result when a persona has tools=None:

if (persona and persona.get("tools") is None) or not persona:
    persona_toolset = tmgr.get_full_tool_set()

The vulnerable subagent path does not. In FunctionToolExecutor._build_handoff_toolset(), when the subagent config uses tools is None, AstrBot reconstructs the toolset directly from raw llm_tools.func_list:

if tools is None:
    toolset = ToolSet()
    for registered_tool in llm_tools.func_list:
        if isinstance(registered_tool, HandoffTool):
            continue
        if registered_tool.active:
            toolset.add_tool(registered_tool)

That means the child agent receives the raw MCPTool object instead of _PermissionGuardedTool. Once the child tool loop sees that raw MCP tool, the admin permission check is never re-applied.

I verified this end-to-end against a real local AstrBot stack driven only through supported HTTP/dashboard APIs:

  1. Start AstrBot with a local OpenAI-compatible mock provider.
  2. Log into the dashboard API as admin.
  3. Add an MCP stdio server exposing a single tool, drop_canary.
  4. Mark drop_canary as admin via /api/tools/permission.
  5. Configure a subagent with tools: null.
  6. Use an ordinary chat API key and send a POST /api/v1/chat request that causes the main agent to call transfer_to_worker.

Observed result:

  • The control run with tools: [] produced no canary file.
  • The vulnerable run with tools: null caused the subagent to call drop_canary, emitted MCP CallToolRequest logs, and wrote SUBAGENT_TOOLS_NONE_CANARY to the canary file.

This is a real authorization bypass across the chat trust boundary and the tool permission boundary. It does not require local shell access, source modifications, or admin privileges at exploit time.

PoC

Prerequisites

  • An AstrBot checkout containing the vulnerable master-branch code path, including commit ae44b912fc9153f6ecca1ecb0ac3bf0a6065e753 and the vulnerable handoff logic present at current master commit 32cfcbf52db6bfe490957e0a16d0e4378f59b918
  • uv installed
  • Network access for AstrBot dependencies
  • No other process listening on 127.0.0.1:6185 or 127.0.0.1:18080

Reproduction Steps

  1. Download the vulnerable-run PoC from: min-verification_test.py
  2. Download the control-run PoC from: min-control-no-subagent-tools.py
  3. Download the mock OpenAI server from: min-mock_openai_server.py
  4. Download the MCP canary tool server from: min-mcp_admin_tool_server.py
  5. Download the runtime config template from: min-runtime-config-template.json
  6. Place those files in one directory and run the control test:
uv run python min-control-no-subagent-tools.py
  1. Confirm the control result is safe:
{"canary_exists": false}
  1. Run the vulnerable test:
uv run python min-verification_test.py
  1. Confirm the vulnerable result:
{"canary_exists": true, "canary_content": "SUBAGENT_TOOLS_NONE_CANARY"}

The only meaningful difference between the two runs is the subagent configuration:

  • control: tools: []
  • exploit: tools: null

Log of Evidence

From the validated local end-to-end run:

Control:
{"canary_exists": false}

Exploit:
{"canary_exists": true, "canary_content": "SUBAGENT_TOOLS_NONE_CANARY"}

Additional runtime evidence gathered during manual verification:

Agent 使用工具: ['transfer_to_worker']
Agent 使用工具: ['drop_canary']
[MCPServer-admin_canary_server] Processing request of type CallToolRequest
Tool `drop_canary` Result: SUBAGENT_TOOLS_NONE_CANARY

Impact

This is an authorization bypass affecting AstrBot deployments that use:

  • subagent orchestration
  • non-builtin tools such as MCP tools
  • dashboard-enforced admin restrictions

An attacker with only member-level chat/API access can invoke tools the operator explicitly intended to reserve for admins. The concrete impact depends on what tools are attached:

  • filesystem read/write MCP tools can expose secrets or modify local state
  • host-command or automation MCP tools can become code execution primitives
  • external-system MCP tools can be abused to access downstream APIs, knowledge bases, CI/CD systems, or cloud resources

Because the entrypoint is the public chat API and the bypass crosses a documented permission boundary, this is more serious than a local misconfiguration issue.

Affected products

  • Ecosystem: GitHub repository / self-hosted application
  • Package name: AstrBot
  • Affected versions: Unreleased master branch after commit ae44b912fc9153f6ecca1ecb0ac3bf0a6065e753 (introduced after release v4.25.5)
  • Patched versions:

Severity

  • Severity: High
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Weaknesses

  • CWE: CWE-863: Incorrect Authorization

Occurrences

Permalink Description
class _PermissionGuardedTool(FunctionTool):
"""Transparent proxy that checks per-tool permissions before delegating.
Only wraps non-builtin tools. Builtin tools are added to the tool set
without wrapping, so their existing hardcoded permission logic
(``check_admin_permission`` / ``_is_restricted_env``) is unaffected.
The ``handler`` field is intentionally kept ``None`` so that
``FunctionToolExecutor._execute_local`` falls through to the
``is_override_call`` branch and invokes our ``call()`` instead of
calling the raw handler directly. This ensures the permission
check runs for *every* invocation path.
"""
def __init__(
self,
tool: FunctionTool,
manager: FunctionToolManager,
) -> None:
# Do NOT pass handler to the parent — keep self.handler = None
# so the tool executor always routes through our call().
super().__init__(
name=tool.name,
description=tool.description,
parameters=getattr(tool, "parameters", {}),
)
self._wrapped = tool
self._mgr = manager
# Mirror mutable state from the underlying tool
self.active = getattr(tool, "active", True)
self.handler_module_path = getattr(tool, "handler_module_path", None)
async def call(self, context: Any, **kwargs: Any) -> Any:
import inspect as _inspect
error = self._mgr._check_tool_permission(self.name, context)
if error is not None:
return error
# Delegate to handler first (plugin tools).
if self._wrapped.handler is not None:
result = self._wrapped.handler(context, **kwargs)
if _inspect.isasyncgen(result):
last: Any = None
async for item in result:
last = item
return last
if _inspect.isawaitable(result):
return await result
return result
# Fall back to overridden call() on subclasses (e.g. MCPTool).
call_override = getattr(type(self._wrapped), "call", None)
if call_override is not None and call_override is not FunctionTool.call:
return await self._wrapped.call(context, **kwargs)
return "error: tool has no callable handler"
_PermissionGuardedTool is the permission-enforcing wrapper added for non-builtin tools. It calls _check_tool_permission() before delegating to the wrapped handler or MCP tool.
"""Return an error string if the caller lacks permission, or None.
Only non-builtin tools are guarded. Permission is resolved from
``tool_permissions`` in SharedPreferences (``_default`` key). When
no explicit entry exists the tool inherits the fallback
``_default_permission``."""
try:
perms_raw = sp.get(
"tool_permissions", {}, scope="global", scope_id="global"
)
except Exception:
perms_raw = {}
defaults = perms_raw.get("_default", {}) if isinstance(perms_raw, dict) else {}
effective = defaults.get(tool_name)
if effective is None:
effective = self._default_permission(tool_name)
if effective != "admin":
return None # member or unknown → pass
try:
event = context.context.event
except AttributeError:
event = None
if event is None or not event.is_admin():
sender_id = getattr(event, "get_sender_id", lambda: "unknown")()
return (
f"error: Permission denied. The tool '{tool_name}' requires admin "
f"privileges. Your ID: {sender_id}. "
"Ask admin to configure in WebUI → Extension → Components."
)
return None
def get_full_tool_set(self) -> ToolSet:
"""获取完整工具集
使用 ToolSet.add_tool 进行填充。对于同名工具,去重规则为:
- 优先保留 active=True 的工具;
- 当 active 状态相同时,后加载的工具会覆盖前面的工具。
因此,后加载的 inactive 工具不会覆盖已激活的工具;
同时,MCP 工具在需要时仍可覆盖被禁用的内置工具。
Non-builtin tools are wrapped with ``_PermissionGuardedTool`` so that
every invocation checks the per-tool permission configured via the
dashboard.
"""
tool_set = ToolSet()
for tool in self.func_list:
tool_set.add_tool(_PermissionGuardedTool(tool, self))
FunctionToolManager.get_full_tool_set() wraps each non-builtin tool in _PermissionGuardedTool, establishing the intended permission boundary.
# inject toolset in the persona
if (persona and persona.get("tools") is None) or not persona:
persona_toolset = tmgr.get_full_tool_set()
for tool in list(persona_toolset):
The main-agent persona path correctly uses tmgr.get_full_tool_set() when tools is None, so the guarded toolset is available before handoff.
def _build_handoff_toolset(
cls,
run_context: ContextWrapper[AstrAgentContext],
tools: list[str | FunctionTool] | None,
) -> ToolSet | None:
ctx = run_context.context.context
event = run_context.context.event
cfg = ctx.get_config(umo=event.unified_msg_origin)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
tool_mgr = (
ctx.get_llm_tool_manager()
if hasattr(ctx, "get_llm_tool_manager")
else llm_tools
)
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
tool_mgr,
provider_settings.get("sandbox", {}).get("booter"),
)
# Keep persona semantics aligned with the main agent: tools=None means
# "all tools", including runtime computer-use tools.
if tools is None:
toolset = ToolSet()
for registered_tool in llm_tools.func_list:
if isinstance(registered_tool, HandoffTool):
continue
if registered_tool.active:
toolset.add_tool(registered_tool)
for runtime_tool in runtime_computer_tools.values():
toolset.add_tool(runtime_tool)
return None if toolset.empty() else toolset
if not tools:
return None
toolset = ToolSet()
for tool_name_or_obj in tools:
if isinstance(tool_name_or_obj, str):
registered_tool = llm_tools.get_func(tool_name_or_obj)
if registered_tool and registered_tool.active:
toolset.add_tool(registered_tool)
continue
runtime_tool = runtime_computer_tools.get(tool_name_or_obj)
if runtime_tool:
toolset.add_tool(runtime_tool)
elif isinstance(tool_name_or_obj, FunctionTool):
toolset.add_tool(tool_name_or_obj)
return None if toolset.empty() else toolset
The vulnerable handoff builder reconstructs the tools=None child toolset from raw llm_tools.func_list instead of reusing the guarded toolset, which exposes raw MCP tools such as drop_canary to member-triggered subagents.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:coreThe bug / feature is about astrbot's core, backendarea:providerThe bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner.

    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