迄今为止我们看到好几个这样的段错误问题了。
这个PR,为单线程释放造成的UAF https://github.com/valkey-io/valkey/pull/4212 同时还有多线程下RDMA的崩溃问题 以及 Valkey Over RDMA测试中server UAF崩溃的问题 中我们都遇到类似的问题,于是尝试从架构上来解决一下这个问题.
举一隅不以三隅反,则不复也。
Broader direction (non-blocking): Part of the root cause is that many flows free clients synchronously (
CLIENT KILL,evictClients(), COB-limit handling, …), and we keep tripping over iterators/cached pointers that outlive those frees. This snapshot fix is a good local solution, but it may be worth a broader discussion on whether these paths should preferfreeClientAsync()so client teardown always happens at a safe point (beforeSleep) rather than mid-iteration. Raising it here since it’s a recurring class of bug, not to block this PR.
风险清单 #
核心判据:
listIter 缓存后继 ≠ 只删当前
+ 中途同步 freeClient / module 回调可能拆掉后继
高优先级 #
| # | 位置 | 迭代什么 | 中途可能发生什么 | 为何像本 bug | 最小尝试思路 |
|---|---|---|---|---|---|
| H1 | networking.c → evictClients |
client_mem_usage_buckets[].clients |
freeClient(当前);若 module/CLIENT KILL 再杀同 bucket 后继 |
标准 listIter+后继 | maxmemory-clients + 多个大输出 client;最好配合 disconnect 里 KILL 另一个 |
| H2 | networking.c → clientKillCommand |
server.clients |
对匹配项同步 freeClient |
杀当前安全;若 free→module→KILL 下一个匹配者 则 UAF | ≥2 个匹配 client;module CLIENT_CHANGE 里再 KILL 后继 |
| H3 | acl.c → pubsub/ACL 限制踢人(freeClientOrCloseLater(..., 0)) |
server.clients |
同步 free | 同 H2 | 两 pubsub client + ACL SETUSER/ACL LOAD 收紧 channel |
无module多BLPOP场景 #
1. BLPOP 是干什么的? #
普通 LPOP mylist:list 空就立刻回 (nil)。
BLPOP mylist 0 是 Blocking LPOP:
- list 里有元素 → 立刻弹出,和
LPOP一样 - list 空 → 不立刻回包,这个 TCP 连接在服务端进入阻塞态,等有人
LPUSH/RPUSH再唤醒
从网络协议栈看:
客户端 socket ──write──▶ 服务端 query buffer
│
解析出 BLPOP
│
list 空?──yes──▶ 标记 CLIENT_BLOCKED
│ 可读 handler 仍在(epoll 仍监听)
│ 但不再 parse/execute 后续命令
│ (数据可进 query buf,先不执行)
│
客户端 read() 阻塞等 RESP ◀── 暂时没有任何 reply 写出
所以 BLPOP 本质是:用一条长连接 + 服务端状态机,把“等数据”从客户端轮询变成服务端挂起。超时参数 0 表示一直等。
多个客户端对同一 key 做 BLPOP,就是多个 waiter 排队。
2. 服务端怎么记这些“等着的人”? #
每个 DB 有一张表:db->blocking_keys:
key "mylist" ──▶ list: [ client A ] → [ client B ] → ...
挂上时(blockForKeys)往链表尾插节点:
listAddNodeTail(l, c);
dictSetVal(c->bstate->keys, client_blocked_entry, listLast(l));
内存里大致是:
list 头
│
▼
┌─────────────┐ next ┌─────────────┐
│ listNode A │ ───────▶ │ listNode B │
│ value = &A │ │ value = &B │
└─────────────┘ └─────────────┘
▲ ▲
│ │
client A 还把自己的 client B 同理
bstate->keys["mylist"]
指回这个 listNode*
(方便 O(1) 摘链,而不是扫链表)
要点:链表节点 listNode 是独立 heap 对象;
客户端结构体里还握着指向它的指针。
谁把客户端拆掉,往往会顺手 listUnlink + zfree(listNode)。
3. 有人 LPUSH 之后发生什么? #
LPUSH mylist v1 执行完会 signalKeyAsReady,把 key 丢进 server.ready_keys。
当前命令的 processCommand 末尾调用 handleClientsBlockedOnKeys → handleClientsBlockedOnKey:
listRewind(clients, &li);
...
while ((ln = listNext(&li)) && count--) {
client *receiver = listNodeValue(ln);
...
unblockClientOnKey(receiver, rl->key);
对 list 类命令,唤醒不是“直接塞个回复”那么简单,而是:
- 把该 client 从
blocking_keys摘掉、清CLIENT_BLOCKED - 重新执行原来的 BLPOP(
processCommand再跑一遍) - 这时 list 里已有元素,BLPOP 同步弹出并
addReply
这就是 “reexec / pending_command” 路径。
4. Bug 的核心:listIter 提前缓存了后继节点
#
adlist 的迭代器约定写得很清楚:迭代时只允许删当前节点,不能删别的节点。
listNode *listNext(listIter *iter) {
listNode *current = iter->next;
if (current != NULL) {
if (iter->direction == AL_START_HEAD)
iter->next = current->next; /* 已经把“下一个”指针存进 iter */
else
iter->next = current->prev;
}
return current;
}
服务 A 的那一轮:
listNext(&li) 得到 nodeA
同时 li.next = nodeB ← 后继指针已经钉死在栈上的 listIter 里
然后对 A 调用 unblockClientOnKey(A)。若在这个调用返回之前,
有人把 nodeB 从链表摘掉并 free,
返回后外层再 listNext 就会读已经释放的 listNode → heap UAF。
adlist 只保证:删当前节点时,迭代器里缓存的 next 仍有效。它不保证你在回调里删掉后继。
5. 谁在回调里杀掉了后继 B?(无 module 的这条路径) #
调用栈(你 ASan 打出来的那条):
handleClientsBlockedOnKey // 正在 for 链表,li.next == nodeB
└─ unblockClientOnKey(A)
└─ processCommand(A) // 重跑 BLPOP
└─ evictClients() // processCommand 开头:客户端内存超限就踢人
└─ freeClient(B) // B 输出缓冲很大,被选中
└─ unblockClient / unblockClientWaitingData
└─ zfree(nodeB) // 正是 li.next 指向的那块
… 回到 while
└─ listNext(&li) // 读已 free 的 nodeB → UAF
从系统角度:
| 层 | 发生了什么 |
|---|---|
| 事件循环 | 仍在处理同一次 read→processCommand(EXEC/LPUSH) 的同步调用栈里,没有回到 epoll_wait |
| 客户端状态 | A 从 blocked → 重入命令执行;B 仍在同一条 blocking_keys 链表上 |
| 内存 | evictClients 同步 freeClient(B),把 B 的 listNode 还给 jemalloc/libc |
| 迭代器 | 栈上 listIter.next 仍是悬空指针 |
所以这不是“多线程 race”,而是 单线程重入(reentrancy):外层握着结构,内层同一线程把结构拆了。
和 module 版 #4198 的差别只在“谁触发 freeClient(后继)”:
- module:reply 回调里
CLIENT KILL - 这条:
processCommand→evictClients
同一条链表 + 同一种危险的 listIter 用法。
6. 为啥测试要用 MULTI 绑 CONFIG SET + LPUSH?
#
若直接:
CONFIG SET maxmemory-clients 很小
LPUSH mylist v1
则 LPUSH 自己的 processCommand 一进来就会 evictClients,B 可能在进入 handleClientsBlockedOnKey 之前就被踢掉——打不到“迭代中途 free 后继”。
MULTI/EXEC 把降水位和 LPUSH 放进同一次 EXEC:
processCommand(EXEC)开头:水位还高 → 不踢 B- 事务里
CONFIG SET把水位降下去(只走嵌套call(),不再跑一整遍外层processCommand) - 事务里
LPUSH把 key 标 ready EXEC结束后才handleClientsBlockedOnKeys- 服务 A 时嵌套
processCommand(A)才按低水位踢掉 B → 正好打在迭代窗口里
这是在人为制造“唤醒路径上、迭代中途发生同步 free”的时序,不是日常最常见写法,但证明了:普通 BLPOP 路径确实能踩到这个洞。
7. 一句话收束 #
BLPOP = list 空时把连接挂在 blocking_keys[key] 的 waiter 链表上。
Bug = 唤醒时用 listIter 扫这条链表,却在服务当前 waiter 的同步回调里(重跑命令 → evictClients)把后继 waiter 的 listNode 释放了,外层再 listNext 读悬空指针。
修复思路也因此很明确:不要在可能同步 freeClient 的回调期间,依赖“缓存了后继 listNode*”的裸迭代;改成 ID/指针快照或先收集再处理,使后继被拆掉时不会再解引用已释放节点。
核心栈:
# use: listNext ← handleClientsBlockedOnKey
# free: unblockClientWaitingData ← freeClient ← evictClients
# ← processCommand ← unblockClientOnKey ← handleClientsBlockedOnKey
# alloc: listAddNodeTail ← blockForKeys ← blpopCommand
内存布局上就是:adlist 的 listIter 把后继 listNode* 缓存在 li.next;
嵌套 processCommand 里 evictClients 同步 freeClient 后继 waiter,把同一条 blocking_keys 链表节点 zfree 掉;外层再 listNext → UAF。
H1场景 #
evictClients:
listNext → 当前 fat1,iter->next = fat2 的 listNode*
freeClient(fat1)
├─ CLIENT_CHANGE_DISCONNECTED ← 此时 fat1 还在 bucket 上
│ └─ module: CLIENT KILL fat2
│ └─ freeClient(fat2)
│ └─ listDelNode → zfree(fat2 的 listNode) ← 后继堆块已释放
└─ … 稍后才 listDelNode(fat1)
内存仍超限(fat3 还在)→ 再 listNext → 读已释放的 fat2 节点 → UAF
会产生此类问题.
H2场景 #
结论:炸在外层 clientKillCommand 的第二次 listNext——读的是已被内层 KILL 释放的后继 listNode。
1. 入口(网络 → 命令) #
测试客户端发 CLIENT KILL ID <id1>:
readQueryFromClient → processInputBuffer → processCommand → clientKillCommand
此时 server.clients 大致是:
... → [node_r] → [node_A / id1] → [node_B / id2] → ...
↑ 当前要杀的 ↑ listIter 会缓存的后继
每个 listNode 是堆上 24 字节左右的双向链表节点(prev/next/value);client->client_list_node 指向自己的那颗节点。
2. 外层循环:先“看见” A,并提前缓存 B #
listNode *listNext(listIter *iter) {
listNode *current = iter->next;
if (current != NULL) {
if (iter->direction == AL_START_HEAD)
iter->next = current->next; /* 关键:把后继指针抄进 iter */
...
}
return current;
}
走到 A 时:
| 位置 | 内容 |
|---|---|
返回值 ln |
node_A |
栈上 li.next |
node_B 的地址(尚未访问,只是缓存) |
server.clients |
A、B 都还在 |
然后 freeClient(A)。
3. 关键窗口:CLIENT_CHANGE 在 unlinkClient 之前
#
if (c->conn) {
moduleFireServerEvent(..., CLIENT_CHANGE_DISCONNECTED, c);
}
unlinkClient 要到后面才跑:
unlinkClient(c);
所以回调里:
- A 的
client*/conn还活着 - A、B 的
listNode都还链在server.clients上 - 外层
li.next仍指向node_B
4. Module 同步再杀 B(嵌套同一条路径) #
h2uaf 回调:ValkeyModule_Call(CLIENT KILL ID id2)
调用栈变成:
clientKillCommand ← 外层(杀 A,li.next = node_B)
freeClient(A)
moduleFireServerEvent
clientChangeCallback
VM_Call → CLIENT KILL ID B
clientKillCommand ← 内层
freeClient(B)
unlinkClient(B)
listDelNode(server.clients, node_B) ← zfree(node_B)
listDelNode 直接 zfree(node):
void listDelNode(list *list, listNode *node) {
listUnlinkNode(list, node);
...
zfree(node); /* 堆块进 freelist / ASan 标成 fd */
}
此时内存状态:
栈上 li.next ──────────┐
▼
[已释放的 node_B] ← ASan shadow = fd
server.clients: ... → node_A → (原 B 的 next) → ...
(A 此时可能还没 unlink;无关紧要,UAF 对象是 B 的节点。)
5. 炸掉的那一读 #
内层 KILL 返回 → freeClient(A) 继续跑完(unlink A 等)→ 外层 while 再调 listNext:
#0 listNext adlist.c:267 ← READ iter->next->next 之类
#1 clientKillCommand networking.c:5386
listNext 做的第一件事就是:
listNode *current = iter->next; /* = 已 free 的 node_B */
iter->next = current->next; /* 读 freed heap → UAF */
ASan 报告里:
- READ:
listNext/ 外层clientKillCommand:5386 - freed by:
listDelNode←unlinkClient← 内层freeClient(B)← moduleVM_Call← 外层freeClient(A)的 disconnect hook
和 #4198 同构:都是 迭代器缓存了后继 listNode*,同步 freeClient 把后继节点拆掉并 zfree,迭代器再解引用。差别只是链表从 blocking_keys 换成了 server.clients。
6. 为何“杀当前安全、杀后继才炸” #
- 杀当前节点:adlist 约定允许删 current;
listNext已把next提前抄走,删 current 不伤iter->next。 - 杀后继:正是
iter->next指向的那块堆;listDelNode释放后,下一次listNext必 UAF。
PoC 用相邻的 id1/id2,保证 A 的后继恰好是 B,命中这个窗口。
H3场景 #
1. 订阅在 Valkey 里到底是什么 #
Pub/Sub 不是存在某个 key 里的数据结构,而是:TCP 连接(client)进入一种特殊工作模式。
客户端发:
SUBSCRIBE h3chan
协议层收到后走 subscribeCommand → pubsubSubscribeChannel()。效果是:
- 这个 client 被标成 pubsub 类型(之后普通命令基本不能再发,只能收消息 / 退订)。
- 建立双向索引:
client.pubsub_data->pubsub_channels
"h3chan" ──► (存在即可)
server.pubsub_channels
"h3chan" ──► hashtable{ clientA, clientB, ... } # 所以应该在这里记录了一个客户端的链表(还是list?)
对应代码:
int pubsubSubscribeChannel(client *c, robj *channel, pubsubtype type) {
...
/* client → channels */
hashtableInsertAtPosition(type.clientPubSubChannels(c), channel, &position);
...
/* channel → clients */
serverAssert(hashtableAdd(clients, c));
...
addReplyPubsubSubscribed(c, channel, type);
}
从网络栈看:
- 连接仍是普通
accept()出来的 fd,挂在 ae 事件循环上。 - 区别在于:这个 fd 上的 client 对象多了一份
pubsub_data,并且PUBLISH时会按 channel 找到订阅者,往各自 reply buffer 里写消息,再由写事件write()出去。 - 没有单独的“订阅 socket”;订阅状态 = 堆上
client结构 + 全局 channel 表里的指针。
另外还有一条完全独立的链表:
server.clients ──► listNode ──► listNode ──► ...
│ │
▼ ▼
client* client*
所有已连接 client(管理连接、订阅者、副本……)都挂在这里。ACL 踢人扫的是这条链,不是 server.pubsub_channels。
注意不是,ACL Access Control List 就是访问权限控制链表,来进行处理。
2. ACL 和订阅怎么缠在一起 #
ACL 可以限制用户能碰哪些 channel(&foo、resetchannels 等)。
订阅时就会查权限;更关键的是:权限事后变严了,已经订上的连接怎么办?
Valkey 的策略是:直接踢连接,不是只 UNSUBSCRIBE。
ACL SETUSER h3user resetchannels(或 ACL LOAD 收紧规则)最后会进:
static void ACLKillPubsubClientsIfNeeded(user *new, user *original) {
...
listRewind(server.clients, &li);
while ((ln = listNext(&li)) != NULL) {
client *c = listNodeValue(ln);
if (c->user != original) continue;
if (ACLShouldKillPubsubClient(c, channels)) {
freeClientOrCloseLater(c, 0); /* 同步 free(非 current_client) */
}
}
}
ACLShouldKillPubsubClient 看的是:
这个 client 若是 pubsub,且它订的 channel/pattern 不在新规则允许集合里,就返回要杀。
所以 H3 路径的业务语义很简单:
两个 TCP 连接 AUTH 成同一 ACL 用户
→ 各自 SUBSCRIBE h3chan
→ 管理员收紧该用户的 channel ACL
→ 服务器遍历 server.clients,把这两个订阅者 free 掉
踢人时会走 freeClient → freeClientPubSubData → 从 channel→clients 表里把自己摘掉,再 unlinkClient 关 fd、从 server.clients 摘节点。
3. 为什么“纯踢两个订阅者”不会炸 #
adlist 的迭代器契约是:
listNode *listNext(listIter *iter) {
listNode *current = iter->next;
if (current != NULL) {
if (iter->direction == AL_START_HEAD)
iter->next = current->next; /* 提前缓存后继指针 */
...
}
return current;
}
内存里大致是:
listIter.li.next ──────────┐
▼
[node_admin] → [node_sub1] → [node_sub2] → NULL
│ │
▼ ▼
clientA clientB
当你处理 node_sub1 时,listNext 已经把 li.next 设成 node_sub2。
若此时只 freeClient(A):
unlinkClient(A)只listDelNode(node_sub1)node_sub2还在,li.next仍合法
→ 下一轮listNext拿到 B,再freeClient(B),一切正常。
这就是 control 测试能过的原因:同步 free 当前节点,对 adlist 是安全的。
4. 为什么加上 module 回调就会炸(H3 真正的洞) #
爆炸不来自“订阅机制本身”,而来自 freeClient 的重入时机。
if (c->conn) {
moduleFireServerEvent(..., CLIENT_CHANGE_DISCONNECTED, c);
}
...
/* 很后面才 */
unlinkClient(c); /* 约 2221 行 */
时序:
ACL 循环: listNext → 拿到 sub1
li.next 已经缓存 = node_sub2 ← 关键!
freeClientOrCloseLater(sub1)
freeClient(sub1)
① module: DISCONNECTED(sub1) ← 此时 sub1 还在 server.clients 上
h3uaf: CLIENT KILL ID sub2
freeClient(sub2)
unlinkClient(sub2)
listDelNode(node_sub2) ← 24 字节 listNode 被 zfree
zfree(clientB)
② unlinkClient(sub1) ← 才摘自己
ACL 循环: listNext(&li)
读 li.next (= 已释放的 node_sub2) → ASan: heap-use-after-free
对应你看到的栈:
listNext
← ACLKillPubsubClientsIfNeeded / ACLLoadFromFile
freed by:
listDelNode ← unlinkClient ← freeClient(sub2)
← CLIENT KILL ← module CLIENT_CHANGE ← freeClient(sub1)
用内存布局说:
listNode 是独立堆块(约 24B:prev/next/value)
client 结构是另一块堆
li.next 存的是 listNode*,不是 client*
杀后继时:
clientB 和 node_sub2 都被释放
但栈上/寄存器里的 listIter 仍握着毒指针 node_sub2
下次 listNext 一解引用 → UAF(读 next/value)
这和 #4198(ready-key 上的 blocked list)是同一类 bug:
单线程里,用
listIter缓存后继listNode*,中间同步调用链却把后继节点从同一条 list 上拆掉并释放。
订阅只是提供了一个会批量同步 freeClient 的入口(ACL 收紧 channel);
真正引爆需要 free 当前时再 free 后继(PoC 用 module disconnect hook 模拟)。
5. 整条路径串起来(对照 PoC) #
[TCP] rd1, rd2 accept → linkClient → 挂上 server.clients
[AUTH] 绑定同一 ACL user
[SUBSCRIBE] 写入 client↔channel 双向表,client 变 pubsub
[ACL SETUSER resetchannels] // 或 ACL LOAD 写严规则
└─ ACLKillPubsubClientsIfNeeded
扫 server.clients
发现 rd1 订了不再允许的 channel → freeClient(rd1)
└─ DISCONNECTED hook → CLIENT KILL rd2
└─ 拆掉 li 已缓存的 node_rd2
listNext → 炸
若没有 hook:free(rd1) 只拆 node_rd1,再 free(rd2),安全。
6. 一句话抓住本质 #
- 订阅:连接态 + 双向哈希索引,消息靠往订阅者 reply buffer 写。
- ACL 收紧:策略是遍历
server.clients同步踢非法订阅者。 - 炸的原因:不是 SUBSCRIBE 写坏了表,而是踢人循环用
listIter缓存了后继listNode*,而freeClient在 unlink 前就触发 module,让后继也被同步拆掉——缓存指针变成悬空指针。
如果你愿意,下一步可以对照 H2(CLIENT KILL 自己扫 server.clients)画一张“同一条 clients 链上的两种入口”,会更清楚这类洞的治理面为什么该统一(快照 / async free / 禁止在 DISCONNECTED 里同步杀别的 client)。
但是值得注意的是,这种情况是我们刻意利用module制造出来的,在生产环境中一般见不到,我们只是想证明会出现此类问题。
中优先级 #
| # | 位置 | 结构 | 备注 | 尝试思路 |
|---|---|---|---|---|
| M1 | freeClientsInAsyncFreeQueue |
clients_to_close |
在 beforeSleep;杀当前一般 OK;module 再 sync free 队列里另一个则危险 | 两个 CLOSE_ASAP;第一个 free 时杀第二个 |
| M2 | disconnectReplicas / replicationCron 超时 |
server.replicas |
同 listIter 模式 | ≥2 replica;disconnect 回调再杀另一个 |
| M3 | diskless rdb_pipe_conns 错误路径 |
conn 数组 | 不是 listIter,但是握着后续 client* |
diskless 多副本 + 错误路径 + 级联 free |
| M4 | moduleFireServerEvent / keyspace notify |
listener/subscriber list | 迭代中注销另一个 listener | 两 listener,第一个卸第二个 |
| M5 | module BlockClientOnKeys reply |
仍是 blocking_keys |
同一列表的另一攻击面(已有 PoC) | 已知 #4198 |
再来看看中等优先级是否会出现此类情况。