Ctrl K
ComunidadGuías…

fuzz/memcache_mutator.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""AFL++ custom mutator for memcache text protocol.

Mutates at the command level instead of random bytes,
keeping memcache protocol framing valid.

Usage:
    export PYTHONPATH=/path/to/dragonfly/fuzz
    export AFL_PYTHON_MODULE=memcache_mutator
    afl-fuzz ...
"""

import random

# fmt: off
# (command, type, min_extra_args, max_extra_args)
# type: "store" = key flags exptime bytes [noreply]\r\ndata\r\n
#       "cas"   = key flags exptime bytes cas_unique [noreply]\r\ndata\r\n
#       "get"   = key [key ...]\r\n
#       "gat"   = exptime key [key ...]\r\n
#       "delta" = key delta [noreply]\r\n
#       "del"   = key [noreply]\r\n
#       "bare"  = \r\n (no args)
#       "meta_store" = key datalen [flags...]\r\ndata\r\n
#       "meta"  = key [flags...]\r\n

COMMANDS = [
    # Store commands
    ("set",     "store"),
    ("add",     "store"),
    ("replace", "store"),
    ("append",  "store"),
    ("prepend", "store"),
    # cas dispatch is dead (CLIENT_ERROR) but exercises ParseStore's CAS branch.
    ("cas",     "cas"),
    # Retrieval
    ("get",     "get"),
    ("gets",    "get"),
    ("gat",     "gat"),
    ("gats",    "gat"),
    # Delete / arithmetic
    ("delete",  "del"),
    ("incr",    "delta"),
    ("decr",    "delta"),
    # Utility. flush_all omitted: maps to restricted FLUSHDB (always SERVER_ERROR).
    ("stats",     "bare"),
    ("version",   "bare"),
    ("quit",      "bare"),
    # Meta. mn/me omitted: unconditionally rejected by parser/dispatch.
    ("ms",      "meta_store"),
    ("mg",      "meta"),
    ("md",      "meta"),
    ("ma",      "meta_arithm"),
]
# fmt: on

KEYS = [b"k", b"key", b"k1", b"k2", b"k3", b"mykey", b"counter", b"buf"]
VALUES = [b"abc", b"hello", b"x", b"", b"0", b"12345", b"\x00\xff", b"a" * 100]
EXPIRY = [b"0", b"10", b"100", b"3600", b"9999999"]
FLAGS = [b"0", b"1", b"255", b"65535", b"4294967295"]
DELTAS = [b"1", b"5", b"10", b"100", b"0", b"99999999999"]
# Only flags ParseMeta accepts; anything else is PARSE_ERROR.
META_FLAGS = [b"T30", b"T0", b"F7", b"v", b"h", b"l", b"t", b"c", b"f", b"q"]
MS_MODES = [b"MS", b"ME", b"MA", b"MR", b"MP"]  # ms mode flags
MA_FLAGS = [b"D5", b"D10", b"MI", b"MD", b"T30", b"q", b"v"]  # ma (meta-arithmetic)
FUZZ_VALUES = [b"\x00", b"\xff" * 4, b"\r\n", b"A" * 256, b"-1", b"NaN"]


def init(seed):
    random.seed(seed)


def _random_key():
    if random.random() < 0.8:
        return random.choice(KEYS)
    return random.choice(FUZZ_VALUES)


def _random_value():
    if random.random() < 0.7:
        return random.choice(VALUES)
    return random.choice(FUZZ_VALUES)


def _random_command():
    """Generate a single random memcache command."""
    cmd_name, cmd_type = random.choice(COMMANDS)
    cmd = cmd_name.encode() if isinstance(cmd_name, str) else cmd_name

    if cmd_type == "store":
        key = _random_key()
        flags = random.choice(FLAGS)
        expiry = random.choice(EXPIRY)
        value = _random_value()
        noreply = b" noreply" if random.random() < 0.3 else b""
        return (
            cmd
            + b" "
            + key
            + b" "
            + flags
            + b" "
            + expiry
            + b" "
            + str(len(value)).encode()
            + noreply
            + b"\r\n"
            + value
            + b"\r\n"
        )

    elif cmd_type == "cas":
        key = _random_key()
        flags = random.choice(FLAGS)
        expiry = random.choice(EXPIRY)
        value = _random_value()
        cas_id = str(random.randint(0, 99999)).encode()
        noreply = b" noreply" if random.random() < 0.3 else b""
        return (
            cmd
            + b" "
            + key
            + b" "
            + flags
            + b" "
            + expiry
            + b" "
            + str(len(value)).encode()
            + b" "
            + cas_id
            + noreply
            + b"\r\n"
            + value
            + b"\r\n"
        )

    elif cmd_type == "get":
        nkeys = random.randint(1, 4)
        keys = b" ".join(_random_key() for _ in range(nkeys))
        return cmd + b" " + keys + b"\r\n"

    elif cmd_type == "gat":
        expiry = random.choice(EXPIRY)
        nkeys = random.randint(1, 3)
        keys = b" ".join(_random_key() for _ in range(nkeys))
        return cmd + b" " + expiry + b" " + keys + b"\r\n"

    elif cmd_type == "delta":
        key = _random_key()
        delta = random.choice(DELTAS)
        noreply = b" noreply" if random.random() < 0.3 else b""
        return cmd + b" " + key + b" " + delta + noreply + b"\r\n"

    elif cmd_type == "del":
        key = _random_key()
        noreply = b" noreply" if random.random() < 0.3 else b""
        return cmd + b" " + key + noreply + b"\r\n"

    elif cmd_type == "meta_store":
        key = _random_key()
        value = _random_value()
        flags = random.sample(META_FLAGS, random.randint(0, 3))
        if random.random() < 0.4:
            flags.append(random.choice(MS_MODES))
        extra = (b" " + b" ".join(flags)) if flags else b""
        return (
            cmd + b" " + key + b" " + str(len(value)).encode() + extra + b"\r\n" + value + b"\r\n"
        )

    elif cmd_type == "meta_arithm":
        key = _random_key()
        flags = random.sample(MA_FLAGS, random.randint(0, 2))
        extra = (b" " + b" ".join(flags)) if flags else b""
        return cmd + b" " + key + extra + b"\r\n"

    elif cmd_type == "meta":
        key = _random_key()
        meta_flags = b" ".join(random.sample(META_FLAGS, random.randint(0, 3)))
        extra = (b" " + meta_flags) if meta_flags else b""
        return cmd + b" " + key + extra + b"\r\n"

    else:  # bare
        return cmd + b"\r\n"


def _parse_mc_commands(buf, strict=False):
    """Parse memcache text protocol into (commands, success), each command a
    (line, data_block) pair.

    strict=False is the fuzzing hot path (best-effort). strict=True is the CI
    gate: a store/ms data block that is missing, truncated, or not CRLF-
    terminated is a hard failure and the whole buffer must be consumed, matching
    what the server accepts."""
    commands = []
    data = bytes(buf)
    pos = 0

    STORE = (b"set", b"add", b"replace", b"append", b"prepend", b"cas")

    while pos < len(data):
        end = data.find(b"\r\n", pos)
        if end < 0:
            if strict:
                return ([], False)
            break

        line = data[pos:end]
        pos = end + 2

        parts = line.split(b" ")
        name = parts[0].lower() if parts else b""
        # Byte-count field: index 4 for classic stores/cas, index 2 for meta-set.
        nbytes_idx = None
        if len(parts) >= 5 and name in STORE:
            nbytes_idx = 4
        elif len(parts) >= 3 and name == b"ms":
            nbytes_idx = 2

        if nbytes_idx is not None:
            try:
                nbytes = int(parts[nbytes_idx])
            except (ValueError, IndexError):
                if strict:
                    return ([], False)
                commands.append((line, None))
                continue
            if pos + nbytes + 2 <= len(data) and data[pos + nbytes : pos + nbytes + 2] == b"\r\n":
                value = data[pos : pos + nbytes]
                pos += nbytes + 2
                commands.append((line, value))
                continue
            if strict:
                return ([], False)

        commands.append((line, None))

    if strict:
        return (commands, len(commands) > 0 and pos == len(data))
    return (commands, len(commands) > 0)


def _commands_to_bytes(commands):
    """Serialize parsed commands back to memcache protocol bytes."""
    parts = []
    for line, value in commands:
        parts.append(line + b"\r\n")
        if value is not None:
            parts.append(value + b"\r\n")
    return b"".join(parts)


def _mutate_commands(commands):
    """Apply random mutations to parsed memcache commands."""
    result = list(commands)

    mutation = random.random()

    if mutation < 0.25 and len(result) > 0:
        # Replace a command entirely
        idx = random.randint(0, len(result) - 1)
        new_cmd = _random_command()
        # Parse the generated command back
        parsed, _ = _parse_mc_commands(new_cmd)
        if parsed:
            result[idx] = parsed[0]

    elif mutation < 0.45 and len(result) > 0:
        # Mutate a key or value in a command
        idx = random.randint(0, len(result) - 1)
        line, value = result[idx]
        parts = line.split(b" ")
        if len(parts) >= 2:
            cmd = parts[0].lower()
            # Mutate the correct key index depending on command
            if cmd in (b"gat", b"gats") and len(parts) >= 3:
                key_idx = random.randint(2, len(parts) - 1)
                parts[key_idx] = _random_key()
            else:
                parts[1] = _random_key()
            if value is not None:
                new_value = _random_value()
                # Update byte count in the header
                length_idx = None
                if cmd == b"ms" and len(parts) >= 3:
                    length_idx = 2
                elif len(parts) >= 5:
                    length_idx = 4
                if length_idx is not None:
                    try:
                        int(parts[length_idx])
                        parts[length_idx] = str(len(new_value)).encode()
                    except ValueError:
                        pass
                value = new_value
            result[idx] = (b" ".join(parts), value)

    elif mutation < 0.6:
        # Insert a new random command
        new_cmd = _random_command()
        parsed, _ = _parse_mc_commands(new_cmd)
        if parsed:
            pos = random.randint(0, len(result))
            result.insert(pos, parsed[0])

    elif mutation < 0.7 and len(result) > 1:
        # Remove a command
        idx = random.randint(0, len(result) - 1)
        result.pop(idx)

    elif mutation < 0.8 and len(result) >= 2:
        # Swap two commands
        i, j = random.sample(range(len(result)), 2)
        result[i], result[j] = result[j], result[i]

    elif mutation < 0.9 and len(result) > 0:
        # Duplicate a command
        idx = random.randint(0, len(result) - 1)
        result.insert(idx + 1, result[idx])

    else:
        # Toggle noreply on a command
        if len(result) > 0:
            idx = random.randint(0, len(result) - 1)
            line, value = result[idx]
            if line.endswith(b" noreply"):
                line = line[:-8]
            else:
                line = line + b" noreply"
            result[idx] = (line, value)

    return result


def fuzz(buf, add_buf, max_size):
    """Main mutation function called by AFL++."""
    commands, ok = _parse_mc_commands(buf)

    if ok and commands:
        mutated = _mutate_commands(commands)
        result = _commands_to_bytes(mutated)
    else:
        n = random.randint(1, 5)
        result = b"".join(_random_command() for _ in range(n))

    if len(result) > max_size:
        result = result[:max_size]
    return bytearray(result)


def havoc_mutation(buf, max_size):
    """Called during havoc stage."""
    commands, ok = _parse_mc_commands(buf)
    if not ok or not commands:
        return bytearray(_random_command()[:max_size])

    mutated = _mutate_commands(commands)
    result = _commands_to_bytes(mutated)
    if len(result) > max_size:
        result = result[:max_size]
    return bytearray(result)


def havoc_mutation_probability():
    return 50