跳过正文

UAF类-多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/指针快照或先收集再处理,使后继被拆掉时不会再解引用已释放节点。