跳过正文

防止同步重入解UAF类问题

作者
杨全烨
系统软件:操作系统、网络与分布式系统。
目录

迄今为止我们看到好几个这样的段错误问题了。

这个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 KILLevictClients(), 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 prefer freeClientAsync() 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 0Blocking 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 末尾调用 handleClientsBlockedOnKeyshandleClientsBlockedOnKey

        listRewind(clients, &li);
        ...
        while ((ln = listNext(&li)) && count--) {
            client *receiver = listNodeValue(ln);
            ...
                    unblockClientOnKey(receiver, rl->key);

对 list 类命令,唤醒不是“直接塞个回复”那么简单,而是:

  1. 把该 client 从 blocking_keys 摘掉、清 CLIENT_BLOCKED
  2. 重新执行原来的 BLPOPprocessCommand 再跑一遍)
  3. 这时 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 就会读已经释放的 listNodeheap 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

从系统角度:

发生了什么
事件循环 仍在处理同一次 readprocessCommand(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
  • 这条:processCommandevictClients

同一条链表 + 同一种危险的 listIter 用法。


6. 为啥测试要用 MULTI 绑 CONFIG SET + LPUSH
#

若直接:

CONFIG SET maxmemory-clients 很小
LPUSH mylist v1

LPUSH 自己的 processCommand 一进来就会 evictClients,B 可能在进入 handleClientsBlockedOnKey 之前就被踢掉——打不到“迭代中途 free 后继”。

MULTI/EXEC 把降水位和 LPUSH 放进同一次 EXEC

  1. processCommand(EXEC) 开头:水位还高 → 不踢 B
  2. 事务里 CONFIG SET 把水位降下去(只走嵌套 call(),不再跑一整遍外层 processCommand
  3. 事务里 LPUSH 把 key 标 ready
  4. EXEC 结束后才 handleClientsBlockedOnKeys
  5. 服务 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>

readQueryFromClientprocessInputBufferprocessCommandclientKillCommand

此时 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_CHANGEunlinkClient 之前
#

    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 报告里:

  • READlistNext / 外层 clientKillCommand:5386
  • freed bylistDelNodeunlinkClient ← 内层 freeClient(B) ← module VM_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

协议层收到后走 subscribeCommandpubsubSubscribeChannel()。效果是:

  1. 这个 client 被标成 pubsub 类型(之后普通命令基本不能再发,只能收消息 / 退订)。
  2. 建立双向索引
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(&fooresetchannels 等)。

订阅时就会查权限;更关键的是:权限事后变严了,已经订上的连接怎么办?

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 掉

踢人时会走 freeClientfreeClientPubSubData → 从 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

再来看看中等优先级是否会出现此类情况。