
Stable POC for CVE-2026-25243 (Redis RESTORE double-free -> remote code execution)
Rocky Linux 8.10, aarch64, Redis 8.6.2, jemalloc 5.3.0 환경에서 검증됨.
참고: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
요약; 안정적인 익스플로잇으로, 다양한 OS 배포판과 아키텍처에서 동작합니다.
이것은 무엇인가? 인증된 공격자가 Redis 사용자 권한으로 임의의 명령을 실행할 수 있게 하는 Redis의 메모리 손상 취약점입니다. 이 공격은 실제 환경에서 발생 가능하며, 단 한 번의 RESTORE 명령만 필요합니다 — 이는 일반적인 Redis 작업으로, 관리자 전용이 아닙니다. 이 익스플로잇은 1초 이내에 완전한 RCE를 시연합니다.
영향은? 인증된 모든 Redis 클라이언트가 이를 트리거할 수 있으며, 피해는 전면적입니다: Redis 프로세스에서의 임의 코드 실행(컨테이너에서는 종종 root로 실행됨)이 가능합니다. Redis 자체를 패치하지 않고는 완화할 방법이 없습니다.
한눈에 보는 작동 방식은? Redis에는 바이너리 데이터 블롭(blob)을 받아 Redis 객체로 재구성하는 직렬화 기능(RESTORE)이 있습니다. 블롭의 형식을 검증하는 코드와 이를 역직렬화하는 코드는 특정 시퀀스를 파싱하는 방식에 대해 서로 다른 의견을 갖고 있습니다 — 공격자가 힙을 손상시키기 위해 악용하는 버그입니다. 힙이 손상되면 공격자는 Redis 프로세스의 모든 메모리 주소를 읽고 쓸 수 있는 능력을 얻고, 거기서 서버의 내부 상태를 가로채 셸 명령을 실행합니다.
실제 익스플로잇 기술: 이것은 단순한 크래시가 아닙니다. 힙 익스플로잇 체인입니다: 손상 → 오버랩 → 임의 R/W → 정보 유출 → 서버 구조체 탐색 → 함수 포인터 하이재킹 → RCE. 익스플로잇은 9개 스테이지로 실행되며, 런타임에 여러 주소를 유출하고, 바이너리 구조를 파싱하며, 메모리 앨리어싱을 감지해야 합니다. 다양한 아키텍처(x86-64, aarch64 등)에서 동작하는 이유는 모든 주소가 가정이 아니라 대상에서 직접 유출되기 때문입니다.
CVE-2026-25243은 단일 인증된 RESTORE 명령으로 도달할 수 있는 한 쌍의 이중 해제(double-free) 버그입니다. RESTORE key ttl <serialized-value>는 공격자가 제어하는 RDB 블롭을 역직렬화합니다. 두 버그 모두 블롭을 검사하는 *검증기(validator)*와 이를 실제로 생성하는 변환기(converter) 사이의 간극에 존재합니다.
버그 1 — 레거시 zipmap 변환 (CWE-415, 이 익스플로잇이 사용하는 경로). zipmap 검증기(zipmapValidateIntegrity())와 변환기(zipmapNext())는 중복된 길이 인코딩에 대해 서로 다르게 해석합니다. 작은 길이 4는 긴 5바이트 형식 FE 04 00 00 00으로도 합법적으로 쓸 수 있습니다. 검증기는 한 바이트 수를 소비하고 변환기는 다른 바이트 수를 소비합니다 — 4바이트 파싱 불일치가 발생합니다. 따라서 변환기는 검증된 구조와는 다른 구조를 탐색하게 되고, 필드가 이미 딕셔너리에 삽입된 후 lpSafeToAdd()가 실패하며, 정리 경로에서 필드를 두 번 해제합니다: 한 번은 dictRelease()를 통해, 다시 한 번은 sdsfree()를 통해.
버그 2 — 스트림 컨슈머 PEL 로딩 (CWE-415). rdbLoadStreamConsumersGroup()에서 중복된 엔트리 ID를 포함하는 컨슈머 PEL은 두 번째 raxTryInsert()를 실패하게 만들고, 이는 여전히 그룹의 전역 PEL이 소유하고 있는 streamNACK에 대해 streamFreeNACK()을 호출합니다. 두 번 해제됩니다. (--vuln-type stream으로 선택 가능)
두 버그 모두 공격자에게 동시에 해제되었으면서도 참조되는 메모리 청크를 제공합니다 — 힙 오버랩 익스플로잇의 전형적인 시작점입니다.
영향: 인증된 Redis 클라이언트(관리자 권한 없음, RESTORE는 일반 데이터 명령)는 redis 사용자 권한으로 임의 코드 실행을 얻습니다 — 기본 컨테이너 이미지에서는 root입니다.
총 9개 스테이지로, 각 단계는 더 약한 프리미티브를 더 강한 프리미티브로 변환합니다:
| 스테이지 | 획득한 프리미티브 | 메커니즘 |
|---|---|---|
| 0 | 대상 프로필 | INFO server / INFO memory → 버전, 아키텍처, 배포판, pid, 실행 파일 경로, 시작 시간, 할당자 |
| 1 | 이중 해제 | 잘못된 형식의 zipmap(또는 stream) RESTORE |
| 2 | 메모리를 공유하는 두 키 | 해제된 청크에 마커 키를 스프레이하여 앨리어싱을 감지한 다음, 쌍을 통해 한 키의 SDS 헤더를 덮어써 1 MB "memview"로 확장 |
| 3 | 임의 R/W | memview 내부에서 INCRBYFLOAT 객체를 찾아 ptr 필드를 하이재킹: 해당 키에 대한 GETRANGE/SETRANGE는 이제 모든 주소를 읽고 씀 |
| 4 | 이미지 포인터 | redis-server 이미지 내부의 값을 찾기 위해 힙을 역방향으로 스캔 |
| 5 | &server | ELF 헤더까지 내려가 프로그램 헤더를 파싱하고, 쓰기 가능한 세그먼트를 덤프하여 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 전용이었고 aarch64 대상에서는 스테이지 3에서 실패했습니다. 최종 상태: aarch64 Rocky Linux 8.10에서 1초 이내에 완전한 RCE, Redis 명령 116개.
a) 런타임 대상 핑거프린팅 (신규, 스테이지 0). 대상에 대해 더 이상 가정하지 않습니다. INFO server + INFO memory는 Redis 버전, CPU 아키텍처(os: 라인에서), 배포판 계열(gcc_version에서 추론), 할당자, 그리고 가장 중요한 세 가지 검증 앵커를 제공합니다: process_id, executable, 정확한 stat_starttime(server_time_usec/1e6 - uptime_in_seconds). 이후 스테이지들은 추측 대신 이를 비교합니다.
b) 아키텍처 독립적인 메모리 레이아웃. 하드코딩된 4개의 x86-64 상수(BINARY_ADDR_MIN/MAX, HEAP_ADDR_MIN/MAX)는 x86_64, aarch64(39비트 및 48비트 VA 모두), riscv64, ppc64le, s390x를 각각의 ET_EXEC 및 ET_DYN 배치와 함께 포함하는 아키텍처별 테이블(ARCH_PROFILES)로 대체되었으며, 목록에 없는 모든 항목을 위한 광범위한 범용 폴백도 포함합니다. 이것이 이 대상에서 익스플로잇이 실패한 실제 이유였습니다: 유출된 포인터 0x0000ffff8a5fdf32는 완벽하게 유효한 aarch64 mmap 주소인데 x86-64 범위 검사가 이를 거부했습니다.
c) 합의 기반 유출 검증 (스테이지 3). 하드코딩된 힙 윈도우를 신뢰하는 대신, 스캔은 이제 memview에서 구조적으로 유효한 모든 1337.NNNNNN 객체를 수집하고, 그중 최소 2개가 동일한 memview 베이스 주소(ptr - offset_of_value)를 도출해야 합니다. 실제로 502개의 후보가 일치하며, 이는 어떤 범위 테이블도 제공할 수 없는 증거입니다. 확인된 포인터는 런타임에 힙 윈도우를 *보정(calibrate)*합니다. 형식 검증도 (왕복 비용이 큰) 쓰기 제어 테스트보다 앞으로 이동했습니다.
d) 스테이지 3 스캔 범위 (버그 수정). 스캔은 memview가 1 MB인데 하드코딩된 10 MB까지 실행되어 끝을 넘어 읽은 후 빈 응답을 받고 AssertionError: Empty data from memview로 중단되었습니다. 이제 memview의 실제 STRLEN으로 제한되며, 왕복당 64 KB 대신 256 KB를 읽고, 무의미한 6×1초 재시도-대기 루프는 제거되었습니다.
e) 스테이지 5 재작성: ELF 기반, 크래시 없음 (가장 큰 변경). 기존 구현은 이미지 포인터에서 앞으로 스캔하며 주소를 프로빙하고 쓰레기 SDS 헤더가 주장하는 길이만큼 읽었습니다. 이 대상에서는 읽기 전용 세그먼트 끝을 벗어나 0x715000의 매핑되지 않은 구멍으로 직진하여 서버를 죽였습니다(getrangeCommand → memcpy에서 SIGSEGV). 블라인드 스캐닝은 안전하게 만들 수 없습니다. 교체된 구현은 결정적입니다:
이미지 베이스 찾기. 가장 낮은 유출 이미지 포인터에서 페이지별로 아래로 내려갑니다. 프로브는 비용이 들지 않습니다: 모든 ELF64 이미지의 처음 5바이트는 7f 45 4c 46 02이며, sdslen()은 ptr[-1]에서 플래그 바이트를 가져옵니다 — 따라서 하이재킹된 객체를 base+5에 가리키면 e_ident[EI_CLASS]=0x02가 플래그 바이트가 됩니다. 즉 SDS_TYPE_16이며, 그 길이는 base+0의 uint16 = 0x457f입니다(0x7f45 빅엔디안). 정확히 17791인 STRLEN이 곧 ELF 서명입니다. 바이너리의 로컬 복사본은 필요 없습니다 — 헤더는 대상 자체의 메모리에서 읽어냅니다.
프로그램 헤더 파싱하여 모든 PT_LOAD 세그먼트의 정확한 런타임 범위를 얻습니다(PIE 대상의 ET_DYN 로드 바이어스 처리). 이후의 모든 읽기는 실제 매핑으로 제한되므로 매핑되지 않은 구멍으로 인한 크래시는 이제 구조적으로 불가능합니다.
쓰기 가능한 세그먼트의 0으로 채워진 슬롯에 SDS 헤더 하나를 위조하여 수십만 번의 바이트 프로브 대신 몇 번의 왕복으로 .data/.bss 전체를 읽을 수 있게 합니다. 덮어쓴 바이트는 저장 후 복원됩니다.
server.pid를 INFO의 pid와 일치시킵니다 — 정확한 8바이트 동등성 테스트 — 그런 다음 server.executable을 역참조하여 문자열을 INFO의 executable과 비교하여 확인합니다. 기존 코드는 느슨한 7개 필드 형태 휴리스틱을 수용했지만, 이제 구조체는 확실하게 식별됩니다.
f) 스테이지 4 강화. Lua 검증기는 전체 아키텍처별 범위 목록을 사용하므로(0x400000의 비-PIE 이미지와 0xaaaa…의 PIE 이미지 모두 인식) 보정된 힙 윈도우를 제외합니다. 하나 대신 여러 후보를 반환하므로 잘못된 선택은 실행을 잃는 대신 재시도 비용만 발생합니다.
g) 스테이지 7 자체 검증. enable_debug_cmd는 하드코딩된 stat_starttime - 0x3c로 찾았습니다. 이제 예상 stat_starttime 값은 INFO에서 정확히 알 수 있고(30일 대신 3초 창), 구조체 읽기 창은 4 KB에서 32 KB로 늘어났으며(stat_starttime은 오프셋 0x9e0에 위치, 기존 한도를 훨씬 초과), 결정적으로 각 후보 오프셋은 라이브 오라클로 검증됩니다: 바이트를 설정하고 DEBUG SET-ACTIVE-EXPIRE 1을 보내 서버가 수락하는지 확인합니다. 잘못된 추측은 다음 시도 전에 복원되므로 플래그는 가정이 아닌 모든 빌드에서 발견됩니다. -0x3c는 여전히 먼저 시도되며 8.6.2(오프셋 0x9a4)에서 정확함이 확인되었습니다.
h) 쓰기가 실제로 적용됨 (스테이지 5). setrangeCommand()는 dbUnshareStringValue()를 호출하며, 이는 encoding == RAW && refcount == 1이 아닌 경우 값을 복제합니다. 이제 하이재킹된 포인터를 통한 첫 번째 쓰기 전에 인코딩 바이트가 0으로 설정되므로 쓰기는 개인 복사본이 아닌 대상 주소에 도달합니다.
i) 페이로드 단순화. 모든 백커넥트/리버스 셸 메커니즘, ASCII 배너, 추가된 ;sleep 5가 제거되었습니다. 페이로드는 정확히 /bin/sh -c '<--cmd>'이며 그 외에는 없습니다. --cmd의 기본값은 id > /tmp/pwned123.txt입니다.
j) 속도. 스테이지 4는 8개 대신 3개의 후보를 수집합니다; 스테이지 5는 ~10^5 바이트 프로브를 ~40회의 벌크 읽기로 대체합니다; 스테이지 3은 256 KB 읽기를 사용하고 로컬 검증에 실패한 후보에 대해서는 왕복을 건너뜁니다. 전체 체인: 116개 명령, <1초.
결과: 대상 컨테이너의 /tmp/pwned123.txt에 uid=0(root) gid=0(root) groups=0(root).
아키텍처는 가정이 아닌 감지됩니다. ELF 기반 스테이지 5는 설계상 아키텍처 중립적이며(대상 자체의 프로그램 헤더를 읽음) PIE 및 비-PIE 이미지, 리틀- 및 빅엔디안을 모두 처리합니다.
배포판은 운영자를 위해 보고될 뿐입니다; 익스플로잇은 이에 대한 기능적 의존성이 없습니다. 유일한 파일시스템 가정은 POSIX와 FHS가 요구하는 /bin/sh입니다.
버전: RDB 버전과 스트림 구조체 크기는 redis_version에서 선택됩니다(7.x 및 8.x 지원). 구조체 필드 오프셋(executable=24, exec_argv=32)은 LP64 ABI에서 비롯되며, enable_debug_cmd는 하드코딩이 아닌 런타임에 발견되고 검증됩니다.
32비트 대상은 명시적으로 거부됩니다. 스테이지 0에서(페이로드는 64비트 포인터를 구축하므로) 나중에 불명확하게 실패하는 대신 처리됩니다.
기본 zipmap 경로에서 13/13 성공 실행(5 + 8 연속)을 측정했으며, 각각 ≤1초 내에 완료됩니다. 반복 실행에서만 드러난 세 가지 문제가 있으며 현재 수정되었습니다:
k) 스테이지 0의 SAVE 경합. 이전 실행(또는 redis 자체)의 백그라운드 저장이 아직 실행 중일 때 실행이 ERR Background save already in progress로 중단될 수 있었습니다. 이제 SAVE는 최대 15초 동안 재시도되며, 그래도 실패하면 중단 대신 체크포인트 없이 실행을 계속합니다.
l) 대상이 재시작되는 동안 재연결 (--connect-retries, 기본값 10). 실패한 시도는 힙을 손상된 상태로 남기므로 다음 실행의 FLUSHALL이 오염된 청크를 해제하고 서버를 다운시킵니다. 서버는 몇 초 후 재시작되며 완벽하게 익스플로잇이 가능하므로, 스테이지 0은 이제 실패하는 대신 재연결하고 재시도합니다. 자체 검증 오류(지원되지 않는 버전/아키텍처)는 재시도되지 않습니다. 이로써 스트레스 테스트 중 약 3회 실행 중 1회꼴로 발생하던 간헐적인 "stage 0 failed with an empty error"가 제거되었습니다.
m) 8.6.x용 sizeof(streamNACK) 수정. --vuln-type stream 경로는 구조체가 24 또는 32바이트라고 가정하여 잘못된 jemalloc 크기 클래스를 스프레이했습니다. 8.6.2에서는 64바이트입니다(delivery_time, delivery_count, consumer, cgroup_ref_node, streamID id, pel_prev, pel_next). 올바른 크기로 스트림 경로는 이제 스테이지 2에서 "key overlap not found"로 실패하는 대신 스테이지 5에 도달합니다.
--vuln-type stream은 8.6.2에서 신뢰할 수 없습니다. 크기 수정으로 이중 해제, 오버랩, R/W 프리미티브, ELF 파싱을 통과하지만 그 후 키스페이스를 불안정하게 만듭니다: 서버가 setrangeCommand에서 NULL+8의 o->ptr을 읽다가 죽습니다. 즉 키 조회가 손상된 객체를 반환합니다. 해제하는 64바이트 청크는 다른 활성 할당과 공유되므로 zipmap 경로보다 훨씬 더 많은 부수적 피해를 발생시킵니다. 13/13 성공한 기본 --vuln-type zipmap을 사용하십시오.
--random-heap-massage(100k 랜덤 키 먼저)는 성공하지만 항상 그런 것은 아닙니다 — 스프레이된 힙이 때때로 이중 해제된 청크를 어떤 마커 키도 도달하지 못하는 위치에 배치합니다. 다시 실행하면 성공합니다.
**aarch64 / Rocky Linux 8.10 / Redis 8.6.2(비-PIE ET_EXEC)**에서만 검증되었습니다. x86-64 및 PIE 경로는 구현되었고 설계상 아키텍처 중립적이지만 이번 세션에서는 실제 대상에 대해 실행되지 않았습니다.