
Stable POC for CVE-2026-25243 (Redis RESTORE double-free -> remote code execution)
Verificado contra Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Referência: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; Exploit estável, funciona contra uma variedade de distribuições de SO e arquiteturas.
O que é isto? Uma vulnerabilidade de corrupção de memória no Redis que permite a um atacante autenticado executar comandos arbitrários como o usuário Redis. O ataque é do mundo real e requer apenas um único comando RESTORE — uma operação normal do Redis, não restrita a administradores. Este exploit demonstra RCE completo em menos de um segundo.
Impacto? Qualquer cliente Redis autenticado pode acioná-la, e o dano é total: execução arbitrária de código no processo Redis (frequentemente executando como root em contêineres). Não há como mitigar sem aplicar patch no próprio Redis.
Como funciona em resumo? O Redis tem um recurso de serialização (RESTORE) que pega um blob de dados binários e o reconstrói como um objeto Redis. O código que valida o formato do blob e o código que o desserializa discordam sobre como analisar certas sequências — uma falha que o atacante explora para corromper a heap. Uma vez que a heap é corrompida, o atacante ganha a capacidade de ler e escrever qualquer endereço de memória no processo Redis e, a partir daí, sequestra o estado interno do servidor para executar um comando shell.
A técnica real do exploit: Isto não é um simples crash. É uma cadeia de exploração de heap: corromper → sobrepor → R/W arbitrário → vazamento de informações → encontrar a struct do servidor → sequestrar ponteiros de função → RCE. O exploit executa 9 estágios e exige vazar múltiplos endereços em tempo de execução, analisar estruturas binárias e detectar aliasing de memória. O que o faz funcionar em diferentes arquiteturas (x86-64, aarch64, etc.) é que todos os endereços são vazados do próprio alvo, não presumidos.
CVE-2026-25243 é um par de bugs de double-free alcançáveis a partir de um único comando RESTORE autenticado. RESTORE key ttl <serialized-value> desserializa um blob RDB controlado pelo atacante; ambos os bugs vivem na lacuna entre o validador que verifica o blob e o conversor que o materializa.
Bug 1 — conversão zipmap legada (CWE-415, o caminho que este exploit usa). O validador zipmap (zipmapValidateIntegrity()) e o conversor (zipmapNext()) discordam sobre uma codificação de comprimento redundante. O comprimento pequeno 4 pode ser legalmente escrito na forma longa de cinco bytes FE 04 00 00 00. O validador consome um número de bytes, o conversor outro — uma dessincronização de análise de 4 bytes. O conversor portanto percorre uma estrutura diferente daquela que foi validada, lpSafeToAdd() falha depois que o campo já foi inserido no dicionário, e o caminho de limpeza libera o campo duas vezes: uma via dictRelease() e outra via sdsfree().
Bug 2 — carregamento do PEL do consumidor de stream (CWE-415). Em rdbLoadStreamConsumersGroup(), um PEL de consumidor contendo um ID de entrada duplicado faz a segunda chamada raxTryInsert() falhar, o que chama streamFreeNACK() em um streamNACK que ainda é de propriedade do PEL global do grupo. Liberado duas vezes. (Selecionável com --vuln-type stream.)
Qualquer um dos bugs entrega ao atacante um pedaço de memória que está simultaneamente livre e referenciado — o ponto de partida clássico para um exploit de sobreposição de heap.
Impacto: um cliente Redis autenticado (sem direitos de administrador, RESTORE é um comando de dados normal) obtém execução arbitrária de código como o usuário redis — root na imagem de contêiner padrão.
Nove estágios, cada um dos quais transforma uma primitiva mais fraca em uma mais forte:
python3 exploit.py --host 127.0.0.1 --port 6379 \
--password mypassword --cmd 'id > /tmp/pwned123.txt'
Verifique:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Ponto de partida: o exploit era exclusivo para x86-64 e morria no estágio 3 no alvo aarch64. Estado final: RCE completo em aarch64 Rocky Linux 8.10 em menos de um segundo, 116 comandos Redis.
a) Identificação de impressão digital do alvo em tempo de execução (novo, estágio 0). Nada sobre o alvo é mais presumido. INFO server + INFO memory fornecem a versão do Redis, a arquitetura da CPU (da linha os:), a família da distribuição (inferida de gcc_version), o alocador e — o mais importante — três âncoras de validação: process_id, executable e o stat_starttime exato (server_time_usec/1e6 - uptime_in_seconds). Estágios posteriores comparam com essas em vez de adivinhar.
b) Layout de memória independente de arquitetura. As quatro constantes x86-64 fixas (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) são substituídas por uma tabela por arquitetura (ARCH_PROFILES) que cobre x86_64, aarch64 (VA de 39 e 48 bits), riscv64, ppc64le e s390x, com ambas as disposições ET_EXEC e ET_DYN para cada uma, além de um amplo fallback genérico para qualquer item não listado. Esse foi o motivo real da falha do exploit neste alvo: o ponteiro vazado 0x0000ffff8a5fdf32 é um endereço mmap aarch64 perfeitamente válido que a verificação de faixa do x86-64 rejeitou.
c) Validação de vazamento baseada em consenso (estágio 3). Em vez de confiar em uma janela de heap fixa, a varredura agora coleta todos os objetos estruturalmente válidos 1337.NNNNNN na memview e exige que pelo menos dois deles derivem o mesmo endereço base da memview (ptr - offset_of_value). Na prática, 502 candidatos concordam, o que é uma prova que nenhuma tabela de faixas pode oferecer. O ponteiro confirmado então calibra a janela de heap em tempo de execução. A validação de formato também foi movida para antes do teste de controle de escrita (caro em round-trips).
d) Limite da varredura do estágio 3 (correção de bug). A varredura ia até os 10 MB fixos enquanto a memview tem 1 MB, então lia além do final, obtinha uma resposta vazia e abortava com AssertionError: Empty data from memview. Agora ela é limitada pelo STRLEN real da memview, lê 256 KB por round-trip em vez de 64 KB, e o inútil loop de retry-sleep de 6×1s foi removido.
e) Estágio 5 reescrito: guiado por ELF, sem crash (o grande). A implementação antiga escaneava para frente a partir de um ponteiro de imagem, sondando endereços e lendo qualquer comprimento que um cabeçalho SDS de lixo afirmasse. Neste alvo, ela ultrapassava o final do segmento somente leitura até o buraco não mapeado em 0x715000 e matava o servidor (SIGSEGV em getrangeCommand → memcpy). A varredura cega não pode ser tornada segura. A substituição é determinística:
7f 45 4c 46 02, e sdslen() pega seu byte de flags de ptr[-1] — então apontar o objeto sequestrado para base+5 faz de e_ident[EI_CLASS]=0x02 o byte de flags, ou seja, SDS_TYPE_16, cujo comprimento é o uint16 em base+0 = 0x457f (0x7f45 big-endian). Um STRLEN de exatamente 17791 é a assinatura ELF. Nenhuma cópia local do binário é necessária — o cabeçalho é lido da própria memória do alvo.PT_LOAD (tratando o viés de carga ET_DYN para alvos PIE). Cada leitura subsequente é limitada a um mapeamento real, então o crash do buraco não mapeado agora é estruturalmente impossível.f) Estágio 4 endurecido. O validador Lua recebe a lista completa de faixas por arquitetura (então uma imagem não-PIE em 0x400000 e uma imagem PIE em 0xaaaa… são ambas reconhecidas) e exclui a janela de heap calibrada. Ele retorna vários candidatos em vez de um, então uma escolha ruim custa uma nova tentativa em vez da execução.
g) Estágio 7 autoverificável. enable_debug_cmd era localizado por um stat_starttime - 0x3c fixo. Agora o valor esperado de stat_starttime é conhecido exatamente a partir do INFO (uma janela de 3 segundos em vez de 30 dias), a janela de leitura da struct cresceu de 4 KB para 32 KB (stat_starttime está no offset 0x9e0, bem além do limite antigo) e — de forma decisiva — cada offset candidato é verificado com um oráculo ao vivo: defina o byte, envie DEBUG SET-ACTIVE-EXPIRE 1 e veja se o servidor aceita. Suposições erradas são restauradas antes da próxima tentativa, então a flag é encontrada em qualquer build em vez de presumida. -0x3c ainda é tentado primeiro e confirmado como correto para 8.6.2 (offset 0x9a4).
h) As escritas realmente chegam ao destino (estágio 5). setrangeCommand() chama dbUnshareStringValue(), que duplica o valor a menos que encoding == RAW && refcount == 1. O byte de encoding agora é zerado antes da primeira escrita através do ponteiro sequestrado, então as escritas alcançam o endereço de destino em vez de uma cópia privada.
i) Payload simplificado. Toda a maquinaria de backconnect/reverse-shell, o banner ASCII e o ;sleep 5 anexado foram removidos. O payload é exatamente /bin/sh -c '<--cmd>' e nada mais. --cmd tem como padrão id > /tmp/pwned123.txt.
j) Velocidade. O estágio 4 coleta 3 candidatos em vez de 8; o estágio 5 substitui ~10^5 sondas de byte por ~40 leituras em massa; o estágio 3 usa leituras de 256 KB e pula round-trips para candidatos que falham na validação local. Cadeia inteira: 116 comandos, <1 s.
Resultado: uid=0(root) gid=0(root) groups=0(root) em /tmp/pwned123.txt no contêiner alvo.
/bin/sh, que POSIX e FHS exigem.redis_version (7.x e 8.x suportados). Os offsets de campos da struct (executable=24, exec_argv=32) decorrem da ABI LP64, e enable_debug_cmd é descoberto e verificado em tempo de execução em vez de fixo no código.Medidos 13/13 execuções bem-sucedidas no caminho zipmap padrão (5 + 8 seguidas), cada uma concluindo em ≤1 segundo. Três problemas surgiram apenas sob execução repetida e agora estão corrigidos:
k) Corrida de SAVE no estágio 0. Uma execução podia abortar com ERR Background save already in progress quando um save em segundo plano de uma execução anterior (ou do próprio redis) ainda estava em andamento. SAVE agora é tentado novamente por até 15 segundos e, se falhar, a execução prossegue sem o checkpoint em vez de abortar.
l) Reconectar enquanto o alvo reinicia (--connect-retries, padrão 10). Uma tentativa fracassada deixa a heap corrompida, então o FLUSHALL da próxima execução libera os blocos envenenados e derruba o servidor. Ele reinicia segundos depois e é perfeitamente explorável, então o estágio 0 agora reconecta e tenta novamente em vez de falhar. Nossos próprios erros de validação (versão/arquitetura não suportada) nunca são repetidos. Isso removeu o intermitente "stage 0 failed with an empty error" visto em cerca de 1 em 3 execuções durante os testes de estresse.
m) sizeof(streamNACK) corrigido para 8.6.x. O caminho --vuln-type stream pulverizava a classe de tamanho errada do jemalloc porque a struct era presumida como tendo 24 ou 32 bytes. Em 8.6.2 ela tem 64 bytes (delivery_time, delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev, pel_next). Com o tamanho correto, o caminho de stream agora alcança o estágio 5 em vez de falhar no estágio 2 com "key overlap not found".
--vuln-type stream não é confiável no 8.6.2. Com a correção de tamanho, ele passa pelo double-free, pela sobreposição, pela primitiva R/W e pela análise ELF, então desestabiliza o keyspace: o servidor morre em setrangeCommand lendo o->ptr em NULL+8, ou seja, uma busca de chave retorna um objeto corrompido. O bloco de 64 bytes que ele libera é compartilhado com outras alocações vivas, o que o torna muito mais pesado em danos colaterais do que o caminho zipmap. Use o padrão --vuln-type zipmap, que está 13/13.--random-heap-massage (100k chaves aleatórias primeiro) tem sucesso, mas não invariavelmente — a heap pulverizada às vezes coloca o bloco duplamente liberado onde nenhuma chave marcadora cai. Executar novamente tem sucesso.| Estágio | Primitiva obtida | Mecanismo |
|---|
| 0 | perfil do alvo | INFO server / INFO memory → versão, arquitetura, distribuição, pid, caminho do executável, hora de início, alocador |
| 1 | double free | zipmap malformado (ou stream) RESTORE |
| 2 | duas chaves compartilhando memória | espalhar chaves marcadoras sobre o pedaço liberado, detectar aliasing, então sobrescrever o cabeçalho SDS de uma chave através de sua gêmea para inflá-lo para uma "memview" de 1 MB |
| 3 | R/W arbitrário | encontrar um objeto INCRBYFLOAT dentro da memview, sequestrar seu campo ptr: GETRANGE/SETRANGE nessa chave agora lê/escreve qualquer endereço |
| 4 | ponteiro de imagem | escanear a heap de trás para frente em busca de um valor dentro da imagem do redis-server |
| 5 | &server | descer até o cabeçalho ELF, analisar os program headers, despejar o segmento gravável, corresponder a server.pid |
| 6 | payload na memória | escrever "/bin/sh", "-c", "<cmd>" mais um array argv na memview |
| 7 | struct sequestrada | sobrescrever server.executable, server.exec_argv e server.enable_debug_cmd |
| 8 | RCE | DEBUG CRASH-AND-RECOVER → restartServer() → execve(server.executable, server.exec_argv, environ) |
.data/.bss legível em um punhado de round-trips em vez de centenas de milhares de sondas de byte. Os bytes sobrescritos são salvos e restaurados.server.pid ao pid do INFO — um teste de igualdade exato de 8 bytes — e então confirmar desreferenciando server.executable e comparando a string com o executable do INFO. O código antigo aceitava uma heurística frouxa de formato de sete campos; a struct agora é positivamente identificada.