
Stable POC für CVE-2026-25243 (Redis RESTORE double-free -> Remote Code Execution)
Verifiziert gegen Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Referenz: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; Stabiler Exploit, funktioniert gegen eine Vielzahl von OS-Distributionen und Architekturen.
Was ist das? Eine Speicherkorruptions-Schwachstelle in Redis, die es einem authentifizierten Angreifer ermöglicht, beliebige Befehle als Redis-Benutzer auszuführen. Der Angriff ist real existent und erfordert nur einen einzigen RESTORE-Befehl – eine normale Redis-Operation, nicht nur für Administratoren. Dieser Exploit demonstriert vollständige RCE in unter einer Sekunde.
Auswirkungen? Jeder authentifizierte Redis-Client kann ihn auslösen, und der Schaden ist total: beliebige Codeausführung im Redis-Prozess (der in Containern oft als root läuft). Es gibt keine Möglichkeit der Abschwächung, ohne Redis selbst zu patchen.
Wie funktioniert es auf einen Blick? Redis verfügt über eine Serialisierungsfunktion (RESTORE), die einen Binärdaten-Blob nimmt und ihn als Redis-Objekt rekonstruiert. Der Code, der das Format des Blobs validiert, und der Code, der es deserialisiert, sind sich uneinig, wie bestimmte Sequenzen zu parsen sind – ein Fehler, den der Angreifer ausnutzt, um den Heap zu korrumpieren. Sobald der Heap korrumpiert ist, erlangt der Angreifer die Fähigkeit, beliebige Speicheradressen im Redis-Prozess zu lesen und zu schreiben, und kapert von dort den internen Zustand des Servers, um einen Shell-Befehl auszuführen.
Die eigentliche Exploit-Technik: Dies ist kein einfacher Absturz. Es ist eine Heap-Exploitation-Kette: korrumpieren → überlappen → beliebiges R/W → Informationsleck → server-Struktur finden → Funktionszeiger kapern → RCE. Der Exploit läuft in 9 Stufen ab und erfordert das Leaken mehrerer Adressen zur Laufzeit, das Parsen binärer Strukturen und das Erkennen von Speicher-Aliasing. Was ihn über Architekturen hinweg (x86-64, aarch64 usw.) funktionieren lässt, ist, dass alle Adressen vom Ziel selbst geleakt werden, nicht angenommen.
CVE-2026-25243 ist ein Paar von Double-Free-Fehlern, die über einen einzigen authentifizierten RESTORE-Befehl erreichbar sind. RESTORE key ttl <serialized-value> deserialisiert einen vom Angreifer kontrollierten RDB-Blob; beide Fehler liegen in der Lücke zwischen dem Validator, der den Blob prüft, und dem Konverter, der ihn materialisiert.
Fehler 1 — Legacy-Zipmap-Konvertierung (CWE-415, der Pfad, den dieser Exploit nutzt). Der Zipmap-Validator (zipmapValidateIntegrity()) und der Konverter (zipmapNext()) sind sich uneinig über eine redundante Längenkodierung. Die kleine Länge 4 kann legal in der langen Fünf-Byte-Form FE 04 00 00 00 geschrieben werden. Der Validator verbraucht eine Anzahl von Bytes, der Konverter eine andere — eine 4-Byte-Parsing-Desynchronisation. Der Konverter durchläuft daher eine andere Struktur als die validierte, lpSafeToAdd() schlägt fehl, nachdem das Feld bereits in das Wörterbuch eingefügt wurde, und der Bereinigungspfad gibt das Feld zweimal frei: einmal über dictRelease() und erneut über sdsfree().
Fehler 2 — Laden der Stream-Consumer-PEL (CWE-415). In rdbLoadStreamConsumersGroup() führt eine Consumer-PEL mit einer doppelten Eintrags-ID dazu, dass der zweite raxTryInsert()-Aufruf fehlschlägt, was streamFreeNACK() auf eine streamNACK aufruft, die sich noch im Besitz der globalen PEL der Gruppe befindet. Zweimal freigegeben. (Auswählbar mit --vuln-type stream.)
Jeder der beiden Fehler verschafft dem Angreifer einen Speicherblock, der gleichzeitig frei und referenziert ist — der klassische Ausgangspunkt für einen Heap-Overlap-Exploit.
Auswirkung: Ein authentifizierter Redis-Client (keine Admin-Rechte, RESTORE ist ein normaler Datenbefehl) erlangt beliebige Codeausführung als Redis-Benutzer — root im Standard-Container-Image.
Neun Stufen, von denen jede ein schwächeres Primitive in ein stärkeres verwandelt:
| Stufe | Erlangtes Primitive | Mechanismus |
|---|---|---|
| 0 | Zielprofil | INFO server / INFO memory → Version, Architektur, Distribution, pid, Pfad der ausführbaren Datei, Startzeit, Allokator |
| 1 | Double-Free | fehlerhafte Zipmap (oder Stream) RESTORE |
| 2 | zwei Schlüssel, die sich Speicher teilen | Marker-Keys auf den freigegebenen Block sprühen, Aliasing erkennen, dann den SDS-Header eines Schlüssels über seinen Zwilling überschreiben, um ihn auf ein 1-MB-„memview" aufzublasen |
| 3 | beliebiges R/W | ein INCRBYFLOAT-Objekt im memview finden, dessen ptr-Feld kapern: GETRANGE/SETRANGE auf diesen Schlüssel liest/schreibt nun beliebige Adressen |
| 4 | Image-Pointer | Heap rückwärts nach einem Wert im redis-server-Image durchsuchen |
| 5 | &server | zum ELF-Header hinuntergehen, Programm-Header parsen, das beschreibbare Segment ausgeben, server.pid abgleichen |
| 6 | Payload im Speicher | "/bin/sh", "-c", "<cmd>" plus ein argv-Array in das memview schreiben |
| 7 | gekapertes Struct | server.executable, server.exec_argv und server.enable_debug_cmd überschreiben |
| 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'
Verifizieren:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Ausgangspunkt: Der Exploit war nur für x86-64 und scheiterte in Stufe 3 auf dem aarch64-Ziel. Endzustand: vollständige RCE auf aarch64 Rocky Linux 8.10 in unter einer Sekunde, 116 Redis-Befehle.
a) Laufzeit-Fingerprinting des Ziels (neu, Stufe 0). Über das Ziel wird nichts mehr angenommen. INFO server + INFO memory ergeben die Redis-Version, CPU-Architektur (aus der Zeile os:), Distributionsfamilie (abgeleitet aus gcc_version), Allokator und — am wichtigsten — drei Validierungsanker: process_id, executable und die exakte stat_starttime (server_time_usec/1e6 - uptime_in_seconds). Spätere Stufen vergleichen mit diesen Werten, statt zu raten.
b) Architekturunabhängiges Speicherlayout. Die vier fest kodierten x86-64-Konstanten (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) werden durch eine pro-Architektur-Tabelle (ARCH_PROFILES) ersetzt, die x86_64, aarch64 (sowohl 39- als auch 48-Bit-VA), riscv64, ppc64le und s390x abdeckt, jeweils mit ET_EXEC- und ET_DYN-Platzierungen, plus einem breiten generischen Fallback für alles nicht Aufgeführte. Das war der eigentliche Grund, warum der Exploit auf diesem Ziel scheiterte: Der geleakte Pointer 0x0000ffff8a5fdf32 ist eine völlig gültige aarch64-mmap-Adresse, die die x86-64-Bereichsprüfung zurückwies.
c) Konsensbasierte Leak-Validierung (Stufe 3). Statt sich auf ein hart kodiertes Heap-Fenster zu verlassen, sammelt der Scan nun jedes strukturell gültige 1337.NNNNNN-Objekt im memview und verlangt, dass mindestens zwei davon dieselbe memview-Basisadresse ableiten (ptr - offset_of_value). In der Praxis stimmen 502 Kandidaten überein – ein Beweis, den keine Bereichstabelle liefern kann. Der bestätigte Pointer kalibriert dann das Heap-Fenster zur Laufzeit. Die Formatvalidierung wurde außerdem vor den (round-trip-teuren) Schreibkontrolltest verschoben.
d) Scan-Grenze in Stufe 3 (Fehlerbehebung). Der Scan lief bis zu einer fest kodierten 10 MB, während das memview 1 MB groß ist. Dadurch las er über das Ende hinaus, erhielt eine leere Antwort und brach mit AssertionError: Empty data from memview ab. Jetzt wird er durch die echte STRLEN des memview begrenzt, liest 256 KB pro Round-Trip statt 64 KB, und die sinnlose 6×1s-Wiederholungsschleife ist entfernt.
e) Stufe 5 neu geschrieben: ELF-geführt, absturzfrei (das große Ding). Die alte Implementierung scannte von einem Image-Pointer aus vorwärts, testete Adressen und las die Länge, die ein kaputter SDS-Header behauptete. Auf diesem Ziel lief sie direkt vom Ende des schreibgeschützten Segments in das nicht gemappte Loch bei 0x715000 und tötete den Server (SIGSEGV in getrangeCommand → memcpy). Blindes Scannen kann nicht sicher gemacht werden. Der Ersatz ist deterministisch:
7f 45 4c 46 02, und sdslen() nimmt sein Flags-Byte von ptr[-1] — wenn man also das gekaperte Objekt auf base+5 zeigt, wird e_ident[EI_CLASS]=0x02 zum Flags-Byte, d.h. SDS_TYPE_16, dessen Länge der uint16 bei base+0 = 0x457f ist (0x7f45 Big-Endian). Eine STRLEN von exakt 17791 ist die ELF-Signatur. Es wird keine lokale Kopie des Binärs benötigt — der Header wird aus dem eigenen Speicher des Ziels gelesen.PT_LOAD-Segments zu erhalten (unter Berücksichtigung des ET_DYN-Load-Bias für PIE-Ziele). Jedes nachfolgende Lesen wird auf ein reales Mapping begrenzt, sodass der Absturz durch das nicht gemappte Loch jetzt strukturell unmöglich ist..data/.bss in einer Handvoll Round-Trips lesbar wird, statt Hunderttausende von Byte-Sonden. Die überschriebenen Bytes werden gespeichert und wiederhergestellt.server.pid mit der pid aus INFO abgleichen — ein exakter 8-Byte-Gleichheitstest — dann durch Dereferenzieren von server.executable bestätigen und die Zeichenkette mit dem executable aus INFO vergleichen. Der alte Code akzeptierte eine lose Sieben-Feld-Formheuristik; das Struct wird jetzt positiv identifiziert.f) Stufe 4 gehärtet. Der Lua-Validator übernimmt die vollständige pro-Architektur-Bereichsliste (so werden sowohl ein Nicht-PIE-Image bei 0x400000 als auch ein PIE-Image bei 0xaaaa… erkannt) und schließt das kalibrierte Heap-Fenster aus. Er liefert mehrere Kandidaten statt einem, sodass eine schlechte Wahl einen erneuten Versuch kostet statt den gesamten Lauf.
g) Stufe 7 selbstverifizierend. enable_debug_cmd wurde bisher über eine fest kodierte stat_starttime - 0x3c gefunden. Jetzt ist der erwartete stat_starttime-Wert exakt aus INFO bekannt (ein 3-Sekunden-Fenster statt 30 Tagen), das Struct-Lesenster wuchs von 4 KB auf 32 KB (stat_starttime liegt bei Offset 0x9e0, weit hinter dem alten Limit), und — entscheidend — jeder Kandidaten-Offset wird mit einem Live-Orakel verifiziert: Byte setzen, DEBUG SET-ACTIVE-EXPIRE 1 senden und prüfen, ob der Server es akzeptiert. Falsche Vermutungen werden vor dem nächsten Versuch wiederhergestellt, sodass das Flag auf jedem Build gefunden statt angenommen wird. -0x3c wird weiterhin zuerst versucht und ist für 8.6.2 (Offset 0x9a4) als korrekt bestätigt.
h) Schreibvorgänge landen tatsächlich (Stufe 5). setrangeCommand() ruft dbUnshareStringValue() auf, das den Wert dupliziert, außer wenn encoding == RAW && refcount == 1. Das Kodierungs-Byte wird nun vor dem ersten Schreiben durch den gekaperten Pointer auf null gesetzt, sodass die Schreibvorgänge die Zieladresse erreichen statt einer privaten Kopie.
i) Payload vereinfacht. Die gesamte Backconnect-/Reverse-Shell-Mechanik, das ASCII-Banner und das angehängte ;sleep 5 wurden entfernt. Die Payload ist exakt /bin/sh -c '<--cmd>' und sonst nichts. --cmd lautet standardmäßig auf id > /tmp/pwned123.txt.
j) Geschwindigkeit. Stufe 4 sammelt 3 Kandidaten statt 8; Stufe 5 ersetzt ~10^5 Byte-Sonden durch ~40 Massenlesevorgänge; Stufe 3 verwendet 256-KB-Lesezugriffe und überspringt Round-Trips für Kandidaten, die die lokale Validierung nicht bestehen. Gesamte Kette: 116 Befehle, <1 s.
Ergebnis: uid=0(root) gid=0(root) groups=0(root) in /tmp/pwned123.txt auf dem Zielcontainer.
/bin/sh, die von POSIX und dem FHS gefordert wird.redis_version ausgewählt (7.x und 8.x werden unterstützt). Die Struct-Feld-Offsets (executable=24, exec_argv=32) ergeben sich aus der LP64-ABI, und enable_debug_cmd wird zur Laufzeit entdeckt und verifiziert, statt fest kodiert zu sein.Gemessen wurden 13/13 erfolgreiche Läufe auf dem Standard-Zipmap-Pfad (5 + 8 in Folge), jeder in ≤1 Sekunde abgeschlossen. Drei Probleme traten nur bei wiederholter Ausführung auf und sind jetzt behoben:
k) SAVE-Race in Stufe 0. Ein Lauf konnte mit ERR Background save already in progress abbrechen, wenn ein Hintergrund-Save eines früheren Laufs (oder von Redis selbst) noch lief. SAVE wird jetzt bis zu 15 Sekunden lang erneut versucht; falls das fehlschlägt, fährt der Lauf ohne den Prüfpunkt fort, statt abzubrechen.
l) Reconnect, während das Ziel neu startet (--connect-retries, Standard 10). Ein fehlgeschlagener Versuch hinterlässt den Heap korrumpiert, sodass das FLUSHALL des nächsten Laufs die vergifteten Chunks freigibt und den Server zum Absturz bringt. Dieser startet Sekunden später neu und ist weiterhin perfekt ausnutzbar. Stufe 0 verbindet sich daher jetzt erneut und versucht es weiter, statt zu scheitern. Unsere eigenen Validierungsfehler (nicht unterstützte Version/Architektur) werden nie wiederholt. Dadurch wurde der gelegentliche Fehler „stage 0 failed with an empty error" beseitigt, der beim Stresstest etwa bei 1 von 3 Läufen auftrat.
m) sizeof(streamNACK) für 8.6.x korrigiert. Der --vuln-type stream-Pfad sprühte die falsche jemalloc-Größenklasse, weil das Struct als 24 oder 32 Bytes angenommen wurde. In 8.6.2 beträgt es 64 Bytes (delivery_time, delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev, pel_next). Mit der korrekten Größe erreicht der Stream-Pfad jetzt Stufe 5, statt in Stufe 2 mit „key overlap not found" zu scheitern.
--vuln-type stream ist auf 8.6.2 nicht zuverlässig. Mit der Größenkorrektur kommt es durch Double-Free, Overlap, R/W-Primitive und ELF-Parse, destabilisiert dann aber den Keyspace: Der Server stirbt in setrangeCommand beim Lesen von o->ptr an NULL+8, d.h. eine Schlüsselsuche liefert ein korrumpiertes Objekt zurück. Der freigegebene 64-Byte-Block wird mit anderen lebenden Allokationen geteilt, was ihn weitaus kollateralschwerer macht als den Zipmap-Pfad. Verwenden Sie den Standard --vuln-type zipmap, der 13/13 ist.--random-heap-massage (zuerst 100k zufällige Keys) gelingt, aber nicht ausnahmslos — der gesprayte Heap platziert den doppelt freigegebenen Block manchmal dorthin, wo kein Marker-Key landet. Ein erneuter Lauf ist erfolgreich.