From c73b67f46ea491d90c3e50a525ed860a346888a4 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Fri, 6 Mar 2026 10:46:29 -0800 Subject: [PATCH] Parallel fuzzing --- actions/cairn-zig-fuzz-afl/action.yml | 275 +++++++++++++++++--------- 1 file changed, 183 insertions(+), 92 deletions(-) diff --git a/actions/cairn-zig-fuzz-afl/action.yml b/actions/cairn-zig-fuzz-afl/action.yml index d8d3cf5..ae3ca1d 100644 --- a/actions/cairn-zig-fuzz-afl/action.yml +++ b/actions/cairn-zig-fuzz-afl/action.yml @@ -24,7 +24,7 @@ inputs: varint_decode required: true fuzz_binary: - description: 'Binary name in zig-out/bin/ (auto-detected if only one)' + description: 'Binary name in the build output bin/ directory (auto-detected if only one)' required: false default: '' seed_dir: @@ -90,65 +90,114 @@ runs: set -eu echo "Cairn CLI version: $(cairn version)" + NCPU=$(nproc 2>/dev/null || echo 1) + echo "Available cores: ${NCPU}" - TOTAL_CRASHES=0 - TARGET_NUM=0 - ACTIVE_RUN_ID="" + RESULTS_DIR=$(mktemp -d) + trap 'rm -rf "${RESULTS_DIR}"' EXIT - # Single EXIT trap that finishes whatever run is active. - cleanup() { - if [ -n "${ACTIVE_RUN_ID}" ]; then - echo "Finishing run ${ACTIVE_RUN_ID} (cleanup)..." - cairn run finish -server "${CAIRN_SERVER}" -id "${ACTIVE_RUN_ID}" || true - ACTIVE_RUN_ID="" + # ── Collect target names ── + TARGET_NAMES=() + while IFS= read -r line; do + line=$(echo "${line}" | sed 's/#.*//' | xargs) + [ -z "${line}" ] && continue + TARGET_NAMES+=("${line}") + done </dev/null || true - echo "Copied ${REPO_SEED_COUNT} repo seeds from ${SEED_DIR}/${FUZZ_TARGET}" + echo " Copied ${REPO_SEED_COUNT} repo seeds" fi fi + # Download existing corpus. DL_START=$(date +%s) cairn corpus download \ -server "${CAIRN_SERVER}" \ @@ -156,64 +205,53 @@ runs: -dir "${SEEDS}" || true DL_ELAPSED=$(( $(date +%s) - DL_START )) DL_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l) - echo "Downloaded ${DL_COUNT} corpus entries (${DL_ELAPSED}s)" + echo " Corpus: ${DL_COUNT} entries (${DL_ELAPSED}s)" if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then printf 'A' > "${SEEDS}/seed-0" fi - # ── Build ── - rm -rf zig-out - zig build ${BUILD_ARGS} - - # ── Locate fuzz binary ── - if [ -n "${FUZZ_BINARY}" ]; then - FUZZ_BIN="zig-out/bin/${FUZZ_BINARY}" - else - BIN_COUNT=$(find zig-out/bin -maxdepth 1 -type f -executable 2>/dev/null | wc -l) - if [ "${BIN_COUNT}" -eq 0 ]; then - echo "ERROR: No executable found in zig-out/bin/" - exit 1 - elif [ "${BIN_COUNT}" -gt 1 ]; then - echo "ERROR: Multiple executables in zig-out/bin/, specify fuzz_binary input" - find zig-out/bin -maxdepth 1 -type f -executable - exit 1 - fi - FUZZ_BIN=$(find zig-out/bin -maxdepth 1 -type f -executable) - fi - - if [ ! -x "${FUZZ_BIN}" ]; then - echo "ERROR: Fuzz binary not found or not executable: ${FUZZ_BIN}" - exit 1 - fi - echo "Fuzz binary: ${FUZZ_BIN}" - - # ── Minimize corpus ── + # Minimize corpus. SEED_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l) if [ "${SEED_COUNT}" -gt 1 ]; then - echo "Minimizing corpus (${SEED_COUNT} inputs)..." + echo " Minimizing corpus (${SEED_COUNT} inputs)..." CMIN_START=$(date +%s) - MINIMIZED="afl-cmin-${TARGET_NUM}" + MINIMIZED="work/${FUZZ_TARGET}/minimized" rm -rf "${MINIMIZED}" mkdir -p "${MINIMIZED}" if afl-cmin -i "${SEEDS}" -o "${MINIMIZED}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then CMIN_ELAPSED=$(( $(date +%s) - CMIN_START )) MINIMIZED_COUNT=$(find "${MINIMIZED}" -maxdepth 1 -type f | wc -l) - echo "Corpus minimized: ${SEED_COUNT} -> ${MINIMIZED_COUNT} inputs (${CMIN_ELAPSED}s)" + echo " Minimized: ${SEED_COUNT} -> ${MINIMIZED_COUNT} inputs (${CMIN_ELAPSED}s)" rm -rf "${SEEDS}" mv "${MINIMIZED}" "${SEEDS}" else - echo "afl-cmin failed, using unminimized corpus" + echo " afl-cmin failed, using unminimized corpus" rm -rf "${MINIMIZED}" fi fi + done + + # ── Phase 3: Fuzz all targets in parallel ── + echo "" + echo "==========================================" + echo "Fuzzing ${TARGET_COUNT} target(s) in parallel (${NCPU} cores, ${DURATION}s each)" + echo "==========================================" + + # Function that runs in a background subshell per target. + fuzz_target() { + local FUZZ_TARGET="$1" + local FUZZ_BIN="$2" + local SEEDS="$3" + local FINDINGS="$4" + local RESULT_FILE="$5" + local SEED_COUNT="$6" - # ── Run AFL++ ── - FINDINGS="findings-${TARGET_NUM}" - rm -rf "${FINDINGS}" mkdir -p "${FINDINGS}" + echo "[${FUZZ_TARGET}] Starting AFL++ (${SEED_COUNT} seeds, ${DURATION}s)" - AFL_EXIT=0 + local FUZZ_START=$(date +%s) + local AFL_EXIT=0 { AFL_NO_UI=1 \ AFL_SKIP_CPUFREQ=1 \ @@ -226,16 +264,75 @@ runs: ${EXTRA_AFL_ARGS} \ -- "${FUZZ_BIN}" } >/dev/null 2>&1 || AFL_EXIT=$? + local FUZZ_ELAPSED=$(( $(date +%s) - FUZZ_START )) - if [ "${AFL_EXIT}" -eq 0 ]; then - echo "AFL++ exited normally (completed run)" - elif [ "${AFL_EXIT}" -eq 1 ]; then - echo "AFL++ exited after reaching duration limit (${DURATION}s)" + # Summary from fuzzer_stats. + local STATS_FILE="${FINDINGS}/default/fuzzer_stats" + if [ -f "${STATS_FILE}" ]; then + local EXECS=$(grep -oP 'execs_done\s*:\s*\K\d+' "${STATS_FILE}" || echo "?") + local PATHS=$(grep -oP 'corpus_count\s*:\s*\K\d+' "${STATS_FILE}" || echo "?") + local CRASHES=$(grep -oP 'saved_crashes\s*:\s*\K\d+' "${STATS_FILE}" || echo "?") + local SPEED=$(grep -oP 'execs_per_sec\s*:\s*\K[\d.]+' "${STATS_FILE}" || echo "?") + local COVERAGE=$(grep -oP 'bitmap_cvg\s*:\s*\K[\d.]+%' "${STATS_FILE}" || echo "?") + echo "[${FUZZ_TARGET}] Finished in ${FUZZ_ELAPSED}s: ${EXECS} execs (${SPEED}/s), ${PATHS} paths, ${CRASHES} crashes, ${COVERAGE} coverage (exit ${AFL_EXIT})" else - echo "AFL++ exited with code ${AFL_EXIT}" + echo "[${FUZZ_TARGET}] AFL++ exited with code ${AFL_EXIT} after ${FUZZ_ELAPSED}s" fi - # ── Upload crashes ── + # Count crashes for the result file. + local CRASH_COUNT=0 + local CRASH_DIR="${FINDINGS}/default/crashes" + if [ -d "${CRASH_DIR}" ]; then + CRASH_COUNT=$(find "${CRASH_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l) + fi + echo "${CRASH_COUNT}" > "${RESULT_FILE}" + } + + FUZZ_PHASE_START=$(date +%s) + PIDS=() + for i in "${!TARGET_NAMES[@]}"; do + FUZZ_TARGET="${TARGET_NAMES[$i]}" + FUZZ_BIN="${TARGET_BINS[${FUZZ_TARGET}]}" + SEEDS="work/${FUZZ_TARGET}/seeds" + FINDINGS="work/${FUZZ_TARGET}/findings" + RESULT_FILE="${RESULTS_DIR}/${FUZZ_TARGET}" + SEED_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l) + + fuzz_target "${FUZZ_TARGET}" "${FUZZ_BIN}" "${SEEDS}" "${FINDINGS}" "${RESULT_FILE}" "${SEED_COUNT}" & + PIDS+=($!) + + # Limit parallelism to available cores. + if [ "${#PIDS[@]}" -ge "${NCPU}" ]; then + wait "${PIDS[0]}" + PIDS=("${PIDS[@]:1}") + fi + done + + # Wait for remaining jobs. + for pid in "${PIDS[@]}"; do + wait "${pid}" + done + FUZZ_PHASE_ELAPSED=$(( $(date +%s) - FUZZ_PHASE_START )) + echo "Fuzzing phase completed in ${FUZZ_PHASE_ELAPSED}s" + + # ── Phase 4: Upload results sequentially ── + echo "" + echo "==========================================" + echo "Uploading results" + echo "==========================================" + + TOTAL_CRASHES=0 + for i in "${!TARGET_NAMES[@]}"; do + FUZZ_TARGET="${TARGET_NAMES[$i]}" + NUM=$((i + 1)) + FUZZ_BIN="${TARGET_BINS[${FUZZ_TARGET}]}" + RUN_ID="${RUN_IDS[${FUZZ_TARGET}]}" + CAIRN_TARGET_ID="${CAIRN_TARGET_IDS[${FUZZ_TARGET}]}" + FINDINGS="work/${FUZZ_TARGET}/findings" + + echo "[${NUM}/${TARGET_COUNT}] ${FUZZ_TARGET}..." + + # Upload crashes. CRASH_DIR="${FINDINGS}/default/crashes" if [ -d "${CRASH_DIR}" ]; then for crash_file in "${CRASH_DIR}"/id:*; do @@ -255,14 +352,13 @@ runs: REPLAY_OUTPUT=$(timeout 10 "${FUZZ_BIN}" < "${crash_file}" 2>&1 || true) if [ -n "${REPLAY_OUTPUT}" ]; then STACK_TRACE="${REPLAY_OUTPUT}" - # Extract a concise crash message from the first meaningful line. FIRST_LINE=$(echo "${REPLAY_OUTPUT}" | grep -m1 -iE 'panic|error|fault|abort|overflow|undefined|sanitizer|SUMMARY' || true) if [ -n "${FIRST_LINE}" ]; then CRASH_MSG="${FIRST_LINE}" fi fi - echo "Uploading crash: ${CRASH_NAME}" + echo " Uploading crash: ${CRASH_NAME}" set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ -commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \ -kind crash \ @@ -283,47 +379,42 @@ runs: done fi - # ── Minimize and upload corpus ── + # Minimize and upload corpus. QUEUE_DIR="${FINDINGS}/default/queue" if [ -d "${QUEUE_DIR}" ]; then QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l) if [ "${QUEUE_COUNT}" -gt 0 ]; then - UPLOAD_DIR="corpus-upload-${TARGET_NUM}" + UPLOAD_DIR="work/${FUZZ_TARGET}/corpus-upload" rm -rf "${UPLOAD_DIR}" mkdir -p "${UPLOAD_DIR}" CMIN_START=$(date +%s) if afl-cmin -i "${QUEUE_DIR}" -o "${UPLOAD_DIR}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then CMIN_ELAPSED=$(( $(date +%s) - CMIN_START )) UPLOAD_COUNT=$(find "${UPLOAD_DIR}" -maxdepth 1 -type f | wc -l) - echo "Corpus for upload minimized: ${QUEUE_COUNT} -> ${UPLOAD_COUNT} entries (${CMIN_ELAPSED}s)" + echo " Corpus minimized: ${QUEUE_COUNT} -> ${UPLOAD_COUNT} entries (${CMIN_ELAPSED}s)" else - echo "afl-cmin failed, uploading full queue" + echo " afl-cmin failed, uploading full queue" rm -rf "${UPLOAD_DIR}" UPLOAD_DIR="${QUEUE_DIR}" UPLOAD_COUNT="${QUEUE_COUNT}" fi - echo "Uploading corpus (${UPLOAD_COUNT} entries)..." + echo " Uploading corpus (${UPLOAD_COUNT} entries)..." cairn corpus upload \ -server "${CAIRN_SERVER}" \ -target-id "${CAIRN_TARGET_ID}" \ -run-id "${RUN_ID}" \ -dir "${UPLOAD_DIR}" - rm -rf "corpus-upload-${TARGET_NUM}" fi fi - # ── Finish run ── + # Finish run. cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true - ACTIVE_RUN_ID="" - - done <