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:
- Start AstrBot with a local OpenAI-compatible mock provider.
- Log into the dashboard API as admin.
- Add an MCP stdio server exposing a single tool,
drop_canary.
- Mark
drop_canary as admin via /api/tools/permission.
- Configure a subagent with
tools: null.
- 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
- Download the vulnerable-run PoC from: min-verification_test.py
- Download the control-run PoC from: min-control-no-subagent-tools.py
- Download the mock OpenAI server from: min-mock_openai_server.py
- Download the MCP canary tool server from: min-mcp_admin_tool_server.py
- Download the runtime config template from: min-runtime-config-template.json
- Place those files in one directory and run the control test:
uv run python min-control-no-subagent-tools.py
- Confirm the control result is safe:
- Run the vulnerable test:
uv run python min-verification_test.py
- 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. |
Advisory Details
Title: AstrBot subagent
tools=nullhandoff bypass lets member users invoke admin-restricted MCP toolsDescription:
Summary
A low-privileged member/API chat user can trigger a subagent whose
toolsvalue isnulland reach admin-restricted non-builtin tools through the publicPOST /api/v1/chatinterface. The root cause is that the subagent handoff path rebuilds its “all tools” set from rawllm_tools.func_listinstead of reusing the already permission-guarded toolset. In practice, this bypasses the dashboard’s per-tooladminrestriction 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
masterbranch in commitae44b912fc9153f6ecca1ecb0ac3bf0a6065e753(feat(dashboard): add per-tool permission management for function tools (#8693)).That feature adds
_PermissionGuardedTooland updatesFunctionToolManager.get_full_tool_set()so non-builtin tools are wrapped and checked at call time:The main-agent path uses that guarded result when a persona has
tools=None:The vulnerable subagent path does not. In
FunctionToolExecutor._build_handoff_toolset(), when the subagent config usestools is None, AstrBot reconstructs the toolset directly from rawllm_tools.func_list:That means the child agent receives the raw
MCPToolobject instead of_PermissionGuardedTool. Once the child tool loop sees that raw MCP tool, theadminpermission check is never re-applied.I verified this end-to-end against a real local AstrBot stack driven only through supported HTTP/dashboard APIs:
drop_canary.drop_canaryasadminvia/api/tools/permission.tools: null.chatAPI key and send aPOST /api/v1/chatrequest that causes the main agent to calltransfer_to_worker.Observed result:
tools: []produced no canary file.tools: nullcaused the subagent to calldrop_canary, emitted MCPCallToolRequestlogs, and wroteSUBAGENT_TOOLS_NONE_CANARYto 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
master-branch code path, including commitae44b912fc9153f6ecca1ecb0ac3bf0a6065e753and the vulnerable handoff logic present at currentmastercommit32cfcbf52db6bfe490957e0a16d0e4378f59b918uvinstalled127.0.0.1:6185or127.0.0.1:18080Reproduction Steps
{"canary_exists": false}{"canary_exists": true, "canary_content": "SUBAGENT_TOOLS_NONE_CANARY"}The only meaningful difference between the two runs is the subagent configuration:
tools: []tools: nullLog of Evidence
From the validated local end-to-end run:
Additional runtime evidence gathered during manual verification:
Impact
This is an authorization bypass affecting AstrBot deployments that use:
adminrestrictionsAn 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:
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
masterbranch after commitae44b912fc9153f6ecca1ecb0ac3bf0a6065e753(introduced after releasev4.25.5)Severity
Weaknesses
Occurrences
AstrBot/astrbot/core/provider/func_tool_manager.py
Lines 213 to 269 in 32cfcbf
_PermissionGuardedToolis the permission-enforcing wrapper added for non-builtin tools. It calls_check_tool_permission()before delegating to the wrapped handler or MCP tool.AstrBot/astrbot/core/provider/func_tool_manager.py
Lines 448 to 497 in 32cfcbf
FunctionToolManager.get_full_tool_set()wraps each non-builtin tool in_PermissionGuardedTool, establishing the intended permission boundary.AstrBot/astrbot/core/astr_main_agent.py
Lines 515 to 518 in 32cfcbf
tmgr.get_full_tool_set()whentoolsisNone, so the guarded toolset is available before handoff.AstrBot/astrbot/core/astr_agent_tool_exec.py
Lines 244 to 293 in 32cfcbf
tools=Nonechild toolset from rawllm_tools.func_listinstead of reusing the guarded toolset, which exposes raw MCP tools such asdrop_canaryto member-triggered subagents.