fuzz/memcache_mutator.py
Rama main · réplica de lectura del repositorio original.
"""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