ZeroDay.cloud
Back to Blog
Redis

CVE-2026-25243: Two Redis RESTORE Bugs Leading to RCE

Emil LernerJun 218 min read

CVE-2026-25243: Two Redis RESTORE Bugs Leading to RCE

Redis is one of those pieces of infrastructure that quietly ends up everywhere: caches, queues, rate limiters, session stores, feature flags, and more. It is a fast, mature, heavily optimized C codebase, and it has accumulated a lot of specialized internal representations over the years.

This post is about two bugs I found while looking at Redis for ZeroDay.Cloud 2025, a cloud hacking competition organized by Wiz Research. The Redis track focused on turning authenticated access into remote code execution. My route went through one command, RESTORE, and two different double-frees in Redis's RDB loading code.

The bugs are independent. One is in the legacy hash zipmap conversion path; the other is in stream consumer group deserialization. Once either double-free is triggered, though, the exploitation path is mostly shared: create overlapping heap objects, turn one Redis string into a 1MB view over heap memory, build arbitrary read/write, find redisServer, and finally hijack Redis's own restart path.

Remediation

Redis patched the bug on May 5, 2026. The fix was shipped across all five maintained release series.

Release seriesAffected versionsFixed version
Redis 7.2.x7.2.0 – 7.2.137.2.14
Redis 7.4.x7.4.0 – 7.4.87.4.9
Redis 8.2.x8.2.0 – 8.2.58.2.6
Redis 8.4.x8.4.0 – 8.4.28.4.3
Redis 8.6.x8.6.0 – 8.6.28.6.3

The Power of RESTORE

RESTORE is Redis's raw import command. It takes a key name, a TTL, and a binary blob in Redis's RDB serialization format:

RESTORE key ttl serialized-value

That blob is the same kind of data produced by DUMP, and it is also used internally for persistence, migration, and replication-related flows. It can encode many Redis object types and their internal encodings: strings, hashes, lists, sorted sets, streams, listpacks, ziplists, old zipmaps, and so on.

That makes RESTORE a very interesting command from a bug hunter's point of view. Historically, this format was mostly something Redis wrote for itself and read back from trusted local files. Starting with Redis 2.6, DUMP and RESTORE made it a user-visible wire format for migration and resharding.

Over time, Redis added more validation around this path, especially around Redis 6.2. That hardening fixed many easy crashes, but it also created a more subtle attack surface: the code now often validates a serialized object, then converts it from an old representation into a newer one. If the validator and converter do not parse the bytes in exactly the same way, malformed data can slip through the first component and break the second.

Fuzzing the RDB Format

Instead of hand-writing malformed RDB objects, I started by building a fuzzer.

Network fuzzing would have added a lot of noise: command parsing, sockets, event loop behavior, timeouts, and state left behind by previous test cases. The code I wanted to hit was deeper: rdbLoadObject() and the type-specific object loaders. The harness therefore boots Redis once inside the fuzzer process, creates a fake client, and calls the loading code directly.

I wrote a custom fuzz_main() function that mirrors just enough of Redis startup to initialize global state:

int fuzz_main(int argc, char **argv) {
    ...
    initServerConfig();
    server.port = 0;
    server.tls_port = 0;
    server.crashlog_enabled = 0;
    server.memcheck_enabled = 0;
    ...
    initServer();
    ...
    client *fake_client = createClient(NULL);
    fake_client->flags |= CLIENT_DENY_BLOCKING;
    fake_client->authenticated = 1;
    server.current_client = fake_client;
    return 0;
}

This function is called on the first invocation of LLVMFuzzerTestOneInput. There are no listening sockets and no normal event loop. The fuzzer gets Redis initialized enough to execute object loading and selected commands, while still keeping ASan/libFuzzer in control of the process.

The main test case format is protobuf-based:

message Command {
  bytes name = 1;
  repeated bytes args = 2;
}

message FuzzCase {
  bytes rdb_object = 1;
  repeated Command commands = 2;
}

The rdb_object field is fed to Redis as a serialized object. If loading succeeds, the harness inserts the resulting object into database 0 under a fixed key such as the_object. Then the optional command list is executed against that key. This gives the fuzzer two chances to find bugs: during deserialization itself, and later when Redis commands operate on the deserialized object.

One practical problem was Redis's use of internal asserts. A normal Redis assert aborts the process, which is exactly what you want in production but not always what you want during fuzzing. Many malformed objects trip assertions that are interesting, but not worth stopping the whole fuzzing run for. To keep the campaign moving, I added a fuzz fatal handler that turns Redis fatal paths into a siglongjmp back to the harness:

void __attribute__((noreturn))
fuzzFatalHandler(const char *type, const char *msg,
                 const char *file, int line) {
    ...
    siglongjmp(recover_ctx.env, 1);
}

The harness arms that recovery point around each test case, resets the database afterwards, and continues. I also put a hard allocation limit in the Redis allocator during fuzzing, so "please allocate half the address space" inputs become cheap recoverable failures instead of machine-killing OOMs.

This setup found both bugs below: the hash fuzzer found the legacy zipmap issue, and the stream fuzzer found the consumer PEL issue.

Bug 1: A Zipmap Length That Meant Two Things

Redis used to store small hashes in a compact encoding called zipmap. A zipmap is a single contiguous blob: a header, then field/value entries, where each field and value is preceded by an encoded length. Modern Redis versions use listpacks or regular hash tables instead, so when RESTORE sees an old zipmap it needs to load it and convert it.

The double-free is in that conversion path. To reach it, the converter has to see an entry whose length makes lpSafeToAdd() reject the conversion. That sounds easy, except RESTORE validates the zipmap before conversion and rejects many malformed entries before they get that far.

The trick is that the validator and converter disagree on a strange but valid-looking length encoding.

Zipmap lengths normally use one byte for small values. Larger values use 0xFE followed by a 32-bit length. The validator decides how many bytes the encoded length occupies by looking at the first byte in the buffer:

static unsigned int zipmapGetEncodedLengthSize(unsigned char *p) {
    return (*p < ZIPMAP_BIGLEN) ? 1: 5;
}

The converter, however, sometimes decodes the logical length first and then asks how many bytes would be needed to encode that length in its most compact form:

#define ZIPMAP_LEN_BYTES(_l) (((_l) < ZIPMAP_BIGLEN) ? 1 : sizeof(unsigned int)+1)

static unsigned int zipmapEncodeLength(unsigned char *p, unsigned int len) {
    if (p == NULL) {
        return ZIPMAP_LEN_BYTES(len);
    } else {
        /* ... */
    }
}

That creates an ambiguity. A payload can encode the small length 4 in the long five-byte form:

0xFE 0x04 0x00 0x00 0x00   # logical length = 4, encoded in 5 bytes

The validator sees 0xFE and skips five bytes of length plus four bytes of data. The converter decodes the length as 4, decides it should have occupied only one byte, and skips one byte of length plus four bytes of data. From that point on, the validator and converter are four bytes apart. They are walking the same blob but no longer seeing the same entries.

With that desynchronization, the payload can pass validation and then make the converter hit the real bug:

sds field = sdstrynewlen(fstr, flen);
if (!field || dictAdd(dupSearchDict, field, NULL) != DICT_OK ||
    !lpSafeToAdd(lp, (size_t)flen + vlen)) {
    rdbReportCorruptRDB("Hash zipmap with dup elements, or big length (%u)", flen);
    dictRelease(dupSearchDict);
    sdsfree(field);
    zfree(encoded);
    o->ptr = NULL;
    decrRefCount(o);
    return NULL;
}

The important ordering is:

  1. sdstrynewlen() allocates field.
  2. dictAdd(dupSearchDict, field, NULL) succeeds, so the dictionary now owns field.
  3. The crafted value length makes lpSafeToAdd() fail.
  4. dictRelease(dupSearchDict) frees the dictionary keys, including field.
  5. The error path then calls sdsfree(field) explicitly.

That gives a double-free of the field allocation. In the exploit configuration I used, this is a 256-byte chunk, which is a comfortable size for building an overlap later.

Bug 2: One Stream NACK, Two Owners

The second bug is in Redis streams.

A Redis stream is an append-only log, and consumer groups let workers coordinate which entries have been delivered but not acknowledged yet. Redis tracks these pending messages in PELs: Pending Entries Lists. There is a group-level PEL mapping stream IDs to streamNACK objects, and each consumer also has its own PEL pointing to the same streamNACK objects for the entries owned by that consumer.

When Redis loads a stream from RDB data, it rebuilds both views:

  1. Load the group global PEL and allocate one streamNACK per pending entry.
  2. For each consumer, load its PEL.
  3. Look up the matching streamNACK in the global PEL.
  4. Insert that same pointer into the consumer's PEL.

The vulnerable path is the duplicate-entry error case:

void *result;
if (!raxFind(cgroup->pel, rawid, sizeof(rawid), &result)) {
    rdbReportCorruptRDB("Consumer entry not found in "
                            "group global PEL");
    decrRefCount(o);
    return NULL;
}
streamNACK *nack = result;

nack->consumer = consumer;
if (!raxTryInsert(consumer->pel, rawid, sizeof(rawid), nack, NULL)) {
    rdbReportCorruptRDB("Duplicated consumer PEL entry "
                            " loading a stream consumer "
                            "group");
    streamFreeNACK(s, nack);
    decrRefCount(o);
    return NULL;
}

The global PEL owns the streamNACK. The consumer PEL is supposed to hold another reference to the same object. But if the serialized consumer PEL contains the same rawid twice, the first insert succeeds and the second raxTryInsert() fails. The error path calls streamFreeNACK(s, nack) even though that nack is still reachable from the global PEL.

Then decrRefCount(o) unwinds the partially loaded stream object. During that cleanup, the global PEL is freed and the same streamNACK is freed again.

The payload for this is pleasantly small:

The resulting freed chunk is smaller than the zipmap one: sizeof(streamNACK), 32 bytes on the target version I used and 24 bytes on some older layouts. Smaller chunks are less convenient, but the same post-exploitation idea still works.

From Double-Free to Heap Overlap

Once either bug fires, Redis has freed the same jemalloc chunk twice. The next task is to turn that allocator inconsistency into two live Redis objects backed by the same memory.

First, we have to detect which exact memory regions are overlapping. To do this, the exploit sprays keys whose values contain unique markers after triggering the double-free. If two different keys start to expose the same marker, that means they are backed by the same heap chunk. That is the overlap.

The next step is to turn this overlap into an actual memory view: a huge string that lets us read and write the heap region beyond its original pointer.

Redis strings are represented using SDS, Simple Dynamic Strings. An SDS is a small header followed by bytes:

[ length ][ allocation size ][ flags ][ data ... ][ \0 ]

The data can be accessed in pieces with GETRANGE, which basically retrieves a substring, and overwritten with SETRANGE. SETRANGE is especially useful because it allows the exploit to overwrite parts of a string in place, a primitive that is missing in most other interpreters.

If I can overwrite the SDS header of one overlapped string, I can make Redis believe the string is much larger than it really is. The string's data pointer still points into the original heap region, but commands like GETRANGE, SETRANGE, and STRLEN now operate over a much larger window.

The exploit's default route uses another malformed RESTORE call to help with this. After finding two overlapping keys:

  1. Delete one key, freeing the overlapped chunk.
  2. Send a crafted intset-like RESTORE payload.
  3. Redis materializes attacker-controlled bytes in the freed slot before rejecting the object as ERR Bad data format.
  4. Those bytes overwrite the SDS header of the still-live overlapping key.

Because of the internal object layout, this approach gives us only an approximately 64KB string. However, we can use this initial 64KB widened view to write a second fake SDS header, this time creating a roughly 1MB "memview" key by corrupting the header of one of the SDS-backed keys we sprayed earlier. I intentionally keep it around 1MB. A fake multi-gigabyte string is tempting, but if Redis decides to save or rewrite AOF during the exploit, a huge fake value can turn a clean exploit attempt into a corrupted dump and a failed restart.

Teaching Redis to Read and Write for Us

At this point I have a 1MB Redis string whose logical length is fake. It gives a useful heap window, but not arbitrary memory access yet. To get there, the exploit needs to find another object inside that window whose internal pointer can be redirected.

The object I used is created with INCRBYFLOAT.

for i = 0, count - 1 do
    local key = string.format("%s:%06d", float_prefix, i)
    local value = string.format("1337.%06d05", i + 1)
    redis.call("INCRBYFLOAT", key, value)
end

This creates many predictable string values such as:

float:012345 -> "1337.01234605"

Those values are easy to search for in the memview. More importantly, their object layout is predictable enough to find the internal ptr field:

In both cases, the exploit scans the memview for 1337.NNNNNN, derives the matching key name, and confirms the match by modifying bytes through the memview and reading the float key back. Once it knows where the object's internal pointer lives, it overwrites that pointer with SETRANGE.

Redis string commands still expect an SDS header before the pointer, so this is not quite "read any address blindly". However, when the bytes before a pointer do not contain a valid SDS header, STRLEN returns zero or a negative length instead of crashing. This lets us use a backward scan for a byte sequence that forms a valid SDS header: if we want to read or write at pointer P, we can point the controlled string at P, P-1, P-2, and so on, checking STRLEN after each attempt. If we get a positive length, we can use GETRANGE and SETRANGE to access P and the bytes after it.

This is a good place to note that GETRANGE and SETRANGE are real gifts for an attacker. They allow partial reads and writes of string values, which means that even if the corrupted string reports an unreasonable length, such as 2^62, we can still use the malformed key by setting appropriate range limits.

While locating and hijacking this float object, we also disclose a heap address for free: the location of the overwritten pointer inside the memview tells us where that heap chunk lives despite ASLR.

Finding Redis in Memory

With heap read/write available, the exploit needs two more landmarks:

  1. A pointer into the Redis binary mapping.
  2. The address of the global redisServer struct.

The first search starts near the heap region already exposed by the memview. I did not research exactly how binary addresses end up on the heap (I believe they are function pointers or similar), but experimentation showed that reading backwards from the known heap address is enough to find one. The exploit again probes candidate addresses by redirecting the float pointer into heap regions and asking Redis whether the target looks like a readable SDS string. When a region can be read, it scans the returned bytes for 64-bit values that look like PIE pointers into the Redis binary, roughly in the expected high userspace range.

Since this stage can require many probes, I moved the whole probing loop into a small server-side Lua helper, which avoids thousands of network round trips.

Once the exploit has one likely binary pointer, it scans the binary's region for the beginning of struct redisServer. The early fields are distinctive enough to find with consistency checks:

struct redisServer {
    pid_t pid;
    pthread_t main_thread_id;
    char *configfile;
    char *executable;
    char **exec_argv;
    int dynamic_hz;
    int config_hz;
    mode_t umask;
    int hz;
    int in_fork_child;
    ...
};

A good candidate has a plausible pid, heap-looking pointers for main_thread_id, executable, and exec_argv, and small positive values for fields like dynamic_hz, config_hz, and hz. This gives the exploit the address it really wants: the live global redisServer instance.

Letting Redis Restart Into /bin/sh

The last part of the exploit reuses a feature Redis already has: restarting itself.

To do this, the exploit has to send the DEBUG CRASH-AND-RECOVER command. There is one catch, though: DEBUG is disabled by default. Luckily, whether this command is enabled is controlled by the enable_debug_cmd flag inside redisServer, the structure we can already edit.

After executing DEBUG CRASH-AND-RECOVER, Redis relaunches itself using the executable path and original argument vector stored in redisServer:

execve(server.executable, server.exec_argv, environ);

If I can patch those fields before triggering a controlled restart, Redis will call execve() for me.

First, the exploit lays out the future command line in the memview:

Offset 0:    "/bin/sh\0"
Offset 8:    "-c\0"
Offset 11:   <actual sh command>\0

Offset N:    [0]
Offset N+8:  [pointer to "-c"]
Offset N+16: [pointer to command]
Offset N+24: [0]

The zero at argv[0] is intentional. During restart, Redis does this:

zfree(server.exec_argv[0]);
server.exec_argv[0] = zstrdup(server.executable);
execve(server.executable, server.exec_argv, environ);

If argv[0] is NULL, zfree(NULL) is harmless, and Redis then fills it with a duplicate of server.executable, which I have changed to "/bin/sh".

Before patching redisServer, there is one annoying Redis detail to handle. Some string writes can trigger copy-on-write-style unsharing through dbUnshareStringValueByLink(), which would make Redis copy the object instead of modifying the memory I am pointing at. The exploit avoids this by normalizing the float object's encoding byte to OBJ_ENCODING_RAW.

Next, the exploit makes the redisServer region writable through the same string primitive. Since string commands expect an SDS header, the exploit points near the start of the struct, writes a small fake SDS header over the leading bytes, and then repoints the float so GETRANGE and SETRANGE operate just after that header. This ensures the whole redisServer struct is accessible. Corrupting a few early fields is acceptable at this point because the process is about to be replaced.

The final patches are:

Then the exploit sends:

DEBUG CRASH-AND-RECOVER

That command reaches restartServer(). Instead of restarting Redis with its original executable and arguments, the process executes:

/bin/sh -c "<command>"

And that is the full chain: a malformed RESTORE payload becomes a double-free; the double-free becomes overlapping Redis objects; the overlap becomes a heap memview; the memview becomes arbitrary read/write; and arbitrary write turns Redis's own restart machinery into code execution.

How Wiz Can Help

Wiz customers can use the pre-built query and advisory in the Wiz Threat Center to assess the risk in their environment.

Wiz identifies both internal and publicly exposed Redis instances in your environment affected by CVE-2026-25243, and alerts you to instances that have been misconfigured to allow unauthenticated access or use weak or default passwords.

Responsible Disclosure Timeline

Closing Thoughts

The bugs above show that even deep into the AI era, straightforward and fuzzable memory-safety bugs still exist in very popular open-source software. Both issues were fixed promptly by the Redis team within four months of the competition: c77d60d6b8c0dfa67b938ea50929ffb1661612df fixed the double-free during zipmap conversion, while 47c51369eeffd55e1baf20df7955a3dfbe842fc4 fixed the stream NACK-related free. A previous bug in RESTORE that I reported survived for four and a half years, from 2016 to 2020, so this is a genuine 93% improvement in patch time.

The full exploit chain is available here.

I'm really grateful to Wiz Research for organizing ZeroDay.Cloud, and I hope to find something interesting in next year's targets too.