跳过正文

Valkey Over RDMA测试中server UAF崩溃的问题

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

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 256Ctrl+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):

  1. pending_list_node 指向 node_B(被 T2 覆盖)
  2. listDelNode 删掉 node_B,node_A 还在链表上
  3. 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