1#!/usr/bin/env python3 2# Part of the aflplusplus project, requires Python 3.8+. 3# Author: Chris Ball <[email protected]>, ported from Marc "van Hauser" Heuse's "benchmark.sh". 4import argparse, asyncio, json, multiprocessing, os, platform, re, shutil, sys 5from dataclasses import asdict, dataclass 6from decimal import Decimal 7from enum import Enum, auto 8from pathlib import Path 9from typing import Dict, List, Optional, Tuple 10 11blue = lambda text: f"\033[1;94m{text}\033[0m"; gray = lambda text: f"\033[1;90m{text}\033[0m" 12green = lambda text: f"\033[0;32m{text}\033[0m"; red = lambda text: f"\033[0;31m{text}\033[0m" 13yellow = lambda text: f"\033[0;33m{text}\033[0m" 14 15class Mode(Enum): 16 multicore = auto() 17 singlecore = auto() 18 19@dataclass 20class Target: 21 source: Path 22 binary: Path 23 24@dataclass 25class Run: 26 execs_per_sec: float 27 execs_total: float 28 fuzzers_used: int 29 30@dataclass 31class Config: 32 afl_persistent_config: bool 33 afl_system_config: bool 34 afl_version: Optional[str] 35 comment: str 36 compiler: str 37 target_arch: str 38 39@dataclass 40class Hardware: 41 cpu_fastest_core_mhz: float 42 cpu_model: str 43 cpu_threads: int 44 45@dataclass 46class Results: 47 config: Optional[Config] 48 hardware: Optional[Hardware] 49 targets: Dict[str, Dict[str, Optional[Run]]] 50 51all_modes = [Mode.singlecore, Mode.multicore] 52all_targets = [ 53 Target(source=Path("../utils/persistent_mode/test-instr.c").resolve(), binary=Path("test-instr-persist-shmem")), 54 Target(source=Path("../test-instr.c").resolve(), binary=Path("test-instr")) 55] 56modes = [mode.name for mode in all_modes] 57targets = [str(target.binary) for target in all_targets] 58cpu_count = multiprocessing.cpu_count() 59env_vars = { 60 "AFL_DISABLE_TRIM": "1", "AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES": "1", "AFL_FAST_CAL": "1", 61 "AFL_NO_UI": "1", "AFL_TRY_AFFINITY": "1", "PATH": f'{str(Path("../").resolve())}:{os.environ["PATH"]}', 62} 63 64parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 65parser.add_argument("-b", "--basedir", help="directory to use for temp files", type=str, default="/tmp/aflpp-benchmark") 66parser.add_argument("-d", "--debug", help="show verbose debugging output", action="store_true") 67parser.add_argument("-r", "--runs", help="how many runs to average results over", type=int, default=3) 68parser.add_argument("-f", "--fuzzers", help="how many afl-fuzz workers to use", type=int, default=cpu_count) 69parser.add_argument("-m", "--mode", help="pick modes", action="append", default=modes, choices=modes) 70parser.add_argument("-c", "--comment", help="add a comment about your setup", type=str, default="") 71parser.add_argument("--cpu", help="override the detected CPU model name", type=str, default="") 72parser.add_argument("--mhz", help="override the detected CPU MHz", type=str, default="") 73parser.add_argument( 74 "-t", "--target", help="pick targets", action="append", default=["test-instr-persist-shmem"], choices=targets 75) 76args = parser.parse_args() 77# Really unsatisfying argparse behavior: we want a default and to allow multiple choices, but if there's a manual choice 78# it should override the default. Seems like we have to remove the default to get that and have correct help text? 79if len(args.target) > 1: 80 args.target = args.target[1:] 81if len(args.mode) > 2: 82 args.mode = args.mode[2:] 83 84chosen_modes = [mode for mode in all_modes if mode.name in args.mode] 85chosen_targets = [target for target in all_targets if str(target.binary) in args.target] 86results = Results(config=None, hardware=None, targets={ 87 str(t.binary): {m.name: None for m in chosen_modes} for t in chosen_targets} 88) 89debug = lambda text: args.debug and print(blue(text)) 90 91async def clean_up_tempfiles() -> None: 92 shutil.rmtree(f"{args.basedir}/in") 93 for target in chosen_targets: 94 target.binary.unlink() 95 for mode in chosen_modes: 96 shutil.rmtree(f"{args.basedir}/out-{mode.name}-{str(target.binary)}") 97 98async def check_afl_persistent() -> bool: 99 with open("/proc/cmdline", "r") as cmdline: 100 return "mitigations=off" in cmdline.read().strip().split(" ") 101 102async def check_afl_system() -> bool: 103 sysctl = next((s for s in ["sysctl", "/sbin/sysctl"] if shutil.which(s)), None) 104 if sysctl: 105 (returncode, stdout, _) = await run_command([sysctl, "kernel.randomize_va_space"]) 106 return returncode == 0 and stdout.decode().rstrip().split(" = ")[1] == "0" 107 return False 108 109async def prep_env() -> None: 110 Path(f"{args.basedir}/in").mkdir(exist_ok=True, parents=True) 111 with open(f"{args.basedir}/in/in.txt", "wb") as seed: 112 seed.write(b"\x00" * 10240) 113 114async def compile_target(source: Path, binary: Path) -> None: 115 print(f" [*] Compiling the {binary} fuzzing harness for the benchmark to use.") 116 (returncode, stdout, stderr) = await run_command( 117 [str(Path("../afl-clang-lto").resolve()), "-o", str(Path(binary.resolve())), str(Path(source).resolve())] 118 ) 119 if returncode == 0: 120 return 121 print(yellow(f" [*] afl-clang-lto was unable to compile; falling back to afl-cc.")) 122 (returncode, stdout, stderr) = await run_command( 123 [str(Path("../afl-cc").resolve()), "-o", str(Path(binary.resolve())), str(Path(source).resolve())] 124 ) 125 if returncode != 0: 126 sys.exit(red(f" [*] Error: afl-cc is unable to compile: {stderr.decode()} {stdout.decode()}")) 127 128async def run_command(cmd: List[str]) -> Tuple[Optional[int], bytes, bytes]: 129 debug(f"Launching command: {cmd} with env {env_vars}") 130 p = await asyncio.create_subprocess_exec( 131 *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env_vars 132 ) 133 stdout, stderr = await p.communicate() 134 debug(f"Output: {stdout.decode()} {stderr.decode()}") 135 return (p.returncode, stdout, stderr) 136 137async def check_deps() -> None: 138 if not (plat := platform.system()) == "Linux": sys.exit(red(f" [*] {plat} is not supported by this script yet.")) 139 if not os.access(Path("../afl-fuzz").resolve(), os.X_OK) and os.access(Path("../afl-cc").resolve(), os.X_OK) and ( 140 os.path.exists(Path("../SanitizerCoveragePCGUARD.so").resolve())): 141 sys.exit(red(" [*] Compile AFL++: we need afl-fuzz, afl-clang-fast and SanitizerCoveragePCGUARD.so built.")) 142 143 (returncode, stdout, stderr) = await run_command([str(Path("../afl-cc").resolve()), "-v"]) 144 if returncode != 0: 145 sys.exit(red(f" [*] Error: afl-cc -v returned: {stderr.decode()} {stdout.decode()}")) 146 compiler = "" 147 target_arch = "" 148 for line in stderr.decode().split("\n"): 149 if "clang version" in line: 150 compiler = line 151 elif m := re.match(r"^Target: (.*)", line): 152 target_arch = m.group(1) 153 154 # Pick some sample settings from afl-{persistent,system}-config to try to see whether they were run. 155 afl_pc = await check_afl_persistent() 156 afl_sc = await check_afl_system() 157 if not afl_pc: 158 print(yellow(f" [*] afl-persistent-config did not run; run it to improve performance (and decrease security).")) 159 if not afl_sc: 160 print(yellow(f" [*] afl-system-config did not run; run it to improve performance (and decrease security).")) 161 results.config = Config(afl_persistent_config=afl_pc, afl_system_config=afl_sc, afl_version="", 162 comment=args.comment, compiler=compiler, target_arch=target_arch) 163 164async def colon_values(filename: str, searchKey: str) -> List[str]: 165 """Return a colon-separated value given a key in a file, e.g. 'cpu MHz : 4976.109')""" 166 with open(filename, "r") as fh: 167 kv_pairs = (line.split(": ", 1) for line in fh if ": " in line) 168 v_list = [v.rstrip() for k, v in kv_pairs if k.rstrip() == searchKey] 169 return v_list 170 171async def describe_afl_config() -> str: 172 if results.config is None: 173 return "unknown" 174 elif results.config.afl_persistent_config and results.config.afl_system_config: 175 return "both" 176 elif results.config.afl_persistent_config: 177 return "persistent" 178 elif results.config.afl_system_config: 179 return "system" 180 else: 181 return "none" 182 183async def save_benchmark_results() -> None: 184 """Append a single row to the benchmark results in JSON Lines format (which is simple to write and diff).""" 185 with open("benchmark-results.jsonl", "a") as jsonfile: 186 json.dump(asdict(results), jsonfile, sort_keys=True) 187 jsonfile.write("\n") 188 print(blue(f" [*] Results have been written to the {jsonfile.name} file.")) 189 with open("COMPARISON.md", "r+") as comparisonfile: 190 described_config = await describe_afl_config() 191 aflconfig = described_config.ljust(12) 192 if results.hardware is None: 193 return 194 cpu_model = results.hardware.cpu_model.ljust(51) 195 if cpu_model in comparisonfile.read(): 196 print(blue(f" [*] Results have not been written to the COMPARISON.md file; this CPU is already present.")) 197 return 198 cpu_mhz = str(round(results.hardware.cpu_fastest_core_mhz)).ljust(5) 199 if not "test-instr-persist-shmem" in results.targets or \ 200 not "multicore" in results.targets["test-instr-persist-shmem"] or \ 201 not "singlecore" in results.targets["test-instr-persist-shmem"] or \ 202 results.targets["test-instr-persist-shmem"]["singlecore"] is None or \ 203 results.targets["test-instr-persist-shmem"]["multicore"] is None: 204 return 205 single = str(round(results.targets["test-instr-persist-shmem"]["singlecore"].execs_per_sec)).ljust(10) 206 multi = str(round(results.targets["test-instr-persist-shmem"]["multicore"].execs_per_sec)).ljust(9) 207 cores = str(args.fuzzers).ljust(7) 208 comparisonfile.write(f"{cpu_model} | {cpu_mhz} | {cores} | {single} | {multi} | {aflconfig} |\n") 209 print(blue(f" [*] Results have been written to the COMPARISON.md file.")) 210 with open("COMPARISON.md", "r") as comparisonfile: 211 print(comparisonfile.read()) 212 213 214async def main() -> None: 215 try: 216 await clean_up_tempfiles() 217 except FileNotFoundError: 218 pass 219 await check_deps() 220 if args.mhz: 221 cpu_mhz = float(args.mhz) 222 else: 223 cpu_mhz_str = await colon_values("/proc/cpuinfo", "cpu MHz") 224 if len(cpu_mhz_str) == 0: 225 cpu_mhz_str.append("0") 226 cpu_mhz = max([float(c) for c in cpu_mhz_str]) # use the fastest CPU MHz for now 227 if args.cpu: 228 cpu_model = [args.cpu] 229 else: 230 cpu_model = await colon_values("/proc/cpuinfo", "model name") or [""] 231 results.hardware = Hardware(cpu_fastest_core_mhz=cpu_mhz, cpu_model=cpu_model[0], cpu_threads=cpu_count) 232 await prep_env() 233 print(f" [*] Ready, starting benchmark...") 234 for target in chosen_targets: 235 await compile_target(target.source, target.binary) 236 binary = str(target.binary) 237 for mode in chosen_modes: 238 if mode == Mode.multicore: 239 print(blue(f" [*] Using {args.fuzzers} fuzzers for multicore fuzzing "), end="") 240 print(blue("(use --fuzzers to override)." if args.fuzzers == cpu_count else f"(the default is {cpu_count})")) 241 execs_per_sec, execs_total = ([] for _ in range(2)) 242 for run_idx in range(0, args.runs): 243 print(gray(f" [*] {mode.name} {binary} run {run_idx+1} of {args.runs}, execs/s: "), end="", flush=True) 244 fuzzers = range(0, args.fuzzers if mode == Mode.multicore else 1) 245 outdir = f"{args.basedir}/out-{mode.name}-{binary}" 246 cmds = [] 247 for fuzzer_idx, afl in enumerate(fuzzers): 248 name = ["-o", outdir, "-M" if fuzzer_idx == 0 else "-S", str(afl)] 249 cmds.append(["afl-fuzz", "-i", f"{args.basedir}/in"] + name + ["-s", "123", "-V10", "-D", f"./{binary}"]) 250 # Prepare the afl-fuzz tasks, and then block while waiting for them to finish. 251 fuzztasks = [run_command(cmds[cpu]) for cpu in fuzzers] 252 await asyncio.gather(*fuzztasks) 253 afl_versions = await colon_values(f"{outdir}/0/fuzzer_stats", "afl_version") 254 if results.config: 255 results.config.afl_version = afl_versions[0] 256 # Our score is the sum of all execs_per_sec entries in fuzzer_stats files for the run. 257 sectasks = [colon_values(f"{outdir}/{afl}/fuzzer_stats", "execs_per_sec") for afl in fuzzers] 258 all_execs_per_sec = await asyncio.gather(*sectasks) 259 execs = sum([Decimal(count[0]) for count in all_execs_per_sec]) 260 print(green(execs)) 261 execs_per_sec.append(execs) 262 # Also gather execs_total and total_run_time for this run. 263 exectasks = [colon_values(f"{outdir}/{afl}/fuzzer_stats", "execs_done") for afl in fuzzers] 264 all_execs_total = await asyncio.gather(*exectasks) 265 execs_total.append(sum([Decimal(count[0]) for count in all_execs_total])) 266 267 # (Using float() because Decimal() is not JSON-serializable.) 268 avg_afl_execs_per_sec = round(Decimal(sum(execs_per_sec) / len(execs_per_sec)), 2) 269 afl_execs_total = int(sum([Decimal(execs) for execs in execs_total])) 270 run = Run(execs_per_sec=float(avg_afl_execs_per_sec), execs_total=afl_execs_total, fuzzers_used=len(fuzzers)) 271 results.targets[binary][mode.name] = run 272 print(f" [*] Average execs/sec for this test across all runs was: {green(avg_afl_execs_per_sec)}") 273 if (((max(execs_per_sec) - min(execs_per_sec)) / avg_afl_execs_per_sec) * 100) > 15: 274 print(yellow(" [*] The difference between your slowest and fastest runs was >15%, maybe try again?")) 275 276 await clean_up_tempfiles() 277 await save_benchmark_results() 278 279if __name__ == "__main__": 280 asyncio.run(main()) 281 282