
Stable POC for CVE-2026-25243 (Redis RESTORE double-free -> remote code execution)
Verificato su Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Riferimento: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; Exploit stabile, funziona su diverse distribuzioni e architetture.
Cos'è? Una vulnerabilità di corruzione della memoria in Redis che consente a un attaccante autenticato di eseguire comandi arbitrari come utente Redis. L'attacco è reale e richiede un solo comando RESTORE — una normale operazione Redis, non riservata agli amministratori. Questo exploit dimostra una RCE completa in meno di un secondo.
Impatto? Qualsiasi client Redis autenticato può innescarla, e il danno è totale: esecuzione arbitraria di codice nel processo Redis (spesso in esecuzione come root nei container). Non c'è modo di mitigare senza applicare la patch a Redis stesso.
Come funziona in sintesi? Redis ha una funzionalità di serializzazione (RESTORE) che prende un blob di dati binari e lo ricostruisce come oggetto Redis. Il codice che valida il formato del blob e quello che lo deserializza non concordano su come interpretare certe sequenze — un bug che l'attaccante sfrutta per corrompere l'heap. Una volta corrotto l'heap, l'attaccante ottiene la capacità di leggere e scrivere qualsiasi indirizzo di memoria nel processo Redis e, da lì, di dirottare lo stato interno del server per eseguire un comando di shell.
La vera tecnica dell'exploit: Non si tratta di un semplice crash. È una catena di sfruttamento dell'heap: corruzione → sovrapposizione → R/W arbitrario → leak di informazioni → individuazione della struct del server → dirottamento dei puntatori a funzione → RCE. L'exploit esegue 9 fasi e richiede il leak di più indirizzi a runtime, l'analisi di strutture binarie e il rilevamento dell'aliasing di memoria. Ciò che lo rende funzionante su più architetture (x86-64, aarch64, ecc.) è che tutti gli indirizzi vengono ottenuti tramite leak dal target stesso, non ipotizzati.
CVE-2026-25243 è una coppia di bug double-free raggiungibili da un singolo comando RESTORE autenticato. RESTORE key ttl <serialized-value> deserializza un blob RDB controllato dall'attaccante; entrambi i bug vivono nello spazio tra il validatore che controlla il blob e il convertitore che lo materializza.
Bug 1 — conversione legacy zipmap (CWE-415, il percorso usato da questo exploit). Il validatore zipmap (zipmapValidateIntegrity()) e il convertitore (zipmapNext()) non concordano su una codifica ridondante della lunghezza. La lunghezza piccola 4 può essere legittimamente scritta nella forma lunga di cinque byte FE 04 00 00 00. Il validatore consuma un certo numero di byte, il convertitore un altro — una desincronizzazione di parsing di 4 byte. Il convertitore percorre quindi una struttura diversa da quella validata, lpSafeToAdd() fallisce dopo che il campo è già stato inserito nel dizionario, e il percorso di cleanup libera il campo due volte: una volta tramite dictRelease() e un'altra tramite sdsfree().
Bug 2 — caricamento del PEL dei consumatori di stream (CWE-415). In rdbLoadStreamConsumersGroup(), un PEL di un consumatore contenente un ID duplicato fa fallire la seconda raxTryInsert(), che chiama streamFreeNACK() su uno streamNACK ancora di proprietà del PEL globale del gruppo. Liberato due volte. (Selezionabile con --vuln-type stream.)
Entrambi i bug consegnano all'attaccante un blocco di memoria simultaneamente libero e referenziato — il classico punto di partenza per un exploit di sovrapposizione dell'heap.
Impatto: un client Redis autenticato (senza diritti di amministrazione; RESTORE è un normale comando dati) ottiene l'esecuzione arbitraria di codice come utente redis — root nell'immagine container predefinita.
Nove fasi, ognuna delle quali trasforma una primitiva più debole in una più forte:
python3 exploit.py --host 127.0.0.1 --port 6379 \
--password mypassword --cmd 'id > /tmp/pwned123.txt'
Verifica:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Punto di partenza: l'exploit era solo x86-64 e moriva in fase 3 sul target aarch64. Stato finale: RCE completa su aarch64 Rocky Linux 8.10 in meno di un secondo, 116 comandi Redis.
a) Rilevamento delle caratteristiche del target a runtime (nuovo, fase 0). Non si assume più nulla sul target. INFO server + INFO memory forniscono la versione di Redis, l'architettura della CPU (dalla riga os:), la famiglia di distribuzione (dedotta da gcc_version), l'allocatore e — cosa più importante — tre punti di ancoraggio di validazione: process_id, executable e l'esatto stat_starttime (server_time_usec/1e6 - uptime_in_seconds). Le fasi successive confrontano questi valori invece di fare ipotesi.
b) Layout di memoria indipendente dall'architettura. Le quattro costanti x86-64 hardcoded (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) sono sostituite da una tabella per architettura (ARCH_PROFILES) che copre x86_64, aarch64 (VA sia a 39 che a 48 bit), riscv64, ppc64le e s390x, con entrambi i posizionamenti ET_EXEC ed ET_DYN per ciascuna, più un'ampia fallback generica per qualsiasi cosa non elencata. Questa era la vera ragione per cui l'exploit falliva su questo target: il puntatore leakato 0x0000ffff8a5fdf32 è un perfetto indirizzo mmap aarch64 che il controllo di intervallo x86-64 respingeva.
c) Validazione del leak basata sul consenso (fase 3). Invece di fidarsi di una finestra heap hardcoded, la scansione ora raccoglie tutti gli oggetti 1337.NNNNNN strutturalmente validi nella memview e richiede che almeno due di essi derivino lo stesso indirizzo base della memview (ptr - offset_of_value). In pratica 502 candidati concordano, una prova che nessuna tabella di intervalli può offrire. Il puntatore confermato calibra poi la finestra heap a runtime. La validazione del formato è stata inoltre spostata prima del test di controllo scrittura (costoso in termini di round-trip).
d) Limite di scansione della fase 3 (correzione di bug). La scansione arrivava a un limite hardcoded di 10 MB mentre la memview è di 1 MB, quindi leggeva oltre la fine, otteneva una risposta vuota e abortiva con AssertionError: Empty data from memview. Ora è limitata dalla STRLEN reale della memview, legge 256 KB per round-trip invece di 64 KB, e il loop inutile di retry-sleep 6×1s è stato rimosso.
e) Fase 5 riscritta: guidata da ELF, senza crash (la più importante). La vecchia implementazione scansionava in avanti da un puntatore all'immagine, sondando gli indirizzi e leggendo qualsiasi lunghezza dichiarata da un header SDS spazzatura. Su questo target usciva dritta dalla fine del segmento read-only nel buco non mappato a 0x715000 e uccideva il server (SIGSEGV in getrangeCommand → memcpy). La scansione cieca non può essere resa sicura. La sostituzione è deterministica:
7f 45 4c 46 02, e sdslen() prende il suo byte flags da ptr[-1] — quindi puntare l'oggetto dirottato a base+5 fa sì che e_ident[EI_CLASS]=0x02 sia il byte flags, cioè SDS_TYPE_16, la cui lunghezza è lo uint16 a base+0 = 0x457f (0x7f45 big-endian). Una STRLEN di esattamente 17791 è la firma ELF. Non serve una copia locale del binario: l'header viene letto dalla memoria del target stesso.PT_LOAD (gestendo il bias di caricamento ET_DYN per i target PIE). Ogni lettura successiva è limitata a un mapping reale, quindi il crash nel buco non mappato è ora strutturalmente impossibile.f) Fase 4 indurita. Il validatore Lua prende l'intera lista di intervalli per architettura (così sia un'immagine non-PIE a 0x400000 sia un'immagine PIE a 0xaaaa… sono riconosciute) ed esclude la finestra heap calibrata. Restituisce più candidati invece di uno solo, quindi una scelta sbagliata costa un retry, non l'intera esecuzione.
g) Fase 7 auto-verificante. enable_debug_cmd veniva localizzato con un stat_starttime - 0x3c hardcoded. Ora il valore atteso di stat_starttime è noto esattamente da INFO (una finestra di 3 secondi invece di 30 giorni), la finestra di lettura della struct è passata da 4 KB a 32 KB (stat_starttime si trova all'offset 0x9e0, ben oltre il vecchio limite) e — in modo decisivo — ogni offset candidato viene verificato con un oracolo live: imposta il byte, invia DEBUG SET-ACTIVE-EXPIRE 1, e osserva se il server lo accetta. Le ipotesi sbagliate vengono ripristinate prima del tentativo successivo, quindi il flag viene trovato su qualsiasi build invece di essere assunto. -0x3c viene comunque provato per primo e confermato corretto per 8.6.2 (offset 0x9a4).
h) Le scritture arrivano davvero a destinazione (fase 5). setrangeCommand() chiama dbUnshareStringValue(), che duplica il valore a meno che encoding == RAW && refcount == 1. Il byte encoding ora viene azzerato prima della prima scrittura attraverso il puntatore dirottato, così le scritture raggiungono l'indirizzo target invece di una copia privata.
i) Payload semplificato. Tutto il meccanismo di backconnect/reverse shell, il banner ASCII e il ;sleep 5 aggiunto sono stati rimossi. Il payload è esattamente /bin/sh -c '<--cmd>' e nient'altro. --cmd ha come default id > /tmp/pwned123.txt.
j) Velocità. La fase 4 raccoglie 3 candidati invece di 8; la fase 5 sostituisce ~10^5 probe a byte con ~40 letture bulk; la fase 3 usa letture da 256 KB e salta i round-trip per i candidati che falliscono la validazione locale. Intera catena: 116 comandi, <1 s.
Risultato: uid=0(root) gid=0(root) groups=0(root) in /tmp/pwned123.txt nel container target.
/bin/sh, richiesta da POSIX e FHS.redis_version (supportate 7.x e 8.x). Gli offset dei campi della struct (executable=24, exec_argv=32) derivano dall'ABI LP64, e enable_debug_cmd viene scoperto e verificato a runtime invece di essere hardcoded.Misurate 13/13 esecuzioni riuscite sul percorso predefinito zipmap (5 + 8 consecutive), ognuna completata in ≤1 secondo. Tre problemi sono emersi solo sotto esecuzione ripetuta e ora sono corretti:
k) Corsa di SAVE in fase 0. Un'esecuzione poteva abortire con ERR Background save already in progress quando un salvataggio in background di una run precedente (o di redis stesso) era ancora in corso. Ora SAVE viene ritentato fino a 15 secondi e, se fallisce, l'esecuzione prosegue senza il checkpoint invece di abortire.
l) Riconnessione durante il riavvio del target (--connect-retries, default 10). Un tentativo fallito lascia l'heap corrotto, quindi la FLUSHALL della run successiva libera i chunk avvelenati e abbatte il server. Questo si riavvia pochi secondi dopo ed è perfettamente sfruttabile, quindi la fase 0 ora si riconnette e ritenta invece di fallire. I nostri stessi errori di validazione (versione/architettura non supportata) non vengono mai ritentati. Questo ha eliminato l'intermittente "stage 0 failed with an empty error" osservato circa 1 volta su 3 durante gli stress test.
m) sizeof(streamNACK) corretto per 8.6.x. Il percorso --vuln-type stream spruzzava la classe di dimensione jemalloc sbagliata perché si presumeva che la struct fosse di 24 o 32 byte. In 8.6.2 è di 64 byte (delivery_time, delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev, pel_next). Con la dimensione corretta, il percorso stream ora raggiunge la fase 5 invece di fallire in fase 2 con "key overlap not found".
--vuln-type stream non è affidabile su 8.6.2. Con la correzione della dimensione supera il double-free, la sovrapposizione, la primitiva R/W e il parsing ELF, poi destabilizza il keyspace: il server muore in setrangeCommand leggendo o->ptr a NULL+8, cioè una lookup di chiave restituisce un oggetto corrotto. Il chunk da 64 byte che libera è condiviso con altre allocazioni live, il che lo rende molto più dannoso per la collateralità rispetto al percorso zipmap. Usa il --vuln-type zipmap predefinito, che è 13/13.--random-heap-massage (100k chiavi casuali prima) funziona ma non sempre — a volte l'heap spruzzato posiziona il chunk liberato due volte dove nessuna chiave marker atterra. Rilanciare l'esecuzione risolve.| Fase | Primitiva ottenuta | Meccanismo |
|---|
| 0 | profilo del target | INFO server / INFO memory → versione, architettura, distro, pid, percorso dell'eseguibile, tempo di avvio, allocatore |
| 1 | double free | RESTORE con zipmap (o stream) malformato |
| 2 | due chiavi che condividono la memoria | spruzza chiavi marker sul chunk liberato, rileva l'aliasing, poi sovrascrive l'header SDS di una chiave attraverso la sua gemella per espanderla a una "memview" da 1 MB |
| 3 | R/W arbitrario | trova un oggetto INCRBYFLOAT dentro la memview, dirotta il suo campo ptr: GETRANGE/SETRANGE su quella chiave ora legge/scrive qualsiasi indirizzo |
| 4 | puntatore all'immagine | scansiona l'heap all'indietro per trovare un valore dentro l'immagine di redis-server |
| 5 | &server | scendi fino all'header ELF, analizza i program header, dumpa il segmento scrivibile, confronta server.pid |
| 6 | payload in memoria | scrivi "/bin/sh", "-c", "<cmd>" più un array argv nella memview |
| 7 | struct dirottata | sovrascrivi 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 leggibile in una manciata di round-trip invece che in centinaia di migliaia di probe a byte. I byte sovrascritti vengono salvati e ripristinati.server.pid con il pid di INFO — un test di uguaglianza esatto di 8 byte — poi conferma dereferenziando server.executable e confrontando la stringa con executable di INFO. Il vecchio codice accettava un'euristica di forma vaga su sette campi; ora la struct è identificata positivamente.