
POC estable para CVE-2026-25243 (doble liberación en Redis RESTORE -> ejecución remota de código)
Verificado contra Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Referencia: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; Exploit estable, funciona contra varias distribuciones de SO y arquitecturas.
¿Qué es? Una vulnerabilidad de corrupción de memoria en Redis que permite a un
atacante autenticado ejecutar comandos arbitrarios como el usuario de Redis. El
ataque se da en el mundo real y requiere solo un único comando RESTORE — una operación
normal de Redis, no exclusiva de administradores. Este exploit demuestra RCE completa en menos de
un segundo.
¿Impacto? Cualquier cliente Redis autenticado puede desencadenarla, y el daño es total: ejecución de código arbitrario en el proceso de Redis (a menudo ejecutándose como root en contenedores). No hay forma de mitigarla sin parchear el propio Redis.
¿Cómo funciona de un vistazo? Redis tiene una función de serialización (RESTORE)
que toma un blob de datos binarios y lo reconstruye como un objeto Redis. El
código que valida el formato del blob y el código que lo deserializa
no se ponen de acuerdo sobre cómo analizar ciertas secuencias — un bug que el atacante
explota para corromper el heap. Una vez corrompido el heap, el atacante obtiene
la capacidad de leer y escribir en cualquier dirección de memoria del proceso de Redis, y desde ahí
secuestra el estado interno del servidor para ejecutar un comando de shell.
La técnica real del exploit: Esto no es un simple fallo. Es una cadena de explotación del heap: corromper → solapar → R/W arbitrario → fuga de información → encontrar la estructura del servidor → secuestrar punteros de función → RCE. El exploit ejecuta 9 etapas y requiere filtrar múltiples direcciones en tiempo de ejecución, analizar estructuras binarias y detectar el aliasing de memoria. Lo que hace que funcione entre arquitecturas (x86-64, aarch64, etc.) es que todas las direcciones se filtran del propio objetivo, no se asumen.
CVE-2026-25243 es un par de bugs de double-free (doble liberación)
alcanzables desde un único comando RESTORE autenticado. RESTORE key ttl <serialized-value>
deserializa un blob RDB controlado por el atacante; ambos bugs viven en la brecha
entre el validador que comprueba el blob y el conversor que
lo materializa.
Bug 1 — conversión de zipmap heredado (CWE-415, la ruta que usa este exploit).
El validador de zipmap (zipmapValidateIntegrity()) y el conversor
(zipmapNext()) discrepan sobre una codificación de longitud redundante. La longitud pequeña
4 puede escribirse legalmente en la forma larga de cinco bytes FE 04 00 00 00. El
validador consume un número de bytes, el conversor otro — una desincronización de análisis
de 4 bytes. Por tanto, el conversor recorre una estructura diferente
de la que se validó, lpSafeToAdd() falla después de que el campo ya se haya
insertado en el diccionario, y la ruta de limpieza libera el campo dos veces: una
mediante dictRelease() y otra mediante sdsfree().
Bug 2 — carga de PEL de consumidores de stream (CWE-415). En
rdbLoadStreamConsumersGroup(), un PEL de consumidor que contiene un ID de entrada
duplicado hace que el segundo raxTryInsert() falle, lo que llama a streamFreeNACK() sobre un
streamNACK que todavía pertenece al PEL global del grupo. Se libera dos veces.
(Se puede seleccionar con --vuln-type stream).
Cualquiera de los dos bugs entrega al atacante un fragmento de memoria que está simultáneamente libre y referenciado — el punto de partida clásico para un exploit de solapamiento de heap.
Impacto: un cliente Redis autenticado (sin derechos de administrador, RESTORE es un
comando de datos normal) obtiene ejecución de código arbitrario como el usuario redis — root
en la imagen de contenedor predeterminada.
Nueve etapas, cada una de las cuales convierte una primitiva más débil en una más fuerte:
python3 exploit.py --host 127.0.0.1 --port 6379 \
--password mypassword --cmd 'id > /tmp/pwned123.txt'
Verificación:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Punto de partida: el exploit era solo para x86-64 y moría en la etapa 3 en el objetivo aarch64. Estado final: RCE completa en aarch64 Rocky Linux 8.10 en menos de un segundo, con 116 comandos Redis.
a) Huella digital del objetivo en tiempo de ejecución (nueva, etapa 0). Ya no se asume
nada sobre el objetivo. INFO server + INFO memory proporcionan la versión de Redis,
la arquitectura de CPU (de la línea os:), la familia de distribución (inferida de
gcc_version), el asignador y — lo más importante — tres anclas de validación:
process_id, executable y el stat_starttime exacto
(server_time_usec/1e6 - uptime_in_seconds). Las etapas posteriores comparan
contra estos valores en lugar de adivinar.
b) Disposición de memoria independiente de la arquitectura. Las cuatro constantes x86-64
codificadas (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) se sustituyen por una
tabla por arquitectura (ARCH_PROFILES) que cubre x86_64, aarch64 (VA de 39
y 48 bits), riscv64, ppc64le y s390x, con las colocaciones ET_EXEC y ET_DYN
para cada una, además de un fallback genérico amplio para cualquier cosa no listada. Esta
fue la razón real del fallo del exploit en este objetivo: el puntero filtrado
0x0000ffff8a5fdf32 es una dirección mmap aarch64 perfectamente válida que la
comprobación de rango x86-64 rechazaba.
c) Validación de fugas basada en consenso (etapa 3). En lugar de confiar en una
ventana de heap codificada, el escaneo ahora recopila todos los objetos
1337.NNNNNN estructuralmente válidos en la memview y requiere que al menos dos de ellos
deriven la misma dirección base de memview (ptr - offset_of_value). En la práctica,
502 candidatos coinciden, lo que es una prueba que ninguna tabla de rangos puede ofrecer. El
puntero confirmado calibra entonces la ventana de heap en tiempo de ejecución. La
validación de formato también se movió antes de la prueba de control de escritura (costosa en viajes de ida y vuelta).
d) Límite del escaneo de la etapa 3 (corrección de bug). El escaneo llegaba a 10 MB
codificados mientras que la memview es de 1 MB, por lo que leía más allá del final, obtenía
una respuesta vacía y abortaba con AssertionError: Empty data from memview. Ahora está
limitado por el STRLEN real de la memview, lee 256 KB por viaje de ida y vuelta
en lugar de 64 KB, y el inútil bucle de reintento con sleep de 6×1s ha desaparecido.
e) Etapa 5 reescrita: guiada por ELF, sin fallos (la grande). La
implementación anterior escaneaba hacia delante desde un puntero de imagen, probando direcciones y
leyendo la longitud que un encabezado SDS basura afirmara. En este objetivo, caminaba
directamente fuera del final del segmento de solo lectura hacia el hueco no mapeado en
0x715000 y mataba al servidor (SIGSEGV en getrangeCommand → memcpy).
El escaneo ciego no puede hacerse seguro. El reemplazo es determinista:
7f 45 4c 46 02, y sdslen() toma su byte de banderas de
ptr[-1] — así que apuntar el objeto secuestrado a base+5 hace que
e_ident[EI_CLASS]=0x02 sea el byte de banderas, es decir, SDS_TYPE_16, cuya longitud es
el uint16 en base+0 = 0x457f (0x7f45 en big-endian). Un STRLEN de
exactamente 17791 es la firma ELF. No se necesita una copia local del
binario — el encabezado se lee de la propia memoria del objetivo.PT_LOAD (manejando el sesgo de carga ET_DYN para
objetivos PIE). Cada lectura posterior se limita a un mapeo real, por lo que el fallo por
hueco no mapeado ahora es estructuralmente imposible.f) Etapa 4 reforzada. El validador Lua toma la lista completa de rangos por arquitectura
(de modo que una imagen no PIE en 0x400000 y una imagen PIE en 0xaaaa… se reconocen
ambas) y excluye la ventana de heap calibrada. Devuelve varios candidatos
en lugar de uno, por lo que una mala elección cuesta un reintento en lugar de la ejecución completa.
g) Etapa 7 autoverificable. enable_debug_cmd se localizaba mediante un
stat_starttime - 0x3c codificado. Ahora el valor esperado de stat_starttime se conoce
exactamente desde INFO (una ventana de 3 segundos en lugar de 30 días), la ventana
de lectura de la estructura creció de 4 KB a 32 KB (stat_starttime está en el offset
0x9e0, muy por detrás del límite anterior) y — de forma decisiva — cada offset candidato se
verifica con un oráculo en vivo: establecer el byte, enviar DEBUG SET-ACTIVE-EXPIRE 1,
y ver si el servidor lo acepta. Las conjeturas erróneas se restauran antes del
siguiente intento, por lo que la bandera se encuentra en cualquier build en lugar de asumirse. -0x3c todavía
se prueba primero y se confirma correcto para 8.6.2 (offset 0x9a4).
h) Las escrituras realmente llegan (etapa 5). setrangeCommand() llama a
dbUnshareStringValue(), que duplica el valor a menos que encoding == RAW && refcount == 1.
El byte de codificación ahora se pone a cero antes de la primera escritura a través del puntero
secuestrado, por lo que las escrituras alcanzan la dirección objetivo en lugar de una copia privada.
i) Payload simplificado. Se eliminó todo el mecanismo de backconnect/reverse-shell, el banner
ASCII y el ;sleep 5 añadido. El payload es exactamente
/bin/sh -c '<--cmd>' y nada más. --cmd por defecto es
id > /tmp/pwned123.txt.
j) Velocidad. La etapa 4 recopila 3 candidatos en lugar de 8; la etapa 5 sustituye ~10^5 sondas de bytes por ~40 lecturas masivas; la etapa 3 usa lecturas de 256 KB y omite los viajes de ida y vuelta para los candidatos que fallan la validación local. Cadena completa: 116 comandos, <1 s.
Resultado: uid=0(root) gid=0(root) groups=0(root) en
/tmp/pwned123.txt en el contenedor objetivo.
/bin/sh,
que requieren POSIX y el FHS.redis_version (se admiten 7.x y 8.x). Los offsets de campos de la estructura
(executable=24, exec_argv=32) se derivan del ABI LP64, y
enable_debug_cmd se descubre y verifica en tiempo de ejecución en lugar de
codificarse.Se midieron 13/13 ejecuciones exitosas en la ruta zipmap predeterminada (5 + 8 consecutivas), completando cada una en ≤1 segundo. Tres problemas surgieron solo bajo ejecución repetida y ahora están corregidos:
k) Condición de carrera con SAVE en la etapa 0. Una ejecución podía abortar con
ERR Background save already in progress cuando un guardado en segundo plano de una
ejecución anterior (o del propio redis) seguía en curso. SAVE ahora se reintenta hasta 15
segundos y, si falla, la ejecución continúa sin el punto de control en lugar de abortar.
l) Reconexión mientras el objetivo se reinicia (--connect-retries, por defecto 10).
Un intento fallido deja el heap corrompido, por lo que el FLUSHALL de la siguiente
ejecución libera los fragmentos envenenados y derriba el servidor. Se reinicia segundos
más tarde y es perfectamente explotable, por lo que la etapa 0 ahora se reconecta y
reintenta en lugar de fallar. Nuestros propios errores de validación (versión/arquitectura no
soportadas) nunca se reintentan. Esto eliminó el intermitente "stage 0 failed with an empty
error" que se veía aproximadamente en 1 de cada 3 ejecuciones durante las pruebas de estrés.
m) sizeof(streamNACK) corregido para 8.6.x. La ruta --vuln-type stream
rociaba la clase de tamaño jemalloc equivocada porque se asumía que la estructura era
de 24 o 32 bytes. En 8.6.2 es de 64 bytes (delivery_time,
delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev,
pel_next). Con el tamaño correcto, la ruta de stream ahora llega a la etapa 5 en lugar
de fallar en la etapa 2 con "key overlap not found".
--vuln-type stream no es fiable en 8.6.2. Con la corrección de tamaño, supera
el double-free, el solapamiento, la primitiva R/W y el análisis ELF, y luego
desestabiliza el espacio de claves: el servidor muere en setrangeCommand al leer
o->ptr en NULL+8, es decir, una búsqueda de clave devuelve un objeto corrupto. El
fragmento de 64 bytes que libera se comparte con otras asignaciones vivas, lo que lo hace
mucho más propenso a daños colaterales que la ruta zipmap. Use el
--vuln-type zipmap predeterminado, que es 13/13.--random-heap-massage (100k claves aleatorias primero) tiene éxito pero no
invariablemente — el heap rociado a veces coloca el fragmento doblemente liberado
donde ninguna clave marcadora aterriza. Re-ejecutar tiene éxito.| Etapa | Primitiva obtenida | Mecanismo |
|---|
| 0 | perfil del objetivo | INFO server / INFO memory → versión, arquitectura, distribución, pid, ruta del ejecutable, tiempo de inicio, asignador |
| 1 | doble liberación | RESTORE de zipmap (o stream) malformado |
| 2 | dos claves que comparten memoria | rociar claves marcadoras sobre el fragmento liberado, detectar aliasing, luego sobrescribir el encabezado SDS de una clave a través de su gemela para inflarlo a una "memview" de 1 MB |
| 3 | R/W arbitrario | encontrar un objeto INCRBYFLOAT dentro de la memview, secuestrar su campo ptr: GETRANGE/SETRANGE sobre esa clave ahora lee/escribe cualquier dirección |
| 4 | puntero de imagen | escanear el heap hacia atrás en busca de un valor dentro de la imagen de redis-server |
| 5 | &server | bajar hasta el encabezado ELF, analizar los encabezados de programa, volcar el segmento escribible, comparar server.pid |
| 6 | payload en memoria | escribir "/bin/sh", "-c", "<cmd>" más un array argv en la memview |
| 7 | estructura secuestrada | sobrescribir server.executable, server.exec_argv y server.enable_debug_cmd |
| 8 | RCE | DEBUG CRASH-AND-RECOVER → restartServer() → execve(server.executable, server.exec_argv, environ) |
.data/.bss sea legible en un puñado de viajes de ida y vuelta
en lugar de cientos de miles de sondas de bytes. Los bytes sobrescritos se guardan
y restauran.server.pid con el pid de INFO — una prueba de igualdad exacta
de 8 bytes — y luego confirmar desreferenciando server.executable y comparando
la cadena con executable de INFO. El código antiguo aceptaba una
heurística de forma suelta de siete campos; ahora la estructura se identifica positivamente.