209 lines
6.9 KiB
Python
209 lines
6.9 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""build and run the libFuzzer harness for the .rc config parser.
|
||
|
|
|
||
|
|
usage:
|
||
|
|
python3 tools/fuzz.py [--seconds N] [--build-only] [--corpus DIR]
|
||
|
|
[--dict PATH|--no-dict] [--no-detect-leaks]
|
||
|
|
[--quarantine-mb N]
|
||
|
|
|
||
|
|
defaults to 60 seconds of fuzzing against a small seed corpus. Seed corpus
|
||
|
|
files land in build/fuzz-corpus (recreated only when empty so you can add your
|
||
|
|
own); crashing inputs are saved under build/fuzz- and reported at the end.
|
||
|
|
Leak detection is on by default (tools/lsan.supp silences a torsocks
|
||
|
|
LD_PRELOAD false positive on the dev host); --no-detect-leaks disables it.
|
||
|
|
ASan's freed-memory quarantine (the fuzz peak-RSS driver) defaults to 64MiB;
|
||
|
|
override with --quarantine-mb.
|
||
|
|
|
||
|
|
requires clang++ (libFuzzer's -fsanitize=fuzzer is a clang feature).
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import glob
|
||
|
|
import os
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parent.parent
|
||
|
|
TESTS = ROOT / "tests"
|
||
|
|
BUILD = ROOT / "build"
|
||
|
|
OUT = BUILD / "fuzz_config"
|
||
|
|
CORPUS = BUILD / "fuzz-corpus"
|
||
|
|
DICT = BUILD / "fuzz.dict"
|
||
|
|
|
||
|
|
# restart wildcard entries are not allowed in a dict; keep tokens plain ASCII.
|
||
|
|
SEED_DICT = r"""# bajia rc grammar: libFuzzer dictionary (-dict), regenerated only if missing
|
||
|
|
"service"
|
||
|
|
"on"
|
||
|
|
"@import"
|
||
|
|
"oneshot"
|
||
|
|
"disabled"
|
||
|
|
"console"
|
||
|
|
"user"
|
||
|
|
"group"
|
||
|
|
"class"
|
||
|
|
"respawn"
|
||
|
|
"crash-threshold"
|
||
|
|
"crash-window"
|
||
|
|
"setenv"
|
||
|
|
"cwd"
|
||
|
|
"seclabel"
|
||
|
|
"="
|
||
|
|
"start"
|
||
|
|
"stop"
|
||
|
|
"restart"
|
||
|
|
"exec"
|
||
|
|
"mkdir"
|
||
|
|
"chmod"
|
||
|
|
"chown"
|
||
|
|
"write"
|
||
|
|
"symlink"
|
||
|
|
"mount"
|
||
|
|
"log"
|
||
|
|
"early-init"
|
||
|
|
"init"
|
||
|
|
"boot"
|
||
|
|
"shutdown"
|
||
|
|
"always"
|
||
|
|
"never"
|
||
|
|
"on-failure"
|
||
|
|
"root"
|
||
|
|
"nobody"
|
||
|
|
"daemon"
|
||
|
|
"/bin/true"
|
||
|
|
"/bin/false"
|
||
|
|
"/bin/sh"
|
||
|
|
"/usr/sbin/sshd"
|
||
|
|
"/etc/bajia/init.rc"
|
||
|
|
"/proc"
|
||
|
|
"/sys"
|
||
|
|
"/dev"
|
||
|
|
"/tmp"
|
||
|
|
"0"
|
||
|
|
"0755"
|
||
|
|
"#"
|
||
|
|
";"
|
||
|
|
# __bajia_seed_dict__
|
||
|
|
"""
|
||
|
|
|
||
|
|
SEEDS = {
|
||
|
|
"tiny.rc": b"on boot\n log hi\n",
|
||
|
|
"service.rc": b"service sshd /usr/sbin/sshd -D\n user = root\n respawn = always\n console\n",
|
||
|
|
"alloptions.rc": (
|
||
|
|
b"service s /bin/true\n"
|
||
|
|
b" user = nobody\n group = nobody daemon\n"
|
||
|
|
b" class = main\n oneshot\n disabled\n console\n"
|
||
|
|
b" respawn = never\n crash-threshold = 4\n crash-window = 30\n"
|
||
|
|
b" setenv = FOO=bar\n cwd = /tmp\n seclabel = system_u:object_r:root_t:s0\n"
|
||
|
|
),
|
||
|
|
"quotes.rc": b"write /tmp/x \"a b\\\"c\"\non boot\n exec /bin/sh -c \"echo quoted\"\n",
|
||
|
|
"import.rc": b"@import /etc/bajia/init.rc\non init\n log imported\n",
|
||
|
|
"nested.rc": (
|
||
|
|
b"on early-init\n mount proc /proc proc\n mount sysfs /sys sysfs\n"
|
||
|
|
b"on init\n write /proc/sys/kernel/hostname bajia\n"
|
||
|
|
b"on boot\n start console-serial\n"
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
def find_clang() -> str | None:
|
||
|
|
explicit = os.environ.get("BAJIA_FUZZ_CXX")
|
||
|
|
if explicit and shutil.which(explicit):
|
||
|
|
return explicit
|
||
|
|
if shutil.which("clang++"):
|
||
|
|
return "clang++"
|
||
|
|
return None
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
|
|
ap.add_argument("--seconds", type=int, default=60, help="fuzz time budget")
|
||
|
|
ap.add_argument("--corpus", type=Path,
|
||
|
|
help="corpus dir (default: build/fuzz-corpus)")
|
||
|
|
ap.add_argument("--dict", type=Path,
|
||
|
|
help="custom fuzzer dictionary (default: build/fuzz.dict, "
|
||
|
|
"seeded from the rc grammar on first use)")
|
||
|
|
ap.add_argument("--no-dict", action="store_true",
|
||
|
|
help="run without a dictionary")
|
||
|
|
ap.add_argument("--build-only", action="store_true",
|
||
|
|
help="build the harness and stop")
|
||
|
|
ap.add_argument("--detect-leaks", action="store_true",
|
||
|
|
help=argparse.SUPPRESS) # historical no-op; already default
|
||
|
|
ap.add_argument("--no-detect-leaks", action="store_true",
|
||
|
|
help="disable leak detection (default on: exits nonzero on "
|
||
|
|
"leaks; tools/lsan.supp silences a torsocks "
|
||
|
|
"LD_PRELOAD false positive on the dev host)")
|
||
|
|
ap.add_argument("--quarantine-mb", type=int, default=64, metavar="N",
|
||
|
|
help="ASan freed-memory quarantine in MiB (default 64; "
|
||
|
|
"ASan's own default of 256 dominates fuzz peak RSS. "
|
||
|
|
"0 disables quarantine, which recycles freed memory "
|
||
|
|
"immediately but weakens use-after-free detection)")
|
||
|
|
args = ap.parse_args()
|
||
|
|
|
||
|
|
clang = find_clang()
|
||
|
|
if not clang:
|
||
|
|
sys.exit("clang++ not found: libFuzzer (-fsanitize=fuzzer) requires clang")
|
||
|
|
|
||
|
|
BUILD.mkdir(exist_ok=True)
|
||
|
|
subprocess.run([
|
||
|
|
clang, "-std=c++20", "-O1", "-g",
|
||
|
|
"-fsanitize=fuzzer,address,undefined", "-fno-omit-frame-pointer",
|
||
|
|
str(TESTS / "fuzz_config.cpp"), "-o", str(OUT),
|
||
|
|
], check=True)
|
||
|
|
print("built:", OUT)
|
||
|
|
if args.build_only:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
corpus = args.corpus or CORPUS
|
||
|
|
if corpus.is_dir() and any(corpus.iterdir()):
|
||
|
|
print("using existing corpus:", corpus)
|
||
|
|
else:
|
||
|
|
corpus.mkdir(parents=True, exist_ok=True)
|
||
|
|
for name, blob in SEEDS.items():
|
||
|
|
(corpus / name).write_bytes(blob)
|
||
|
|
print("seeded corpus:", corpus)
|
||
|
|
|
||
|
|
prefix = BUILD / "fuzz-"
|
||
|
|
prefix.mkdir(exist_ok=True)
|
||
|
|
flags = [f"-artifact_prefix={prefix}/", "-print_final_stats=1"]
|
||
|
|
if not args.no_dict:
|
||
|
|
if args.dict:
|
||
|
|
dict_path = args.dict
|
||
|
|
else:
|
||
|
|
if not DICT.is_file() or "__bajia_seed_dict__" not in DICT.read_text(errors="ignore"):
|
||
|
|
DICT.write_text(SEED_DICT) # (re)seed a missing or stale copy
|
||
|
|
dict_path = DICT
|
||
|
|
if not dict_path.is_file():
|
||
|
|
sys.exit(f"--dict: file not found: {dict_path}")
|
||
|
|
flags.append(f"-dict={dict_path}")
|
||
|
|
print("dictionary:", dict_path)
|
||
|
|
env = dict(os.environ)
|
||
|
|
asan = f"quarantine_size_mb={args.quarantine_mb}"
|
||
|
|
if args.no_detect_leaks:
|
||
|
|
flags.append("-detect_leaks=0")
|
||
|
|
asan = "detect_leaks=0:" + asan
|
||
|
|
else:
|
||
|
|
asan = "detect_leaks=1:" + asan
|
||
|
|
supp = ROOT / "tools" / "lsan.supp"
|
||
|
|
env["LSAN_OPTIONS"] = f"suppressions={supp}" + (":" + env["LSAN_OPTIONS"]
|
||
|
|
if env.get("LSAN_OPTIONS") else "")
|
||
|
|
print("leak detection on (suppression:", supp, ")")
|
||
|
|
env["ASAN_OPTIONS"] = asan
|
||
|
|
cmd = [str(OUT), f"-max_total_time={args.seconds}"] + flags + [str(corpus)]
|
||
|
|
print("$", " ".join(cmd))
|
||
|
|
r = subprocess.run(cmd, env=env)
|
||
|
|
|
||
|
|
crashes = sorted(glob.glob(str(prefix) + "crash-*"))
|
||
|
|
leaks = sorted(glob.glob(str(prefix) + "leak-*"))
|
||
|
|
timeouts = sorted(glob.glob(str(prefix) + "timeout-*"))
|
||
|
|
if crashes or leaks or timeouts:
|
||
|
|
for art in crashes + leaks + timeouts:
|
||
|
|
print("artifact:", art)
|
||
|
|
print("!! fuzzer found problems (see artifact files above)")
|
||
|
|
return 1
|
||
|
|
print("no crashes in", args.seconds, "seconds")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|