
POC stable pour CVE-2026-25243 (double-free RESTORE Redis -> exécution de code à distance)
Vérifié sur Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Référence : https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR ; Exploit stable, fonctionne avec une grande variété de distributions et d'architectures OS.
Qu'est-ce que c'est ? Une vulnérabilité de corruption mémoire dans Redis qui permet à un attaquant authentifié d'exécuter des commandes arbitraires en tant qu'utilisateur Redis. L'attaque est réelle et ne nécessite qu'une seule commande RESTORE — une opération Redis normale, pas réservée à l'admin. Cet exploit démontre une RCE complète en moins d'une seconde.
Impact ? N'importe quel client Redis authentifié peut la déclencher, et les dégâts sont totaux : exécution de code arbitraire dans le processus Redis (souvent exécuté en root dans les conteneurs). Il n'existe aucun moyen d'atténuation sans corriger Redis lui-même.
Comment ça fonctionne en un coup d'œil ? Redis dispose d'une fonctionnalité de sérialisation (RESTORE) qui prend un blob de données binaires et le reconstruit en tant qu'objet Redis. Le code qui valide le format du blob et le code qui le désérialise ne sont pas d'accord sur la façon d'analyser certaines séquences — un bug que l'attaquant exploite pour corrompre le tas (heap). Une fois le tas corrompu, l'attaquant obtient la capacité de lire et d'écrire n'importe quelle adresse mémoire dans le processus Redis, puis détourne l'état interne du serveur pour exécuter une commande shell.
La véritable technique de l'exploit : Il ne s'agit pas d'un simple crash. C'est une chaîne d'exploitation du tas : corruption → chevauchement → R/W arbitraire → fuite d'informations → localisation de la structure du serveur → détournement des pointeurs de fonction → RCE. L'exploit s'exécute en 9 étapes et nécessite de fuiter plusieurs adresses à l'exécution, d'analyser des structures binaires et de détecter l'aliasing mémoire. Ce qui le fait fonctionner sur plusieurs architectures (x86-64, aarch64, etc.), c'est que toutes les adresses sont fuitées depuis la cible elle-même, et non supposées.
CVE-2026-25243 est une paire de bugs de double-free exploitables depuis une seule commande RESTORE authentifiée. RESTORE key ttl <serialized-value> désérialise un blob RDB contrôlé par l'attaquant ; les deux bugs se situent dans l'écart entre le validateur qui vérifie le blob et le convertisseur qui le matérialise.
Bug 1 — conversion zipmap legacy (CWE-415, le chemin utilisé par cet exploit).
Le validateur zipmap (zipmapValidateIntegrity()) et le convertisseur (zipmapNext()) ne sont pas d'accord sur un encodage de longueur redondant. La petite longueur 4 peut légalement être écrite sous la forme longue de cinq octets FE 04 00 00 00. Le validateur consomme un certain nombre d'octets, le convertisseur un autre — une désynchronisation d'analyse de 4 octets. Le convertisseur parcourt donc une structure différente de celle qui a été validée, lpSafeToAdd() échoue après que le champ a déjà été inséré dans le dictionnaire, et le chemin de nettoyage libère le champ deux fois : une fois via dictRelease() puis à nouveau via sdsfree().
Bug 2 — chargement du PEL des consommateurs de stream (CWE-415). Dans rdbLoadStreamConsumersGroup(), un PEL de consommateur contenant un ID d'entrée en double fait échouer le second raxTryInsert(), qui appelle streamFreeNACK() sur un streamNACK toujours possédé par le PEL global du groupe. Libéré deux fois. (Sélectionnable avec --vuln-type stream.)
Chacun de ces bugs offre à l'attaquant un bloc de mémoire à la fois libre et référencé — le point de départ classique d'un exploit par chevauchement de tas.
Impact : un client Redis authentifié (sans droits admin, RESTORE est une commande de données normale) obtient l'exécution de code arbitraire en tant qu'utilisateur redis — root dans l'image de conteneur par défaut.
Neuf étapes, chacune transformant une primitive plus faible en une primitive plus forte :
| Étape | Primitive obtenue | Mécanisme |
|---|---|---|
| 0 | profil de la cible | INFO server / INFO memory → version, arch, distribution, pid, chemin de l'exécutable, heure de démarrage, allocateur |
| 1 | double-free | RESTORE zipmap (ou stream) malformé |
| 2 | deux clés partageant la mémoire | pulvériser des clés marqueurs sur le bloc libéré, détecter l'aliasing, puis écraser l'en-tête SDS d'une clé via son jumeau pour la gonfler en « memview » de 1 Mo |
| 3 | R/W arbitraire | trouver un objet INCRBYFLOAT dans la memview, détourner son champ ptr : GETRANGE/SETRANGE sur cette clé lit/écrit désormais n'importe quelle adresse |
| 4 | pointeur d'image | scanner le tas à rebours pour trouver une valeur dans l'image redis-server |
| 5 | &server | descendre jusqu'à l'en-tête ELF, analyser les en-têtes de programme, extraire le segment inscriptible, faire correspondre server.pid |
| 6 | payload en mémoire | écrire "/bin/sh", "-c", "<cmd>" plus un tableau argv dans la memview |
| 7 | structure détournée | écraser server.executable, server.exec_argv et 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'
Vérification :
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Point de départ : l'exploit était exclusivement x86-64 et échouait à l'étape 3 sur la cible aarch64. État final : RCE complète sur aarch64 Rocky Linux 8.10 en moins d'une seconde, 116 commandes Redis.
a) Empreinte de la cible à l'exécution (nouveau, étape 0). Plus rien n'est supposé sur la cible. INFO server + INFO memory fournissent la version de Redis, l'architecture CPU (à partir de la ligne os:), la famille de distribution (déduite de gcc_version), l'allocateur et — surtout — trois ancres de validation : process_id, executable et le stat_starttime exact (server_time_usec/1e6 - uptime_in_seconds). Les étapes suivantes comparent leurs résultats à ces valeurs au lieu de deviner.
b) Disposition mémoire indépendante de l'architecture. Les quatre constantes x86-64 codées en dur (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) sont remplacées par un tableau par architecture (ARCH_PROFILES) couvrant x86_64, aarch64 (VA de 39 et 48 bits), riscv64, ppc64le et s390x, avec à la fois les emplacements ET_EXEC et ET_DYN pour chacune, plus un large repli générique pour tout ce qui n'est pas listé. C'était la raison réelle de l'échec de l'exploit sur cette cible : le pointeur fuité 0x0000ffff8a5fdf32 est une adresse mmap aarch64 parfaitement valide que la vérification de plage x86-64 rejetait.
c) Validation des fuites par consensus (étape 3). Plutôt que de se fier à une fenêtre de tas codée en dur, l'analyse collecte désormais tous les objets structurellement valides 1337.NNNNNN dans la memview et exige qu'au moins deux d'entre eux aboutissent à la même adresse de base de la memview (ptr - offset_of_value). En pratique, 502 candidats concordent, une preuve qu'aucun tableau de plages ne peut offrir. Le pointeur confirmé calibre ensuite la fenêtre de tas à l'exécution. La validation du format a également été déplacée avant le test de contrôle d'écriture (coûteux en allers-retours).
d) Limite d'analyse de l'étape 3 (correction de bug). L'analyse s'exécutait jusqu'à 10 Mo codés en dur alors que la memview fait 1 Mo : elle lisait donc au-delà de la fin, recevait une réponse vide et s'interrompait avec AssertionError: Empty data from memview. Elle est désormais bornée par le vrai STRLEN de la memview, lit 256 Ko par aller-retour au lieu de 64 Ko, et la boucle inutile de 6×1 s de sommeil avec nouvelle tentative a disparu.
e) Étape 5 réécrite : guidée par ELF, sans crash (la grosse modification). L'ancienne implémentation analysait vers l'avant depuis un pointeur d'image, sondant des adresses et lisant la longueur annoncée par un en-tête SDS poubelle. Sur cette cible, elle sortait directement de l'extrémité du segment en lecture seule dans le trou non mappé à 0x715000 et tuait le serveur (SIGSEGV dans getrangeCommand → memcpy). Une analyse aveugle ne peut pas être rendue sûre. Le remplacement est déterministe :
7f 45 4c 46 02, et sdslen() prend son octet de flags dans ptr[-1] — donc en pointant l'objet détourné vers base+5, e_ident[EI_CLASS]=0x02 devient l'octet de flags, c'est-à-dire SDS_TYPE_16, dont la longueur est le uint16 à base+0 = 0x457f (0x7f45 en big-endian). Un STRLEN d'exactement 17791 est la signature ELF. Aucune copie locale du binaire n'est nécessaire — l'en-tête est lu dans la mémoire de la cible elle-même.PT_LOAD (en gérant le biais de chargement ET_DYN pour les cibles PIE). Chaque lecture ultérieure est bornée à un mappage réel, donc le crash par trou non mappé est désormais structurellement impossible..data/.bss lisible en une poignée d'allers-retours au lieu de centaines de milliers de sondages d'octets. Les octets écrasés sont sauvegardés puis restaurés.server.pid avec le pid d'INFO — un test d'égalité exact sur 8 octets — puis confirmer en déréférençant server.executable et en comparant la chaîne au champ executable d'INFO. L'ancien code acceptait une heuristique de forme souple à sept champs ; la structure est désormais identifiée de manière positive.f) Étape 4 renforcée. Le validateur Lua prend la liste complète des plages par architecture (ainsi une image non-PIE à 0x400000 et une image PIE à 0xaaaa… sont toutes deux reconnues) et exclut la fenêtre de tas calibrée. Il renvoie plusieurs candidats au lieu d'un seul, donc un mauvais choix coûte une nouvelle tentative plutôt que l'ensemble de l'exécution.
g) Étape 7 auto-vérifiante. enable_debug_cmd était localisé via un stat_starttime - 0x3c codé en dur. Désormais, la valeur attendue de stat_starttime est connue exactement depuis INFO (une fenêtre de 3 secondes au lieu de 30 jours), la fenêtre de lecture de la structure est passée de 4 Ko à 32 Ko (stat_starttime se trouve à l'offset 0x9e0, bien au-delà de l'ancienne limite) et — de manière décisive — chaque offset candidat est vérifié avec un oracle vivant : définir l'octet, envoyer DEBUG SET-ACTIVE-EXPIRE 1, et voir si le serveur l'accepte. Les mauvaises suppositions sont restaurées avant la tentative suivante, donc le flag est trouvé sur n'importe quelle compilation plutôt que supposé. -0x3c est toujours essayé en premier et confirmé correct pour 8.6.2 (offset 0x9a4).
h) Les écritures aboutissent réellement (étape 5). setrangeCommand() appelle dbUnshareStringValue(), qui duplique la valeur sauf si encoding == RAW && refcount == 1. L'octet d'encodage est désormais mis à zéro avant la première écriture via le pointeur détourné, donc les écritures atteignent l'adresse cible au lieu d'une copie privée.
i) Payload simplifié. Toute la mécanique de backconnect/shell inversée, la bannière ASCII et le ;sleep 5 ajouté ont été supprimés. Le payload est exactement /bin/sh -c '<--cmd>' et rien d'autre. --cmd a pour valeur par défaut id > /tmp/pwned123.txt.
j) Vitesse. L'étape 4 collecte 3 candidats au lieu de 8 ; l'étape 5 remplace ~10^5 sondages d'octets par ~40 lectures en bloc ; l'étape 3 utilise des lectures de 256 Ko et ignore les allers-retours pour les candidats qui échouent à la validation locale. Chaîne complète : 116 commandes, <1 s.
Résultat : uid=0(root) gid=0(root) groups=0(root) dans /tmp/pwned123.txt sur le conteneur cible.
/bin/sh, exigée par POSIX et le FHS.redis_version (7.x et 8.x pris en charge). Les offsets des champs de la structure (executable=24, exec_argv=32) découlent de l'ABI LP64, et enable_debug_cmd est découvert et vérifié à l'exécution plutôt que codé en dur.Mesuré : 13/13 exécutions réussies sur le chemin zipmap par défaut (5 + 8 consécutives), chacune se terminant en ≤1 seconde. Trois problèmes ne sont apparus qu'en exécution répétée et sont désormais corrigés :
k) Course SAVE à l'étape 0. Une exécution pouvait s'interrompre avec ERR Background save already in progress lorsqu'une sauvegarde en arrière-plan d'une exécution précédente (ou de redis lui-même) était encore en cours. SAVE est désormais réessayée jusqu'à 15 secondes et, en cas d'échec, l'exécution se poursuit sans le point de contrôle au lieu de s'interrompre.
l) Reconnexion pendant le redémarrage de la cible (--connect-retries, défaut 10). Une tentative échouée laisse le tas corrompu, donc le FLUSHALL de l'exécution suivante libère les blocs empoisonnés et fait tomber le serveur. Il redémarre quelques secondes plus tard et reste parfaitement exploitable, donc l'étape 0 se reconnecte et réessaie désormais au lieu d'échouer. Nos propres erreurs de validation (version/architecture non prise en charge) ne sont jamais réessayées. Cela a éliminé l'échec intermittent « stage 0 failed with an empty error » observé environ 1 fois sur 3 lors des tests de stress.
m) sizeof(streamNACK) corrigé pour 8.6.x. Le chemin --vuln-type stream pulvérisait la mauvaise classe de taille jemalloc car la structure était supposée faire 24 ou 32 octets. En 8.6.2, elle fait 64 octets (delivery_time, delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev, pel_next). Avec la bonne taille, le chemin stream atteint désormais l'étape 5 au lieu d'échouer à l'étape 2 avec « key overlap not found ».
--vuln-type stream n'est pas fiable sur 8.6.2. Avec la correction de taille, il traverse le double-free, le chevauchement, la primitive R/W et l'analyse ELF, puis déstabilise l'espace de clés : le serveur meurt dans setrangeCommand en lisant o->ptr à NULL+8, c'est-à-dire qu'une recherche de clé renvoie un objet corrompu. Le bloc de 64 octets qu'il libère est partagé avec d'autres allocations vivantes, ce qui le rend beaucoup plus lourd en dégâts collatéraux que le chemin zipmap. Utilisez le --vuln-type zipmap par défaut, qui est 13/13.--random-heap-massage (100 000 clés aléatoires d'abord) réussit mais pas invariablement — le tas pulvérisé place parfois le bloc doublement libéré là où aucune clé marqueur n'atterrit. Relancer l'exploit réussit.