Sploitus

Exploit for Heap-based Buffer Overflow in Redis

githubexploit Β· 2026-08-06

Exploit Code

README213 lines
## https://sploitus.com/exploit?id=B765962F-F328-5BF9-A040-ED43FB4558DA
# 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.

---

## Executive Summary

**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.

---

## 1. The vulnerability β€” in detail

CVE-2026-25243 is a pair of **double-free** bugs reachable from a single
authenticated `RESTORE` command. `RESTORE key ttl `
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.

## 2. How the exploit works

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", ""` 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)` |

### How to trigger

```bash
python3 exploit.py --host 127.0.0.1 --port 6379 \
    --password mypassword --cmd 'id > /tmp/pwned123.txt'
```

Verify:

```bash
sudo docker exec rhel-redis-target cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
```

---

## 3. Change log

### 2026-08-06 β€” portability, reliability and speed rework

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:

1. **Find the image base.** Walk down page by page from the lowest leaked
   image pointer. The probe is free: the first five bytes of *every* ELF64
   image are `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.
2. **Parse the program headers** to get the exact runtime bounds of every
   `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.
3. **Forge one SDS header** in a zeroed slot of the writable segment, which
   makes the whole of `.data`/`.bss` readable in a handful of round-trips
   instead of hundreds of thousands of byte probes. The overwritten bytes are
   saved and restored.
4. **Match `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 ''` 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, 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.
* Verified on **aarch64 / Rocky Linux 8.10 / Redis 8.6.2 (non-PIE ET_EXEC)**
  only. The x86-64 and PIE paths are implemented and arch-neutral by
  construction but have not been executed against a live target in this
  session.