https://github.com/valkey-io/valkey/issues/3345
我们遇到的主要现象就是ctrl + C的时候会出现段错误,应该如何解决?
这是我们的PR:
https://github.com/valkey-io/valkey/pull/3448
为了面试的时候讲清楚这个UAF导致的Server Crash问题,我们需要仔细复盘一下, 这个还是valkey Over RDMA的系列问题之一:
背景:RDMA为什么需要pending list #
RDMA 连接没有 TCP 那种 POLLOUT 事件来驱动 write handler, 所以 server 用一张待处理链表手动"叫醒"连接, 因为在真正开始传输数据的时候,我们使用的是RDMA的单边建立连接的方法, WRITE_WITH_IMM,来产生通知,就是我知道什么时候可读,但是不知道什么时候可以写:
/* RDMA connection is always writable, it has no POLLOUT event to drive the write handler, record available write
* handler into pending list */
static list *pending_list;
没办法来触发写回调。
每个 rdma_connection 里有一个 pending_list_node,既是链表节点指针,也是"是否已在列表中"的标志:
typedef struct rdma_connection {
connection c;
struct rdma_cm_id *cm_id;
int flags;
int last_errno;
listNode *pending_list_node; // 这里应该是指向最后的节点
} rdma_connection;
事件循环在 beforeSleep() 里统一处理:
/* Handle pending data(typical TLS). (must be done before flushAppendOnlyFile) */
int conn_pending = connTypeProcessPendingData();
if (conn_pending > 0) server.el_iteration_active = true;
→ rdmaProcessPendingData() 遍历 pending_list,对 CLOSED 连接调用 read/write handler 完成清理。
这个是需要挂到一个链表上进行统一的处理。
Bug:同一条连接被加入 pending_list 两次
#
比如你被释放了两次,那么是否就是会造成UAF,可以仔细分析一下这个链条,也是非常逆天。
触发场景 #
valkey-benchmark --rdma -c 256,Ctrl+C 中断 → 256 条 RDMA 连接几乎同时断开。
时间线(修复前) #
T1 RDMA CM 事件: DISCONNECTED -》 此时发生RDMA断连
rdmaHandleDisconnect()
conn->state = CONN_STATE_CLOSED -》 注意,这个时候修改了连接的状态为CLOSED
listAddNodeTail(pending_list, conn) ← 第 1 次入队,node_A -> NULL
pending_list_node = node_A
T2 同一连接上,write handler 被更新(仍有 pending write)
connRdmaSetWriteHandler(func=非NULL)
listAddNodeTail(pending_list, conn) ← 第 2 次入队,node_B
pending_list_node = node_B ← 加在了node_A后面!
你可以看到这样的状态,同时 conn->state = CONN_STATE_CLOSED
pending_list: [ node_A → conn ] → [ node_B → conn ]
同一个 conn 出现两次,但 pending_list_node 只记住 B
这里意思就是这样,先加上了node_A(当前连接需要被关闭), 然后加上node_B(当前连接需要处理写事件). 这里入队的条件写的太松了。
什么是CM事件?
CM 事件是 RDMA Connection Manager(librdmacm) 向上层应用报告的连接生命周期异步通知。
在 Valkey 里,它负责"建连/断连“这条控制面;
真正搬数据的数据面走的是 QP + CQ,是另一套 fd 和事件。
┌─────────────────────────────────────────────┐
│ 应用层 Valkey server / libvalkey client │
├─────────────────────────────────────────────┤
│ CM 层 librdmacm (rdma_connect/accept) │ ← CM 事件在这里,怪不得我不知道
├─────────────────────────────────────────────┤
│ Verbs libibverbs (ibv_post_send/recv...) │ ← CQ 完成事件在这里
├─────────────────────────────────────────────┤
│ 内核 rdma_cm 模块 + ib_uverbs 字符设备 │
├─────────────────────────────────────────────┤
│ 硬件 HCA (Host Channel Adapter) │
└─────────────────────────────────────────────┘
这个主要就是在管连接,这里就是CM断连事件。
这个还需要仔细研究。
CM 事件是 struct rdma_cm_event,由内核 rdma_cm 子系统产生,用户态通过 rdma_get_cm_event() 取出:
struct rdma_cm_event {
enum rdma_cm_event_type event; // 事件类型
struct rdma_cm_id *id; // 关联的连接标识
...
};
崩溃路径(修复前的 rdmaProcessPendingData)
#
修复前逻辑(parent of 75fee11c6):
// 旧代码 — 已不存在于当前树 这个时候预期遍历的是node_A
if (conn->state == CONN_STATE_CLOSED) { // 如果当前连接状态结束,需要删除这个节点
listDelNode(pending_list, rdma_conn->pending_list_node); // 删的是 node_B
rdma_conn->pending_list_node = NULL;
if (callHandler(conn, conn->read_handler)) { // 正在遍历 node_A
callHandler(conn, conn->write_handler);
}
...
}
第一轮迭代(ln = node_A):
pending_list_node指向 node_B(被 T2 覆盖)listDelNode删掉 node_B,node_A 还在链表上callHandler→readQueryFromClient→handleReadResult→freeClientAsync(c)(这个是异步free)
if (c->nread <= 0) {
if (c->nread == -1) {
if (connGetState(c->conn) != CONN_STATE_CONNECTED) {
...
freeClientAsync(c);
}
} else if (c->nread == 0) {
...
freeClientAsync(c);
}
return C_ERR;
freeClientAsync 只是入队,不会立刻 zfree(conn):
void freeClientAsync(client *c) {
if (c->flag.close_asap || c->flag.script) return;
c->flag.close_asap = 1;
...
listAddNodeTail(server.clients_to_close, c);
}
真正 freeClient → connClose →
释放 rdma_connection 在 freeClientsInAsyncFreeQueue(),
发生在 rdmaProcessPendingData() 返回之后:
processed += freeClientsInAsyncFreeQueue();
假 node_A(node_B)导致连接真的释放了。
第二轮迭代(ln = node_A,仍在链表上):
listNodeValue(ln)拿到同一条已标记关闭、可能正在被清理的 conn- 再次
callHandler→handleReadResult访问c->nread等字段 - 若内存已被复用或 client/conn 处于不一致状态 → SIGSEGV @ 0x8(NULL 指针 + offset 8,典型 use-after-free)
就是第一次node_B已经导致连接被释放了,之后处理node_A的时候还会在释放一次。
handleReadResult
← readQueryFromClient
← rdmaProcessPendingData
← beforeSleep
修复:四处改动,一个核心思路 #
核心 invariant:每个 connection 在 pending_list 里最多出现一次;
删除时必须删当前迭代节点 ln,不能信 pending_list_node(它可能已被覆盖)。
Fix 1:rdmaHandleDisconnect — 入队前检查
#
/* we can't close connection now, let's mark this connection as closed state */
if (rdma_conn->pending_list_node == NULL) {
listAddNodeTail(pending_list, conn);
rdma_conn->pending_list_node = listLast(pending_list);
}
如果此时pending_list_node == NULL: 表示为空,此时conn真的可以入队。
Fix 2:connRdmaSetWriteHandler — 同样防重复
#
/* does this connection has pending write data? */
if (func) {
if (rdma_conn->pending_list_node == NULL) {
listAddNodeTail(pending_list, conn);
rdma_conn->pending_list_node = listLast(pending_list);
}
} else if (rdma_conn->pending_list_node) {
listDelNode(pending_list, rdma_conn->pending_list_node);
rdma_conn->pending_list_node = NULL;
}
断开时若 write handler 仍被设置,旧代码会无条件再入队一次——这是 duplicate 的主要来源之一,这里就算设置,我们也要检查一次。
Fix 3 + 4:rdmaProcessPendingData — 用 ln 删除 + 先 handler 后删节点
#
现在的处理逻辑:
static int rdmaProcessPendingData(void) {
listIter li;
listNode *ln;
rdma_connection *rdma_conn;
connection *conn;
int processed = 0;
listRewind(pending_list, &li);
while ((ln = listNext(&li))) {
rdma_conn = listNodeValue(ln);
if (rdma_conn->flags & RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE) continue;
conn = &rdma_conn->c;
/* a connection can be disconnected by remote peer, CM event mark state as CONN_STATE_CLOSED, kick connection
* read/write handler to close connection */
if (conn->state == CONN_STATE_ERROR || conn->state == CONN_STATE_CLOSED) {
/* Invoke both read_handler and write_handler, unless read_handler
returns 0, indicating the connection has closed, in which case
write_handler will be skipped. */
if (callHandler(conn, conn->read_handler)) {
callHandler(conn, conn->write_handler);
}
listDelNode(pending_list, ln);
rdma_conn->pending_list_node = NULL;
++processed;
continue;
}
connRdmaEventHandler(NULL, -1, rdma_conn, 0);
++processed;
}
return processed;
}
sequenceDiagram
participant CM as RDMA CM Event
participant Disc as rdmaHandleDisconnect
participant WH as connRdmaSetWriteHandler
participant PL as pending_list
participant BSP as beforeSleep
participant PPD as rdmaProcessPendingData
participant RH as readQueryFromClient
CM->>Disc: DISCONNECTED
Disc->>PL: add conn (if pending_list_node==NULL)
Note over PL: node_A
WH->>PL: add conn again (每次都会加上,问题就出在这里,所以crash是必现)
Note over PL: node_B, pending_list_node=B
BSP->>PPD: connTypeProcessPendingData()
PPD->>PPD: iterate ln=node_A
Note over PPD: OLD: delete node_B, handler, node_A remains
PPD->>RH: callHandler (2nd iter on node_A)
Note over RH: UAF / SIGSEGV @ 0x8
Note over PPD: NEW: guard duplicates + delete ln + handler first