跳至内容

Redis 消费者组 Double Free 漏洞分析,为什么第一次修复还不够?

2026-11-03

2026年5月,Redis 官方披露并修复了一个可能导致远程代码执行的高危漏洞。然而后续公开研究表明,首次修复未能覆盖所有触发场景,相关版本仍可通过另一条路径触发内存破坏,修复工作因此需要进一步完善。两个漏洞的编号为:CVE-2026-25243、CVE-2026-66373。

本文介绍从原始漏洞到补丁绕过,再到补充修复的完整过程,这一过程暴露了内存所有权管理、输入校验和补丁完整性方面的问题,值得开发人员与安全研究人员深入思考。

Redis Nack相关机制

在深入分析漏洞的触发代码前,我们需要先理清 Redis Stream 及其消费组在内存中是如何追踪消息状态的。在 Redis 中,Stream 本质上是一个仅追加写入的持久化消息日志,所有消息按时间顺序分配全局递增的唯一 ID。为了支持多节点协同处理,Redis 引入了类似 Kafka 的“消费组(Consumer Group)”机制:一个消费组代表一个协同消费的业务集群,组内由若干个独立的“消费者(Consumer)”作为具体执行人,共同瓜分并处理同一个 Stream 中的消息。

在可靠消息队列中,最关键的问题在于如何保证“至少送达一次(At-Least-Once)”——当一条消息被某个消费者读走后,系统必须持续追踪其生命周期,防止消费者因异常崩溃导致消息丢失。为此,Redis 引入了 PEL(Pending Entries List,待处理条目列表)与 NACK(streamNACK)结构。当消费者读取消息时,Redis 并不会将消息标记为完成,而是在堆内存中为其创建一个 streamNACK 结构体(NACK 即 Not Acknowledged,代表“尚未确认的任务”)。这个结构体本身与具体的消息内容完全解耦,它并不保存消息字段,仅仅是一张包含了投递时间戳、重试次数以及所属消费者指针的轻量级“待办凭证”。核心数据结构是以下三个结构体。

// 单条“未确认消息”的元数据(在 Redis 源码里叫 streamNACK)
typedef struct streamNACK {
    uint64_t msg_id;              // 消息ID
    long long delivery_time;      // 投递时间戳
    struct streamConsumer *owner; // 关键:指向谁领了这个任务
} streamNACK;

// 一个消费者(比如一个工作进程)
typedef struct streamConsumer {
    char *name;                   // 名字,比如 "worker_A"
    rax *my_pel;                  // 树结构(类似于红黑树),存该 worker 认领的 NACK 指针
} streamConsumer;

// 整个消费组
typedef struct streamCG {
    rax *global_pel;              // 全局树:存当前组所有未确认的 NACK 指针
    dict *consumers;              // 字典表:存所有消费者(worker_A, worker_B...)
} streamCG;

为了管理这些待办凭证,消费组在内存中构建了两层基数树(Radix Tree)组成的 PEL 体系。第一层是消费组级别的“全局 PEL”,记录整个消费组所有在途流转、尚未确认的消息;第二层则是每个消费者名下的“私有 PEL”,只记录该消费者自己领取的任务。在正常的运行时态下,全局 PEL与私有 PEL 共同引用同一个堆上的 streamNACK 指针,以零数据冗余实现状态同步。当消费者处理完业务并调用 XACK 确认后,该凭证才会从两套 PEL 中摘除,并被分配器安全释放。

这套体系能够稳定运转,完全依赖于一个隐式的核心不变式:在同一时刻,全局 PEL 中的一条待办消息只能且必须独占归属于唯一的某一个消费者。当某个消费者被销毁注销(执行XGROUP DELCONSUMER)时,Redis的清理逻辑会遍历该消费者私有的 PEL,将它认领的所有streamNACK 逐一调用 free() 销毁。正是这种将“对象释放权”直接交予消费者的清理设计,为后来的灾难埋下了伏笔——一旦反序列化逻辑出现偏差,让两个不同消费者的私有 PEL 同时持有了指向同一块 streamNACK 的堆指针,原本顺理成章的销毁流程,就会瞬间蜕变为连续对同一块内存执行释放的 Double Free。

Redis支持一组管理命令DUMP、RESTORE,它们用于数据的序列化与反序列化。其中RESTORE命令,它干的事情本质上就是从网络接收一段二进制数据,然后做反序列化,本次分析的漏洞就出在RESTORE命令的反序列化过程中。

原始漏洞的根本原因

究其根本,问题在于 Redis 在通过 RESTORE 命令反序列化 Stream 消费组数据时,其异常处理分支存在职责重叠导致的重复释放缺陷。当恶意构造的 RDB 数据使同一个消费者名下出现重复的消息 ID 时,由于消费者的私有基数树不允许重复 Key,第二次插入(raxTryInsert)必然失败并走入错误分支。在该分支中,代码不仅显式调用了 streamFreeNACK(nack) 销毁该凭据,紧接着又调用decrRefCount(o) 试图安全中止并销毁整个 Stream 对象;然而,此时该 nack 的指针早已合法挂载在消费组的全局 PEL 树上,decrRefCount(o) 在级联清理全局 PEL 时,不可避免地对同一块内存指针执行了第二次 free(),从而在加载失败的瞬间直接触发了堆内存双重释放(Double Free)。

/* src/rdb.c - rdbLoadObject() 中解析消费者私有 PEL 的关键代码 */
while (pel_size--) {
    /* 1. 读取消息 ID,并从已初始化的全局 PEL 中检索出对应的 nack 堆指针 */
    unsigned char rawid[sizeof(streamID)];
    rioRead(rdb, rawid, sizeof(rawid));
    streamNACK *nack = raxFind(cgroup->pel, rawid, sizeof(rawid));

    nack->consumer = consumer;

    /* 2. 尝试挂载至消费者的私有树。
     * 若 Payload 在同个消费者下声明了重复的 ID,raxTryInsert 必将返回失败 */
 	if (!raxTryInsert(consumer->pel, rawid, sizeof(rawid), nack, NULL)) 	{
        rdbReportCorruptRDB("Duplicated consumer PEL entry loading a stream consumer group");

        /* 【第 1 次释放】:错误分支主动将 nack 释放还给堆分配器 */
        streamFreeNACK(nack);

        /* 【第 2 次释放】:销毁整个 Stream 对象。
         * 其内部析构流程会遍历全局 PEL (cgroup->pel),对依然留在全局树中的
         * 同一个 nack 指针再次调用 streamFreeNACK(),当场引爆 Double Free! */
        decrRefCount(o);
        return NULL;
    }
}    

可以看出,这个双重释放算相对明显的问题,我们可以就此认为:开发人员的测试不够完善,没有覆盖这个分支,因为一旦测试覆盖这个分支,直接就会显示出这个内存问题;也可以认为,开发人员的错误处理不够优雅。触发双重释放的数据如下:


0000: 0f 00 00 00 00 01 01 67 00 00 01 01 00 00 00 00   |.......g........|
0010: 00 00 00 00 00 00 00 00 00 00 00 00 68 e5 cf 8b   |............h...|
0020: 01 00 00 01 02 01 41 00 68 e5 cf 8b 01 00 00 01   |......A.h.......|
0030: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   |................|
0040: 01 42 00 68 e5 cf 8b 01 00 00 01 01 00 00 00 00   |.B.h............|
0050: 00 00 00 00 00 00 00 00 00 00 00 09 00 9b 61 c8   |.............a.|
0060: 9c 7f 21 84 98                                     |..!..|

[CVE-2026-25243 RDB Payload (共 90 字节)]
 ├── 1. 基础元数据 (5 字节)
 │     ├── 0x0F          -> 类型标识: RDB_TYPE_STREAM_LISTPACKS (15)
 │     ├── 0x00          -> 消息 Listpack 数量 = 0
 │     ├── 0x00          -> 流内消息条目 = 0 (空消息流)
 │     └── 0x00 0x00     -> 流最后消息 ID (0-0)
 │
 ├── 2. 消费组声明 (5 字节)
 │     ├── 0x01          -> 消费组数量 = 1
 │     ├── 0x01 0x67     -> 组名长度 1,字符 "g"
 │     └── 0x00 0x00     -> 消费组最后投递 ID (0-0)
 │
 ├── 3. 全局 PEL 待确认池 (26 字节)
 │     ├── 0x01          -> 全局未确认消息数 = 1
 │     ├── 16 字节 ID    -> rawid = 1-0 (ID 编码)
 │     ├── 8 字节时间    -> delivery_time (小端序时间戳)
 │     └── 1 字节次数    -> delivery_count = 1
 │     /* Redis 在堆上 malloc(sizeof(streamNACK)),记为 P_NACK,并存入全局树 */
 │
 ├── 4. 消费者列表 (44 字节) - 【25243 原漏洞核心构造】
 │     ├── 0x01          -> 消费者数量 = 1 (仅 Consumer A)
 │     │
 │     └── 消费者 A:
 │           ├── 0x01 0x41     -> 名字 "A"
 │           ├── 8 字节        -> seen_time
 │           ├── 0x02          -> 【关键诱因】:该消费者认领消息数 = 2
 │           │
 │           ├── 16 字节 ID    -> 首次认领: rawid = 1-0
 │           │     /* raxTryInsert 成功挂载,P_NACK 进入 A->pel */
 │           │
 │           └── 16 字节 ID    -> 恶意重复: 再次认领同一 rawid = 1-0!
 │                 /* 【灾难爆发】:基数树 raxTryInsert 发现重复 Key 报错失败!
 │                  * 强制触发异常分支:
 │                  * 1. 局部释放:streamFreeNACK(P_NACK); (第 1 次 free)
 │                  * 2. 全局析构:decrRefCount(o);        (第 2 次 free)
 │                  * -> 在 RESTORE 阶段当场引爆 Double Free! */
 │
 └── 5. 完整性校验尾部 (10 字节)
       ├── 0x09 0x00     -> RDB 版本号 9 (对应 Redis 5.0+)
       └── 8 字节        -> 根据前面 82 字节算出的 CRC64 校验码

首次修复方案及遗漏的问题

在收到 CVE-2026-25243 的漏洞报告后,官方维护者的视线迅速聚焦在了异常处理分支的崩溃现场上。当时的崩溃机理非常直白:当恶意数据在同一个消费者下连续写入两次相同的消息 ID 时,私有基数树的去重逻辑会强制走入错误分支;而在该分支中,代码先显式调用了一次 streamFreeNACK(nack),随后调用的全局析构函数 decrRefCount(o) 又会级联遍历全局树对该指针执行二次释放。基于“既然全局析构会统一回收,局部显式释放纯属多此一举”的直觉,官方制定了一套极其典型的对症修补方案——直接删掉那行多余的局部 free 代码。

在 commit b9dde6fc 中,官方提交的补丁代码极其精准,仅仅从 src/rdb.c 中抹去了一行调用:

--- a/src/rdb.c
+++ b/src/rdb.c
@@ -3335,7 +3335,6 @@ robj *rdbLoadObject(int rdbtype, rio *rdb, sds key, int dbid, int *error)
             rdbReportCorruptRDB("Duplicated consumer PEL entry "
                                     " loading a stream consumer "
                                     "group");
-            	 streamFreeNACK(nack);
                 decrRefCount(o);
                 return NULL;
}

这一修复方案,开发者把防线死死焊在了错误分支上,却忽略了补丁上方两行处于裸奔状态的赋值语句:nack->consumer = consumer;。代码自始至终没有校验该 NACK 是否已被其他消费者认领,对跨消费者的所有权冲突毫无防备。

攻击者只需将同一个消息 ID 拆分给两个不同的消费者,基数树插入便能一路绿灯返回成功。这不仅彻底绕开了官方修补的错误分支,更让带有双重引用的恶意数据合法落库,直接把原本在加载报错时同归于尽的崩溃漏洞,升级为了完全由后续业务命令(DELCONSUMER)自主触发的高危双重释放。展示一下第二个漏洞位置:

/* 循环遍历当前 Consumer 认领的每一条消息 */
while(pel_size--) {
    unsigned char rawid[sizeof(streamID)];
    rioRead(rdb, rawid, sizeof(rawid));

    // 拿着 ID 从全局 PEL 里把 nack 指针找出来
    streamNACK *nack = raxFind(cgroup->pel, rawid, sizeof(rawid));
    if (nack == raxNotFound) {
        ...
        return NULL;
    }

    /* -------------------------------------------------------------
     * 【位置 1:绕过修复的位置】
     * 这里是正常执行流,原本完全没有任何校验,直接赋值!
     * 101 字节 Payload 故意让两个不同的 Consumer 认领同一个 ID,
     * 让代码顺顺当当地走过这里,两次把同一个 nack 赋给不同的 consumer。
     * ------------------------------------------------------------- */
    nack->consumer = consumer; 

    /* -------------------------------------------------------------
     * 【位置 2:CVE-2026-25243 最初的原漏洞点】
     * 这里是异常分支。原版 25243 让同一个 Consumer 认领两次相同的 ID,
     * 导致这里的 raxTryInsert 报错返回 0,强行冲进了这个 if 分支里!
     * ------------------------------------------------------------- */
    if (!raxTryInsert(consumer->pel, rawid, sizeof(rawid), nack, NULL)) {
        rdbReportCorruptRDB("Duplicated consumer PEL entry...");
        
        // 【CVE-2026-25243 原漏洞罪魁祸首】:
        streamFreeNACK(s, nack);  // <--- 官方在修 25243 时把这行代码删掉了!
        
        decrRefCount(o);          // 这里销毁整个 Stream,又把 nack free 了一次
        return NULL;
    }
}

这次利用,攻击者采用的序列化数据长这个样子。

[RDB Payload (共 101 字节)]
 ├── 1. 基础元数据 (5 字节)
 │     ├── 0x0F          -> 类型标识: RDB_TYPE_STREAM_LISTPACKS (15)
 │     ├── 0x00          -> 消息 Listpack 数量 = 0
 │     ├── 0x00          -> 流内消息条目 = 0 (空消息流)
 │     └── 0x00 0x00     -> 流最后消息 ID (0-0)
 │
 ├── 2. 消费组声明 (5 字节)
 │     ├── 0x01          -> 消费组数量 = 1
 │     ├── 0x01 0x67     -> 组名长度 1,字符 "g"
 │     └── 0x00 0x00     -> 消费组最后投递 ID (0-0)
 │
 ├── 3. 全局 PEL 待确认池 (26 字节)
 │     ├── 0x01          -> 全局未确认消息数 = 1
 │     ├── 16 字节 ID    -> rawid = 1-0 (ID 编码)
 │     ├── 8 字节时间    -> delivery_time (小端序时间戳)
 │     └── 1 字节次数    -> delivery_count = 1
 │     /* Redis 在堆上 malloc(sizeof(streamNACK)),记为 P_NACK,并存入全局树 */
 │
 ├── 4. 消费者列表 (55 字节) - 【核心构造】
 │     ├── 0x02          -> 消费者数量 = 2 (Consumer A 与 Consumer B)
 │     │
 │     ├── 消费者 A:
 │     │     ├── 0x01 0x41     -> 名字 "A"
 │     │     ├── 8 字节        -> seen_time
 │     │     ├── 0x01          -> 认领消息数 = 1
 │     │     └── 16 字节 ID    -> 声明认领 rawid (1-0)
 │     │     /* Redis 将 P_NACK 挂入 A 的私有树:A->pel 拥有 P_NACK */
 │     │
 │     └── 消费者 B:
 │           ├── 0x01 0x42     -> 名字 "B"
 │           ├── 8 字节        -> seen_time
 │           ├── 0x01          -> 认领消息数 = 1
 │           └── 16 字节 ID    -> 恶意重叠:再次声明认领 rawid (1-0)!
 │           /* 因缺少校验,Redis 将同一个 P_NACK 再次挂入 B 的私有树:B->pel 拥有 P_NACK */
 │
 └── 5. 完整性校验尾部 (10 字节)
       ├── 0x09 0x00     -> RDB 版本号 9 (对应 Redis 5.0+)
       └── 8 字节        -> 根据前面 93 字节算出的 CRC64 校验码

最终修复方案

官方的终极修复非常干脆:不再折腾那些修修补补的退路,而是在派单前加了一道硬门禁——每次把任务凭单交给消费者前先瞅一眼,只要发现凭单上已经签过别人的名字了(nack->consumer != NULL),立刻认定数据被投毒,当场拒绝并销毁。

为什么这招能彻底断绝后患?因为它从源头卡死了“独占”的底线。不管攻击者是在同一个人身上刷单,还是把同一个任务分给两个人甚至一百个人,只要敢出现第二次认领,这道门禁就会当场抓现行。没有任何畸形的共享指针能活着溜进数据库,后头的双重释放自然就被连根拔起了。

写在最后

回顾这两个漏洞,问题都出在同一个对象被多处引用之后,代码没有始终守住它的归属和释放规则。原始漏洞中,消费者列表保存的是借用指针,局部错误处理却直接释放了仍由组级 PEL 管理的 NACK,导致整体清理时再次释放。首次补丁删除了这次多余的释放,却没有阻止两个消费者引用同一个 NACK。原有检查只能发现单个列表内部的重复,无法识别不同消费者之间的归属冲突,因而留下了另一条触发路径。

对于开发人员,修复这类问题既要明确谁负责释放对象,也要检查输入能否建立不合法的引用关系,并验证这些关系在后续查询、转移和删除操作中是否安全。对于安全研究人员,补丁审计除了验证已知触发路径是否被堵住,还应追问:危险的对象关系是否仍能构造?

关于利用,由于redis提供了丰富的命令支持,可以说在redis上利用内存破坏类漏洞,调试分析相对是简单的,有兴趣的可以试一试。