
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 : 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 , 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:
7f 45 4c 46 02, and sdslen() takes its flags byte from
ptr[-1] — so pointing the hijacked object at base+5 makes
e_ident[EI_CLASS]=0x02 the flags byte, i.e. SDS_TYPE_16, whose length is
the uint16 at base+0 = 0x457f (0x7f45 big-endian). A STRLEN of
exactly 17791 is the ELF signature. No local copy of the binary is
needed — the header is read out of the target's own memory.PT_LOAD segment (handling the ET_DYN load bias for PIE targets). Every
subsequent read is clamped to a real mapping, so the unmapped-hole crash is
now structurally impossible..data/.bss readable in a handful of round-trips
instead of hundreds of thousands of byte probes. The overwritten bytes are
saved and restored.server.pid against the pid from INFO — an exact 8-byte equality
test — then confirm by dereferencing server.executable and comparing
the string to INFO's executable. The old code accepted a loose
seven-field shape heuristic; the struct is now positively identified.f) Stage 4 hardened. The Lua validator takes the full per-arch range list
(so a non-PIE image at 0x400000 and a PIE image at 0xaaaa… are both
recognised) and excludes the calibrated heap window. It returns several
candidates instead of one, so a bad pick costs a retry rather than the run.
g) Stage 7 self-verifying. enable_debug_cmd was located by a hardcoded
stat_starttime - 0x3c. Now the expected stat_starttime value is known
exactly from INFO (a 3-second window instead of 30 days), the struct read
window grew from 4 KB to 32 KB (stat_starttime sits at offset 0x9e0, well
past the old limit), and — decisively — each candidate offset is verified
with a live oracle: set the byte, send DEBUG SET-ACTIVE-EXPIRE 1, and see
whether the server accepts it. Wrong guesses are restored before the next
attempt, so the flag is found on any build rather than assumed. -0x3c is
still tried first and confirmed correct for 8.6.2 (offset 0x9a4).
h) Writes actually land (stage 5). setrangeCommand() calls
dbUnshareStringValue(), which duplicates the value unless
encoding == RAW && refcount == 1. The encoding byte is now zeroed before the
first write through the hijacked pointer, so writes reach the target address
instead of a private copy.
i) Payload simplified. All backconnect/reverse-shell machinery, the ASCII
banner and the appended ;sleep 5 were removed. The payload is exactly
/bin/sh -c '<--cmd>' and nothing else. --cmd defaults to
id > /tmp/pwned123.txt.
j) Speed. Stage 4 collects 3 candidates instead of 8; stage 5 replaces ~10^5 byte probes with ~40 bulk reads; stage 3 uses 256 KB reads and skips round-trips for candidates that fail local validation. Whole chain: 116 commands, <1 s.
Result: uid=0(root) gid=0(root) groups=0(root) in
/tmp/pwned123.txt on the target container.
/bin/sh,
which POSIX and the FHS require.redis_version (7.x and 8.x supported). Struct field offsets
(executable=24, exec_argv=32) follow from the LP64 ABI, and
enable_debug_cmd is discovered and verified at runtime rather than
hardcoded.Measured 13/13 successful runs on the default zipmap path (5 + 8 back-to-back), each completing in ≤1 second. Three issues surfaced only under repeated execution and are now fixed:
k) SAVE race in stage 0. A run could abort with
ERR Background save already in progress when a previous run's (or redis'
own) background save was still running. SAVE is now retried for up to 15
seconds and, failing that, the run proceeds without the checkpoint instead of
aborting.
l) Reconnect while the target restarts (--connect-retries, default 10).
A failed attempt leaves the heap corrupted, so the next run's FLUSHALL
frees the poisoned chunks and takes the server down. It restarts seconds later
and is perfectly exploitable, so stage 0 now reconnects and retries rather
than failing. Our own validation errors (unsupported version/architecture) are
never retried. This removed the intermittent "stage 0 failed with an empty
error" seen at roughly 1 run in 3 during stress testing.
m) sizeof(streamNACK) corrected for 8.6.x. The --vuln-type stream
path sprayed the wrong jemalloc size class because the struct was assumed to
be 24 or 32 bytes. In 8.6.2 it is 64 bytes (delivery_time,
delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev,
pel_next). With the correct size the stream path now reaches stage 5 instead
of failing at stage 2 with "key overlap not found".
--vuln-type stream is not reliable on 8.6.2. With the size fix it gets
through the double-free, the overlap, the R/W primitive and the ELF parse,
then destabilizes the keyspace: the server dies in setrangeCommand reading
o->ptr at NULL+8, i.e. a key lookup returns a corrupted object. The
64-byte chunk it frees is shared with other live allocations, which makes it
far more collateral-heavy than the zipmap path. Use the default
--vuln-type zipmap, which is 13/13.--random-heap-massage (100k random keys first) succeeds but not
invariably — the sprayed heap sometimes places the double-freed chunk
where no marker key lands. Re-running succeeds.