
Stable POC for CVE-2026-25243 (Redis RESTORE double-free -> remote code execution)
Verified against Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Reference: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; Stable exploit, works against variety of OS distro and architecture.
What is this? A memory corruption vulnerability in Redis that lets an
authenticated attacker run arbitrary commands as the Redis user. The attack is
real-world and requires just a single RESTORE command — a normal Redis
operation, not admin-only. This exploit demonstrates full RCE in under one
second.
Impact? Any authenticated Redis client can trigger it, and the damage is total: arbitrary code execution in the Redis process (often running as root in containers). There is no way to mitigate without patching Redis itself.
How does it work at a glance? Redis has a serialization feature (RESTORE)
that takes a blob of binary data and reconstructs it as a Redis object. The
code that validates the blob's format and the code that deserializes it
disagree on how to parse certain sequences — a bug that the attacker exploits
to corrupt the heap. Once the heap is corrupted, the attacker gains the ability
to read and write any memory address in the Redis process, and from there
hijacks the server's internal state to execute a shell command.
The real exploit technique: This is not a simple crash. It's a heap exploitation chain: corrupt → overlap → arbitrary R/W → information leak → find the server struct → hijack function pointers → RCE. The exploit runs 9 stages and requires leaking multiple addresses at runtime, parsing binary structures, and detecting memory aliasing. What makes it work across architectures (x86-64, aarch64, etc.) is that all the addresses are leaked from the target itself, not assumed.
CVE-2026-25243 is a pair of double-free bugs reachable from a single
authenticated RESTORE command. RESTORE key ttl <serialized-value>
deserializes an attacker-controlled RDB blob; both bugs live in the gap
between the validator that checks the blob and the converter that
materializes it.
Bug 1 — legacy zipmap conversion (CWE-415, the path this exploit uses).
The zipmap validator (zipmapValidateIntegrity()) and the converter
(zipmapNext()) disagree about a redundant length encoding. The small length
4 can legally be written in the long five-byte form FE 04 00 00 00. The
validator consumes one number of bytes, the converter another — a 4-byte
parsing desynchronisation. The converter therefore walks a different
structure than the one that was validated, lpSafeToAdd() fails after the
field has already been inserted into the dictionary, and the cleanup path
frees the field twice: once via dictRelease() and again via sdsfree().
Bug 2 — stream consumer PEL loading (CWE-415). In
rdbLoadStreamConsumersGroup(), a consumer PEL containing a duplicate entry
ID makes the second raxTryInsert() fail, which calls streamFreeNACK() on a
streamNACK that is still owned by the group's global PEL. Freed twice.
(Selectable with --vuln-type stream.)
Either bug hands the attacker a chunk of memory that is simultaneously free and referenced — the classic starting point for a heap-overlap exploit.
Impact: an authenticated Redis client (no admin rights, RESTORE is a
normal data command) gains arbitrary code execution as the redis user — root
in the default container image.
Nine stages, each of which turns a weaker primitive into a stronger one:
| Stage | Primitive gained | Mechanism |
|---|---|---|
| 0 | target profile | INFO server / INFO memory → version, arch, distro, pid, executable path, start time, allocator |
| 1 | double free | malformed zipmap (or stream) RESTORE |
| 2 | two keys sharing memory | spray marker keys onto the freed chunk, detect aliasing, then overwrite one key's SDS header through its twin to inflate it to a 1 MB "memview" |
| 3 | arbitrary R/W | find an INCRBYFLOAT object inside the memview, hijack its ptr field: GETRANGE/SETRANGE on that key now read/write any address |
| 4 | image pointer | scan heap backwards for a value inside the redis-server image |
| 5 | &server | walk down to the ELF header, parse program headers, dump the writable segment, match server.pid |
| 6 | payload in memory | write "/bin/sh", "-c", "<cmd>" plus an argv array into the memview |
| 7 | hijacked struct | overwrite server.executable, server.exec_argv, and server.enable_debug_cmd |
| 8 | RCE | DEBUG CRASH-AND-RECOVER → restartServer() → execve(server.executable, server.exec_argv, environ) |
python3 exploit.py --host 127.0.0.1 --port 6379 \
--password mypassword --cmd 'id > /tmp/pwned123.txt'
Verify:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Starting point: the exploit was x86-64-only and died in stage 3 on the aarch64 target. End state: full RCE on aarch64 Rocky Linux 8.10 in under one second, 116 Redis commands.
a) Runtime target fingerprinting (new, stage 0). Nothing about the target
is assumed any more. INFO server + INFO memory yield the Redis version,
CPU architecture (from the os: line), distro family (inferred from
gcc_version), allocator, and — most importantly — three validation
anchors: process_id, executable, and the exact stat_starttime
(server_time_usec/1e6 - uptime_in_seconds). Later stages compare against
these instead of guessing.
b) Architecture-independent memory layout. The four hardcoded x86-64
constants (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) are replaced by a
per-architecture table (ARCH_PROFILES) covering x86_64, aarch64 (both 39-
and 48-bit VA), riscv64, ppc64le and s390x, with both the ET_EXEC and ET_DYN
placements for each, plus a wide generic fallback for anything unlisted. This
was the actual reason the exploit failed on this target: the leaked pointer
0x0000ffff8a5fdf32 is a perfectly good aarch64 mmap address that the x86-64
range check rejected.
c) Consensus-based leak validation (stage 3). Rather than trusting a
hardcoded heap window, the scan now collects every structurally valid
1337.NNNNNN object in the memview and requires at least two of them to
derive the same memview base address (ptr - offset_of_value). In practice
502 candidates agree, which is proof no range table can offer. The confirmed
pointer then calibrates the heap window at runtime. Format validation was
also moved before the (round-trip-expensive) write-control test.
d) Stage 3 scan bound (bug fix). The scan ran to a hardcoded 10 MB while
the memview is 1 MB, so it read past the end, got an empty reply and aborted
with AssertionError: Empty data from memview. It is now bounded by the
memview's real STRLEN, reads 256 KB per round-trip instead of 64 KB, and the
pointless 6×1s retry-sleep loop is gone.
e) Stage 5 rewritten: ELF-guided, crash-free (the big one). The old
implementation scanned forward from an image pointer, probing addresses and
reading whatever length a garbage SDS header claimed. On this target it walked
straight off the end of the read-only segment into the unmapped hole at
0x715000 and killed the server (SIGSEGV in getrangeCommand → memcpy).
Blind scanning cannot be made safe. The replacement is deterministic: