
Стабильный 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). Слепое сканирование нельзя
сделать безопасным. Замена детерминирована: