
Стабильный POC для CVE-2026-25243 (Redis RESTORE double-free -> удалённое выполнение кода)
Проверено на Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0.
Ссылка: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; Стабильный эксплойт, работает на различных дистрибутивах ОС и архитектурах.
Что это? Уязвимость повреждения памяти в Redis, которая позволяет
аутентифицированному атакующему выполнять произвольные команды от имени
пользователя Redis. Атака реальна и требует лишь одной команды RESTORE —
обычной операции Redis, а не только для администратора. Этот эксплойт
демонстрирует полный RCE менее чем за секунду.
Влияние? Любой аутентифицированный клиент Redis может её вызвать, и ущерб тотален: произвольное выполнение кода в процессе Redis (часто работающем от root в контейнерах). Без патча самого Redis смягчить это невозможно.
Как это работает в двух словах? У Redis есть функция сериализации (RESTORE),
которая принимает блоб двоичных данных и восстанавливает его как объект Redis.
Код, проверяющий формат блоба, и код, десериализующий его, расходятся
в том, как разбирать определённые последовательности, — ошибка, которую
атакующий использует для повреждения кучи. После повреждения кучи атакующий
получает возможность читать и записывать любой адрес памяти в процессе Redis,
а оттуда перехватывает внутреннее состояние сервера, чтобы выполнить команду
оболочки.
Реальная техника эксплойта: Это не простой краш. Это цепочка heap-эксплойта: corrupt → overlap → произвольное чтение/запись → утечка информации → поиск структуры server → перехват указателей на функции → RCE. Эксплойт проходит 9 стадий и требует утечки множества адресов в рантайме, разбора двоичных структур и обнаружения алиасинга памяти. То, что заставляет его работать на разных архитектурах (x86-64, aarch64 и т. д.), — все адреса утекают из самой цели, а не предполагаются.
CVE-2026-25243 — это пара double-free ошибок, достижимых одной
аутентифицированной командой RESTORE. RESTORE key ttl <serialized-value>
десериализует управляемый атакующим RDB-блоб; обе ошибки находятся в разрыве
между валидатором, проверяющим блоб, и конвертером, который материализует его.
Ошибка 1 — преобразование устаревшего zipmap (CWE-415, путь, который использует этот эксплойт).
Валидатор zipmap (zipmapValidateIntegrity()) и конвертер (zipmapNext())
расходятся во мнении об избыточной кодировке длины. Маленькая длина 4 может
быть легально записана в длинной пятибайтовой форме FE 04 00 00 00.
Валидатор потребляет одно количество байт, конвертер — другое:
рассинхронизация парсинга на 4 байта. Поэтому конвертер обходит иную
структуру, не ту, что была проверена; lpSafeToAdd() завершается ошибкой уже
после того, как поле вставлено в словарь, и путь очистки освобождает поле
дважды: один раз через dictRelease() и ещё раз через sdsfree().
Ошибка 2 — загрузка PEL consumer'а потока (CWE-415). В
rdbLoadStreamConsumersGroup() PEL consumer'а, содержащий повторяющийся ID
записи, приводит к сбою второго raxTryInsert(), который вызывает
streamFreeNACK() для streamNACK, всё ещё принадлежащего глобальному PEL
группы. Освобождается дважды. (Выбирается с помощью --vuln-type stream.)
Любая из ошибок даёт атакующему чанк памяти, который одновременно свободен и на который есть ссылка, — классическая отправная точка для heap-overlap эксплойта.
Воздействие: аутентифицированный клиент Redis (без прав администратора,
RESTORE — обычная команда работы с данными) получает произвольное выполнение
кода от имени пользователя redis — root в образе контейнера по умолчанию.
Девять стадий, каждая из которых превращает более слабый примитив в более сильный:
| Стадия | Полученный примитив | Механизм |
|---|---|---|
| 0 | профиль цели | INFO server / INFO memory → версия, архитектура, дистрибутив, pid, путь к исполняемому файлу, время запуска, аллокатор |
| 1 | double free | некорректный zipmap (или stream) RESTORE |
| 2 | два ключа, разделяющие память | распылить маркерные ключи на освобождённый чанк, обнаружить алиасинг, затем перезаписать SDS-заголовок одного ключа через его двойника, чтобы раздуть его до 1 МБ «memview» |
| 3 | произвольное чтение/запись | найти объект INCRBYFLOAT внутри memview, перехватить его поле ptr: GETRANGE/SETRANGE по этому ключу теперь читают/записывают любой адрес |
| 4 | указатель на образ | сканировать кучу назад в поисках значения внутри образа redis-server |
| 5 | &server | спуститься к ELF-заголовку, разобрать program headers, слить сегмент, доступный на запись, сопоставить server.pid |
| 6 | пейлоад в памяти | записать "/bin/sh", "-c", "<cmd>" и массив argv в memview |
| 7 | перехваченная структура | перезаписать server.executable, server.exec_argv и 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'
Проверка:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
Отправная точка: эксплойт работал только на x86-64 и погибал на стадии 3 на цели aarch64. Конечное состояние: полный RCE на aarch64 Rocky Linux 8.10 менее чем за секунду, 116 команд Redis.
a) Снятие цифрового отпечатка цели в рантайме (новое, стадия 0). О цели
больше ничего не предполагается. INFO server + INFO memory дают версию
Redis, архитектуру CPU (из строки os:), семейство дистрибутива (выводится из
gcc_version), аллокатор и — самое важное — три якоря валидации:
process_id, executable и точный stat_starttime
(server_time_usec/1e6 - uptime_in_seconds). Последующие стадии сверяются
с ними вместо догадок.
b) Независимая от архитектуры компоновка памяти. Четыре захардкоженных
константы x86-64 (BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX) заменены
таблицей по архитектурам (ARCH_PROFILES), покрывающей x86_64, aarch64
(VA и 39-, и 48-бит), riscv64, ppc64le и s390x, с размещениями как ET_EXEC,
так и ET_DYN для каждой, плюс широкий универсальный запасной вариант для всего
остального. Это и была настоящая причина, по которой эксплойт падал на этой
цели: утёкший указатель 0x0000ffff8a5fdf32 — вполне себе валидный aarch64
mmap-адрес, который проверка диапазонов x86-64 отвергала.
c) Валидация утечки на основе консенсуса (стадия 3). Вместо доверия
захардкоженному окну кучи сканирование теперь собирает каждый структурно
валидный объект 1337.NNNNNN в memview и требует, чтобы по крайней мере два
из них давали один и тот же базовый адрес memview (ptr - offset_of_value).
На практике совпадают 502 кандидата — это доказательство, которого не может
дать ни одна таблица диапазонов. Подтверждённый указатель затем калибрует
окно кучи в рантайме. Проверка формата также перенесена перед дорогим по
round-trip тестом контроля записи.
d) Граница сканирования на стадии 3 (исправление ошибки). Сканирование
уходило до захардкоженных 10 МБ, тогда как memview — 1 МБ, поэтому оно читало
за конец, получало пустой ответ и падало с AssertionError: Empty data from memview.
Теперь оно ограничено реальным STRLEN memview, читает 256 КБ за round-trip
вместо 64 КБ, а бессмысленный цикл повторов со сном 6×1 с убран.
e) Стадия 5 переписана: по ELF-ориентирам, без падений (главное). Старая
реализация сканировала вперёд от указателя на образ, опрашивая адреса и читая
ту длину, которую заявлял мусорный SDS-заголовок. На этой цели она ушла прямо
за конец сегмента только для чтения в незамапленную дыру на 0x715000 и убила
сервер (SIGSEGV в getrangeCommand → memcpy). Слепое сканирование нельзя
сделать безопасным. Замена детерминирована:
7f 45 4c 46 02, а sdslen() берёт байт
флагов из ptr[-1] — так что наведение перехваченного объекта на base+5
делает e_ident[EI_CLASS]=0x02 байтом флагов, то есть SDS_TYPE_16, чья
длина — это uint16 по адресу base+0 = 0x457f (0x7f45 в big-endian).
STRLEN, равный ровно 17791, и есть сигнатура ELF. Локальная копия
бинарника не нужна — заголовок читается из памяти самой цели.PT_LOAD в рантайме (с учётом load bias ET_DYN для PIE-целей).
Каждое последующее чтение ограничено реальным маппингом, так что падение
в незамапленной дыре теперь структурно невозможно..data/.bss читаемым за несколько round-trip
вместо сотен тысяч байтовых проб. Перезаписанные байты сохраняются
и восстанавливаются.server.pid с pid из INFO — точная проверка равенства
8 байт — затем подтвердить разыменованием server.executable
и сравнением строки с executable из INFO. Старый код довольствовался
слабой эвристикой по форме из семи полей; теперь структура идентифицируется
наверняка.f) Стадия 4 ужесточена. Lua-валидатор принимает полный список диапазонов
по архитектурам (так что и не-PIE образ по адресу 0x400000, и PIE-образ
по адресу 0xaaaa… распознаются) и исключает калиброванное окно кучи. Он
возвращает несколько кандидатов вместо одного, так что неудачный выбор стоит
повтора, а не всего запуска.
g) Стадия 7 с самопроверкой. enable_debug_cmd определялся по
захардкоженному смещению stat_starttime - 0x3c. Теперь ожидаемое значение
stat_starttime точно известно из INFO (окно в 3 секунды вместо 30 дней),
окно чтения структуры выросло с 4 КБ до 32 КБ (stat_starttime находится
на смещении 0x9e0, далеко за старым пределом), и — что решающе — каждый
кандидат-смещение проверяется живым оракулом: установить байт, отправить
DEBUG SET-ACTIVE-EXPIRE 1 и посмотреть, примет ли его сервер. Неверные
догадки восстанавливаются перед следующей попыткой, так что флаг находится
на любой сборке, а не предполагается. -0x3c по-прежнему пробуется первым
и подтверждён корректным для 8.6.2 (смещение 0x9a4).
h) Записи действительно достигают цели (стадия 5). setrangeCommand()
вызывает dbUnshareStringValue(), который дублирует значение, если только не
encoding == RAW && refcount == 1. Байт кодировки теперь зануляется перед
первой записью через перехваченный указатель, так что записи достигают целевого
адреса вместо приватной копии.
i) Пейлоад упрощён. Убраны все механизмы backconnect/reverse-shell,
ASCII-баннер и добавленный ;sleep 5. Пейлоад — это ровно /bin/sh -c '<--cmd>'
и ничего больше. По умолчанию --cmd равен id > /tmp/pwned123.txt.
j) Скорость. Стадия 4 собирает 3 кандидатов вместо 8; стадия 5 заменяет ~10^5 байтовых проб на ~40 массовых чтений; стадия 3 использует чтения по 256 КБ и пропускает round-trip для кандидатов, не прошедших локальную валидацию. Вся цепочка: 116 команд, <1 с.
Результат: uid=0(root) gid=0(root) groups=0(root) в
/tmp/pwned123.txt в целевом контейнере.
/bin/sh,
который требуют POSIX и FHS.redis_version
(поддерживаются 7.x и 8.x). Смещения полей структуры (executable=24,
exec_argv=32) следуют из ABI LP64, а enable_debug_cmd обнаруживается
и проверяется в рантайме, а не захардкожен.Замерено 13/13 успешных запусков на пути zipmap по умолчанию (5 + 8 подряд), каждый завершается ≤1 секунды. Три проблемы проявились только при повторном выполнении и теперь исправлены:
k) Гонка SAVE на стадии 0. Запуск мог прерываться с ERR Background save already in progress,
когда фоновое сохранение от предыдущего запуска (или самого redis) всё ещё
выполнялось. Теперь SAVE повторяется до 15 секунд, а если не получается,
запуск продолжается без контрольной точки вместо прерывания.
l) Переподключение, пока цель перезапускается (--connect-retries, по умолчанию 10).
Неудачная попытка оставляет кучу повреждённой, так что FLUSHALL следующего
запуска освобождает отравленные чанки и роняет сервер. Он перезапускается через
несколько секунд и полностью эксплуатируем, поэтому стадия 0 теперь
переподключается и повторяет попытки, а не падает. Наши собственные ошибки
валидации (неподдерживаемая версия/архитектура) никогда не повторяются. Это
устранило перемежающуюся ошибку «stage 0 failed with an empty error»,
наблюдавшуюся примерно в 1 запуске из 3 во время стресс-тестирования.
m) sizeof(streamNACK) исправлен для 8.6.x. Путь --vuln-type stream
распылял не тот jemalloc size class, поскольку предполагалось, что структура
занимает 24 или 32 байта. В 8.6.2 это 64 байта (delivery_time,
delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev,
pel_next). С правильным размером путь stream теперь доходит до стадии 5 вместо
провала на стадии 2 с «key overlap not found».
--vuln-type stream ненадёжен на 8.6.2. С исправлением размера он
проходит double-free, overlap, примитив чтения/записи и разбор ELF, но затем
дестабилизирует keyspace: сервер падает в setrangeCommand при чтении
o->ptr по адресу NULL+8, то есть поиск ключа возвращает повреждённый объект.
Освобождаемый им 64-байтовый чанк разделяется с другими живыми выделениями,
из-за чего этот путь наносит гораздо больше сопутствующего ущерба, чем путь
zipmap. Используйте путь по умолчанию --vuln-type zipmap, который 13/13.--random-heap-massage (сначала 100k случайных ключей) срабатывает, но не всегда —
распылённая куча иногда размещает double-free чанк там, куда не попадает ни один
маркерный ключ. Повторный запуск срабатывает.