mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-07-09 18:58:35 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Fuzzing Modules
|
||||
|
||||
This package contains modules for various fuzzing techniques and tools.
|
||||
|
||||
Available modules:
|
||||
- LibFuzzer: LLVM's coverage-guided fuzzing engine
|
||||
- AFL++: Advanced American Fuzzy Lop with modern features
|
||||
- AFL-RS: Rust-based AFL implementation
|
||||
- Atheris: Python fuzzing engine for finding bugs in Python code
|
||||
- Cargo Fuzz: Rust fuzzing integration with libFuzzer
|
||||
- Go-Fuzz: Coverage-guided fuzzing for Go packages
|
||||
- OSS-Fuzz: Google's continuous fuzzing for open source
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
from typing import List, Type
|
||||
from ..base import BaseModule
|
||||
|
||||
# Module registry for automatic discovery
|
||||
FUZZING_MODULES: List[Type[BaseModule]] = []
|
||||
|
||||
def register_module(module_class: Type[BaseModule]):
|
||||
"""Register a fuzzing module"""
|
||||
FUZZING_MODULES.append(module_class)
|
||||
return module_class
|
||||
|
||||
def get_available_modules() -> List[Type[BaseModule]]:
|
||||
"""Get all available fuzzing modules"""
|
||||
return FUZZING_MODULES.copy()
|
||||
|
||||
# Import modules to trigger registration
|
||||
from .libfuzzer import LibFuzzerModule
|
||||
from .aflplusplus import AFLPlusPlusModule
|
||||
from .aflrs import AFLRSModule
|
||||
from .atheris import AtherisModule
|
||||
from .cargo_fuzz import CargoFuzzModule
|
||||
from .go_fuzz import GoFuzzModule
|
||||
from .oss_fuzz import OSSFuzzModule
|
||||
@@ -0,0 +1,734 @@
|
||||
"""
|
||||
AFL++ Fuzzing Module
|
||||
|
||||
This module uses AFL++ (Advanced American Fuzzy Lop) for coverage-guided
|
||||
fuzzing with modern features and optimizations.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import subprocess
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class AFLPlusPlusModule(BaseModule):
|
||||
"""AFL++ advanced fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="aflplusplus",
|
||||
version="4.09c",
|
||||
description="Advanced American Fuzzy Lop with modern features for coverage-guided fuzzing",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["coverage-guided", "american-fuzzy-lop", "advanced", "mutation", "instrumentation"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_binary": {
|
||||
"type": "string",
|
||||
"description": "Path to the target binary (compiled with afl-gcc/afl-clang)"
|
||||
},
|
||||
"input_dir": {
|
||||
"type": "string",
|
||||
"description": "Directory containing seed input files"
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "string",
|
||||
"default": "afl_output",
|
||||
"description": "Output directory for AFL++ results"
|
||||
},
|
||||
"dictionary": {
|
||||
"type": "string",
|
||||
"description": "Dictionary file for fuzzing keywords"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"description": "Timeout for each execution (ms)"
|
||||
},
|
||||
"memory_limit": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "Memory limit for child process (MB)"
|
||||
},
|
||||
"skip_deterministic": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Skip deterministic mutations"
|
||||
},
|
||||
"no_arith": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Skip arithmetic mutations"
|
||||
},
|
||||
"shuffle_queue": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Shuffle queue entries"
|
||||
},
|
||||
"max_total_time": {
|
||||
"type": "integer",
|
||||
"default": 3600,
|
||||
"description": "Maximum total fuzzing time (seconds)"
|
||||
},
|
||||
"power_schedule": {
|
||||
"type": "string",
|
||||
"enum": ["explore", "fast", "coe", "lin", "quad", "exploit", "rare"],
|
||||
"default": "fast",
|
||||
"description": "Power schedule algorithm"
|
||||
},
|
||||
"mutation_mode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "old", "mopt"],
|
||||
"default": "default",
|
||||
"description": "Mutation mode to use"
|
||||
},
|
||||
"parallel_fuzzing": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable parallel fuzzing with multiple instances"
|
||||
},
|
||||
"fuzzer_instances": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of parallel fuzzer instances"
|
||||
},
|
||||
"master_instance": {
|
||||
"type": "string",
|
||||
"default": "master",
|
||||
"description": "Name for master fuzzer instance"
|
||||
},
|
||||
"slave_prefix": {
|
||||
"type": "string",
|
||||
"default": "slave",
|
||||
"description": "Prefix for slave fuzzer instances"
|
||||
},
|
||||
"hang_timeout": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"description": "Timeout for detecting hangs (ms)"
|
||||
},
|
||||
"crash_mode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Run in crash exploration mode"
|
||||
},
|
||||
"target_args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Arguments to pass to target binary"
|
||||
},
|
||||
"env_vars": {
|
||||
"type": "object",
|
||||
"description": "Environment variables to set"
|
||||
},
|
||||
"ignore_finds": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ignore existing findings and start fresh"
|
||||
},
|
||||
"force_deterministic": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Force deterministic mutations"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crash_id": {"type": "string"},
|
||||
"crash_file": {"type": "string"},
|
||||
"crash_type": {"type": "string"},
|
||||
"signal": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
target_binary = config.get("target_binary")
|
||||
if not target_binary:
|
||||
raise ValueError("target_binary is required for AFL++")
|
||||
|
||||
input_dir = config.get("input_dir")
|
||||
if not input_dir:
|
||||
raise ValueError("input_dir is required for AFL++")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""Execute AFL++ fuzzing"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running AFL++ fuzzing campaign")
|
||||
|
||||
# Check prerequisites
|
||||
await self._check_afl_prerequisites(workspace)
|
||||
|
||||
# Setup directories and files
|
||||
target_binary, input_dir, output_dir = self._setup_afl_directories(config, workspace)
|
||||
|
||||
# Run AFL++ fuzzing
|
||||
findings = await self._run_afl_fuzzing(target_binary, input_dir, output_dir, config, workspace)
|
||||
|
||||
# Create summary
|
||||
summary = self._create_summary(findings, output_dir)
|
||||
|
||||
logger.info(f"AFL++ found {len(findings)} crashes")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AFL++ module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _check_afl_prerequisites(self, workspace: Path):
|
||||
"""Check AFL++ prerequisites and system setup"""
|
||||
try:
|
||||
# Check if afl-fuzz exists
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"which", "afl-fuzz",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError("afl-fuzz not found. Please install AFL++")
|
||||
|
||||
# Check core dump pattern (important for AFL)
|
||||
try:
|
||||
with open("/proc/sys/kernel/core_pattern", "r") as f:
|
||||
core_pattern = f.read().strip()
|
||||
if core_pattern != "core":
|
||||
logger.warning(f"Core dump pattern is '{core_pattern}', AFL++ may not work optimally")
|
||||
except Exception:
|
||||
logger.warning("Could not check core dump pattern")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AFL++ prerequisite check failed: {e}")
|
||||
|
||||
def _setup_afl_directories(self, config: Dict[str, Any], workspace: Path):
|
||||
"""Setup AFL++ directories and validate files"""
|
||||
# Check target binary
|
||||
target_binary = workspace / config["target_binary"]
|
||||
if not target_binary.exists():
|
||||
raise FileNotFoundError(f"Target binary not found: {target_binary}")
|
||||
|
||||
# Check input directory
|
||||
input_dir = workspace / config["input_dir"]
|
||||
if not input_dir.exists():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
|
||||
# Check if input directory has files
|
||||
input_files = list(input_dir.glob("*"))
|
||||
if not input_files:
|
||||
raise ValueError(f"Input directory is empty: {input_dir}")
|
||||
|
||||
# Create output directory
|
||||
output_dir = workspace / config.get("output_dir", "afl_output")
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
return target_binary, input_dir, output_dir
|
||||
|
||||
async def _run_afl_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run AFL++ fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
if config.get("parallel_fuzzing", False):
|
||||
findings = await self._run_parallel_fuzzing(
|
||||
target_binary, input_dir, output_dir, config, workspace
|
||||
)
|
||||
else:
|
||||
findings = await self._run_single_fuzzing(
|
||||
target_binary, input_dir, output_dir, config, workspace
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running AFL++ fuzzing: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_single_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run single-instance AFL++ fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Build AFL++ command
|
||||
cmd = ["afl-fuzz"]
|
||||
|
||||
# Add input and output directories
|
||||
cmd.extend(["-i", str(input_dir)])
|
||||
cmd.extend(["-o", str(output_dir)])
|
||||
|
||||
# Add dictionary if specified
|
||||
dictionary = config.get("dictionary")
|
||||
if dictionary:
|
||||
dict_path = workspace / dictionary
|
||||
if dict_path.exists():
|
||||
cmd.extend(["-x", str(dict_path)])
|
||||
|
||||
# Add timeout
|
||||
timeout = config.get("timeout", 1000)
|
||||
cmd.extend(["-t", str(timeout)])
|
||||
|
||||
# Add memory limit
|
||||
memory_limit = config.get("memory_limit", 50)
|
||||
cmd.extend(["-m", str(memory_limit)])
|
||||
|
||||
# Add power schedule
|
||||
power_schedule = config.get("power_schedule", "fast")
|
||||
cmd.extend(["-p", power_schedule])
|
||||
|
||||
# Add mutation options
|
||||
if config.get("skip_deterministic", False):
|
||||
cmd.append("-d")
|
||||
|
||||
if config.get("no_arith", False):
|
||||
cmd.append("-a")
|
||||
|
||||
if config.get("shuffle_queue", False):
|
||||
cmd.append("-Z")
|
||||
|
||||
# Add hang timeout
|
||||
hang_timeout = config.get("hang_timeout", 1000)
|
||||
cmd.extend(["-T", str(hang_timeout)])
|
||||
|
||||
# Add crash mode
|
||||
if config.get("crash_mode", False):
|
||||
cmd.append("-C")
|
||||
|
||||
# Add ignore finds
|
||||
if config.get("ignore_finds", False):
|
||||
cmd.append("-f")
|
||||
|
||||
# Add force deterministic
|
||||
if config.get("force_deterministic", False):
|
||||
cmd.append("-D")
|
||||
|
||||
# Add target binary and arguments
|
||||
cmd.append("--")
|
||||
cmd.append(str(target_binary))
|
||||
|
||||
target_args = config.get("target_args", [])
|
||||
cmd.extend(target_args)
|
||||
|
||||
# Set up environment
|
||||
env = os.environ.copy()
|
||||
env_vars = config.get("env_vars", {})
|
||||
env.update(env_vars)
|
||||
|
||||
# Set AFL environment variables
|
||||
env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1" # Avoid interactive prompts
|
||||
env["AFL_SKIP_CPUFREQ"] = "1" # Skip CPU frequency checks
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
|
||||
# Run AFL++ with timeout
|
||||
max_total_time = config.get("max_total_time", 3600)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace,
|
||||
env=env
|
||||
)
|
||||
|
||||
# Wait for specified time then terminate
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=max_total_time
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"AFL++ fuzzing timed out after {max_total_time} seconds")
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
# Parse results from output directory
|
||||
findings = self._parse_afl_results(output_dir, workspace)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running AFL++ process: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in single fuzzing: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_parallel_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run parallel AFL++ fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
fuzzer_instances = config.get("fuzzer_instances", 2)
|
||||
master_name = config.get("master_instance", "master")
|
||||
slave_prefix = config.get("slave_prefix", "slave")
|
||||
|
||||
processes = []
|
||||
|
||||
# Start master instance
|
||||
master_cmd = await self._build_afl_command(
|
||||
target_binary, input_dir, output_dir, config, workspace,
|
||||
instance_name=master_name, is_master=True
|
||||
)
|
||||
|
||||
master_process = await asyncio.create_subprocess_exec(
|
||||
*master_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace,
|
||||
env=self._get_afl_env(config)
|
||||
)
|
||||
processes.append(master_process)
|
||||
|
||||
# Start slave instances
|
||||
for i in range(1, fuzzer_instances):
|
||||
slave_name = f"{slave_prefix}{i:02d}"
|
||||
slave_cmd = await self._build_afl_command(
|
||||
target_binary, input_dir, output_dir, config, workspace,
|
||||
instance_name=slave_name, is_master=False
|
||||
)
|
||||
|
||||
slave_process = await asyncio.create_subprocess_exec(
|
||||
*slave_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace,
|
||||
env=self._get_afl_env(config)
|
||||
)
|
||||
processes.append(slave_process)
|
||||
|
||||
# Wait for specified time then terminate all
|
||||
max_total_time = config.get("max_total_time", 3600)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(max_total_time)
|
||||
finally:
|
||||
# Terminate all processes
|
||||
for process in processes:
|
||||
if process.returncode is None:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
# Parse results from output directory
|
||||
findings = self._parse_afl_results(output_dir, workspace)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in parallel fuzzing: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _build_afl_command(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path, instance_name: str, is_master: bool) -> List[str]:
|
||||
"""Build AFL++ command for a fuzzer instance"""
|
||||
cmd = ["afl-fuzz"]
|
||||
|
||||
# Add input and output directories
|
||||
cmd.extend(["-i", str(input_dir)])
|
||||
cmd.extend(["-o", str(output_dir)])
|
||||
|
||||
# Add instance name
|
||||
if is_master:
|
||||
cmd.extend(["-M", instance_name])
|
||||
else:
|
||||
cmd.extend(["-S", instance_name])
|
||||
|
||||
# Add other options (same as single fuzzing)
|
||||
dictionary = config.get("dictionary")
|
||||
if dictionary:
|
||||
dict_path = workspace / dictionary
|
||||
if dict_path.exists():
|
||||
cmd.extend(["-x", str(dict_path)])
|
||||
|
||||
cmd.extend(["-t", str(config.get("timeout", 1000))])
|
||||
cmd.extend(["-m", str(config.get("memory_limit", 50))])
|
||||
cmd.extend(["-p", config.get("power_schedule", "fast")])
|
||||
|
||||
if config.get("skip_deterministic", False):
|
||||
cmd.append("-d")
|
||||
|
||||
if config.get("no_arith", False):
|
||||
cmd.append("-a")
|
||||
|
||||
# Add target
|
||||
cmd.append("--")
|
||||
cmd.append(str(target_binary))
|
||||
cmd.extend(config.get("target_args", []))
|
||||
|
||||
return cmd
|
||||
|
||||
def _get_afl_env(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Get environment variables for AFL++"""
|
||||
env = os.environ.copy()
|
||||
env.update(config.get("env_vars", {}))
|
||||
env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1"
|
||||
env["AFL_SKIP_CPUFREQ"] = "1"
|
||||
return env
|
||||
|
||||
def _parse_afl_results(self, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Parse AFL++ results from output directory"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for crashes directory
|
||||
crashes_dirs = []
|
||||
|
||||
# Single instance
|
||||
crashes_dir = output_dir / "crashes"
|
||||
if crashes_dir.exists():
|
||||
crashes_dirs.append(crashes_dir)
|
||||
|
||||
# Multiple instances
|
||||
for instance_dir in output_dir.iterdir():
|
||||
if instance_dir.is_dir():
|
||||
instance_crashes = instance_dir / "crashes"
|
||||
if instance_crashes.exists():
|
||||
crashes_dirs.append(instance_crashes)
|
||||
|
||||
# Process crash files
|
||||
for crashes_dir in crashes_dirs:
|
||||
crash_files = [f for f in crashes_dir.iterdir() if f.is_file() and f.name.startswith("id:")]
|
||||
|
||||
for crash_file in crash_files:
|
||||
finding = self._create_afl_crash_finding(crash_file, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing AFL++ results: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _create_afl_crash_finding(self, crash_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from AFL++ crash file"""
|
||||
try:
|
||||
# Parse crash filename for information
|
||||
filename = crash_file.name
|
||||
crash_info = self._parse_afl_filename(filename)
|
||||
|
||||
# Try to read crash file (limited size)
|
||||
crash_content = ""
|
||||
try:
|
||||
crash_data = crash_file.read_bytes()[:1000]
|
||||
crash_content = crash_data.hex()[:200] # Hex representation, limited
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine severity based on signal
|
||||
severity = self._get_crash_severity(crash_info.get("signal", ""))
|
||||
|
||||
# Create relative path
|
||||
try:
|
||||
rel_path = crash_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(crash_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"AFL++ Crash: {crash_info.get('signal', 'Unknown')}",
|
||||
description=f"AFL++ discovered a crash with signal {crash_info.get('signal', 'unknown')} in the target program",
|
||||
severity=severity,
|
||||
category=self._get_crash_category(crash_info.get("signal", "")),
|
||||
file_path=file_path,
|
||||
recommendation=self._get_afl_crash_recommendation(crash_info.get("signal", "")),
|
||||
metadata={
|
||||
"crash_id": crash_info.get("id", ""),
|
||||
"signal": crash_info.get("signal", ""),
|
||||
"src": crash_info.get("src", ""),
|
||||
"crash_file": crash_file.name,
|
||||
"crash_content_hex": crash_content,
|
||||
"fuzzer": "afl++"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating AFL++ crash finding: {e}")
|
||||
return None
|
||||
|
||||
def _parse_afl_filename(self, filename: str) -> Dict[str, str]:
|
||||
"""Parse AFL++ crash filename for information"""
|
||||
info = {}
|
||||
|
||||
try:
|
||||
# AFL++ crash filename format: id:XXXXXX,sig:XX,src:XXXXXX,op:XXX,rep:X
|
||||
parts = filename.split(',')
|
||||
|
||||
for part in parts:
|
||||
if ':' in part:
|
||||
key, value = part.split(':', 1)
|
||||
info[key] = value
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def _get_crash_severity(self, signal: str) -> str:
|
||||
"""Determine severity based on crash signal"""
|
||||
if not signal:
|
||||
return "medium"
|
||||
|
||||
signal_lower = signal.lower()
|
||||
|
||||
# Critical signals indicating memory corruption
|
||||
if signal in ["11", "sigsegv", "segv"]: # Segmentation fault
|
||||
return "critical"
|
||||
elif signal in ["6", "sigabrt", "abrt"]: # Abort
|
||||
return "high"
|
||||
elif signal in ["4", "sigill", "ill"]: # Illegal instruction
|
||||
return "high"
|
||||
elif signal in ["8", "sigfpe", "fpe"]: # Floating point exception
|
||||
return "medium"
|
||||
elif signal in ["9", "sigkill", "kill"]: # Kill signal
|
||||
return "medium"
|
||||
else:
|
||||
return "medium"
|
||||
|
||||
def _get_crash_category(self, signal: str) -> str:
|
||||
"""Determine category based on crash signal"""
|
||||
if not signal:
|
||||
return "program_crash"
|
||||
|
||||
if signal in ["11", "sigsegv", "segv"]:
|
||||
return "memory_corruption"
|
||||
elif signal in ["6", "sigabrt", "abrt"]:
|
||||
return "assertion_failure"
|
||||
elif signal in ["4", "sigill", "ill"]:
|
||||
return "illegal_instruction"
|
||||
elif signal in ["8", "sigfpe", "fpe"]:
|
||||
return "arithmetic_error"
|
||||
else:
|
||||
return "program_crash"
|
||||
|
||||
def _get_afl_crash_recommendation(self, signal: str) -> str:
|
||||
"""Generate recommendation based on crash signal"""
|
||||
if signal in ["11", "sigsegv", "segv"]:
|
||||
return "Segmentation fault detected. Investigate memory access patterns, check for buffer overflows, null pointer dereferences, or use-after-free bugs."
|
||||
elif signal in ["6", "sigabrt", "abrt"]:
|
||||
return "Program abort detected. Check for assertion failures, memory allocation errors, or explicit abort() calls in the code."
|
||||
elif signal in ["4", "sigill", "ill"]:
|
||||
return "Illegal instruction detected. Check for code corruption, invalid function pointers, or architecture-specific instruction issues."
|
||||
elif signal in ["8", "sigfpe", "fpe"]:
|
||||
return "Floating point exception detected. Check for division by zero, arithmetic overflow, or invalid floating point operations."
|
||||
else:
|
||||
return f"Program crash with signal {signal} detected. Analyze the crash dump and input to identify the root cause."
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding], output_dir: Path) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
signal_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
# Count by severity
|
||||
severity_counts[finding.severity] += 1
|
||||
|
||||
# Count by category
|
||||
category = finding.category
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
# Count by signal
|
||||
signal = finding.metadata.get("signal", "unknown")
|
||||
signal_counts[signal] = signal_counts.get(signal, 0) + 1
|
||||
|
||||
# Try to read AFL++ statistics
|
||||
stats = self._read_afl_stats(output_dir)
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts,
|
||||
"signal_counts": signal_counts,
|
||||
"unique_crashes": len(set(f.metadata.get("crash_id", "") for f in findings)),
|
||||
"afl_stats": stats
|
||||
}
|
||||
|
||||
def _read_afl_stats(self, output_dir: Path) -> Dict[str, Any]:
|
||||
"""Read AFL++ fuzzer statistics"""
|
||||
stats = {}
|
||||
|
||||
try:
|
||||
# Look for fuzzer_stats file in single or multiple instance setup
|
||||
stats_files = []
|
||||
|
||||
# Single instance
|
||||
single_stats = output_dir / "fuzzer_stats"
|
||||
if single_stats.exists():
|
||||
stats_files.append(single_stats)
|
||||
|
||||
# Multiple instances
|
||||
for instance_dir in output_dir.iterdir():
|
||||
if instance_dir.is_dir():
|
||||
instance_stats = instance_dir / "fuzzer_stats"
|
||||
if instance_stats.exists():
|
||||
stats_files.append(instance_stats)
|
||||
|
||||
# Read first stats file found
|
||||
if stats_files:
|
||||
with open(stats_files[0], 'r') as f:
|
||||
for line in f:
|
||||
if ':' in line:
|
||||
key, value = line.strip().split(':', 1)
|
||||
stats[key.strip()] = value.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading AFL++ stats: {e}")
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,678 @@
|
||||
"""
|
||||
AFL-RS Fuzzing Module
|
||||
|
||||
This module uses AFL-RS (AFL in Rust) for high-performance coverage-guided fuzzing
|
||||
with modern Rust implementations and optimizations.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import subprocess
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class AFLRSModule(BaseModule):
|
||||
"""AFL-RS Rust-based fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="aflrs",
|
||||
version="0.2.0",
|
||||
description="High-performance AFL implementation in Rust with modern fuzzing features",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["coverage-guided", "rust", "afl", "high-performance", "modern"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_binary": {
|
||||
"type": "string",
|
||||
"description": "Path to the target binary (compiled with AFL-RS instrumentation)"
|
||||
},
|
||||
"input_dir": {
|
||||
"type": "string",
|
||||
"description": "Directory containing seed input files"
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "string",
|
||||
"default": "aflrs_output",
|
||||
"description": "Output directory for AFL-RS results"
|
||||
},
|
||||
"dictionary": {
|
||||
"type": "string",
|
||||
"description": "Dictionary file for token-based mutations"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"description": "Timeout for each execution (ms)"
|
||||
},
|
||||
"memory_limit": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "Memory limit for target process (MB)"
|
||||
},
|
||||
"max_total_time": {
|
||||
"type": "integer",
|
||||
"default": 3600,
|
||||
"description": "Maximum total fuzzing time (seconds)"
|
||||
},
|
||||
"cpu_cores": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of CPU cores to use"
|
||||
},
|
||||
"mutation_depth": {
|
||||
"type": "integer",
|
||||
"default": 4,
|
||||
"description": "Maximum depth for cascaded mutations"
|
||||
},
|
||||
"skip_deterministic": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Skip deterministic mutations"
|
||||
},
|
||||
"power_schedule": {
|
||||
"type": "string",
|
||||
"enum": ["explore", "fast", "coe", "lin", "quad", "exploit", "rare", "mmopt", "seek"],
|
||||
"default": "fast",
|
||||
"description": "Power scheduling algorithm"
|
||||
},
|
||||
"custom_mutators": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Custom mutator libraries to load"
|
||||
},
|
||||
"cmplog": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable CmpLog for comparison logging"
|
||||
},
|
||||
"redqueen": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable RedQueen input-to-state correspondence"
|
||||
},
|
||||
"unicorn_mode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable Unicorn mode for emulation"
|
||||
},
|
||||
"persistent_mode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable persistent mode for faster execution"
|
||||
},
|
||||
"target_args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Arguments to pass to target binary"
|
||||
},
|
||||
"env_vars": {
|
||||
"type": "object",
|
||||
"description": "Environment variables to set"
|
||||
},
|
||||
"ignore_timeouts": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ignore timeout signals and continue fuzzing"
|
||||
},
|
||||
"ignore_crashes": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ignore crashes and continue fuzzing"
|
||||
},
|
||||
"sync_dir": {
|
||||
"type": "string",
|
||||
"description": "Directory for syncing with other AFL instances"
|
||||
},
|
||||
"sync_id": {
|
||||
"type": "string",
|
||||
"description": "Fuzzer ID for syncing"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crash_id": {"type": "string"},
|
||||
"crash_file": {"type": "string"},
|
||||
"signal": {"type": "string"},
|
||||
"execution_time": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
target_binary = config.get("target_binary")
|
||||
if not target_binary:
|
||||
raise ValueError("target_binary is required for AFL-RS")
|
||||
|
||||
input_dir = config.get("input_dir")
|
||||
if not input_dir:
|
||||
raise ValueError("input_dir is required for AFL-RS")
|
||||
|
||||
cpu_cores = config.get("cpu_cores", 1)
|
||||
if cpu_cores < 1:
|
||||
raise ValueError("cpu_cores must be at least 1")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""Execute AFL-RS fuzzing"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running AFL-RS fuzzing campaign")
|
||||
|
||||
# Check AFL-RS installation
|
||||
await self._check_aflrs_installation()
|
||||
|
||||
# Setup directories and files
|
||||
target_binary, input_dir, output_dir = self._setup_aflrs_directories(config, workspace)
|
||||
|
||||
# Run AFL-RS fuzzing
|
||||
findings = await self._run_aflrs_fuzzing(target_binary, input_dir, output_dir, config, workspace)
|
||||
|
||||
# Create summary
|
||||
summary = self._create_summary(findings, output_dir)
|
||||
|
||||
logger.info(f"AFL-RS found {len(findings)} crashes")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AFL-RS module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _check_aflrs_installation(self):
|
||||
"""Check if AFL-RS is installed and available"""
|
||||
try:
|
||||
# Check if aflrs is available (assuming aflrs binary)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"which", "aflrs",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
# Try alternative AFL-RS command names
|
||||
alt_commands = ["afl-fuzz-rs", "afl-rs", "cargo-afl"]
|
||||
found = False
|
||||
|
||||
for cmd in alt_commands:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"which", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise RuntimeError("AFL-RS not found. Please install AFL-RS or ensure it's in PATH")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AFL-RS installation check failed: {e}")
|
||||
|
||||
def _setup_aflrs_directories(self, config: Dict[str, Any], workspace: Path):
|
||||
"""Setup AFL-RS directories and validate files"""
|
||||
# Check target binary
|
||||
target_binary = workspace / config["target_binary"]
|
||||
if not target_binary.exists():
|
||||
raise FileNotFoundError(f"Target binary not found: {target_binary}")
|
||||
|
||||
# Check input directory
|
||||
input_dir = workspace / config["input_dir"]
|
||||
if not input_dir.exists():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
|
||||
# Validate input files exist
|
||||
input_files = list(input_dir.glob("*"))
|
||||
if not input_files:
|
||||
raise ValueError(f"Input directory is empty: {input_dir}")
|
||||
|
||||
# Create output directory
|
||||
output_dir = workspace / config.get("output_dir", "aflrs_output")
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
return target_binary, input_dir, output_dir
|
||||
|
||||
async def _run_aflrs_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run AFL-RS fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Build AFL-RS command
|
||||
cmd = await self._build_aflrs_command(target_binary, input_dir, output_dir, config, workspace)
|
||||
|
||||
# Set up environment
|
||||
env = self._setup_aflrs_environment(config)
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
|
||||
# Run AFL-RS with timeout
|
||||
max_total_time = config.get("max_total_time", 3600)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace,
|
||||
env=env
|
||||
)
|
||||
|
||||
# Wait for specified time then terminate
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=max_total_time
|
||||
)
|
||||
logger.info(f"AFL-RS completed after {max_total_time} seconds")
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"AFL-RS fuzzing timed out after {max_total_time} seconds, terminating")
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
# Parse results
|
||||
findings = self._parse_aflrs_results(output_dir, workspace)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running AFL-RS process: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in AFL-RS fuzzing: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _build_aflrs_command(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[str]:
|
||||
"""Build AFL-RS command"""
|
||||
# Try to determine the correct AFL-RS command
|
||||
aflrs_cmd = "aflrs" # Default
|
||||
|
||||
# Try alternative command names
|
||||
alt_commands = ["aflrs", "afl-fuzz-rs", "afl-rs"]
|
||||
for cmd in alt_commands:
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"which", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode == 0:
|
||||
aflrs_cmd = cmd
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
cmd = [aflrs_cmd]
|
||||
|
||||
# Add input and output directories
|
||||
cmd.extend(["-i", str(input_dir)])
|
||||
cmd.extend(["-o", str(output_dir)])
|
||||
|
||||
# Add dictionary if specified
|
||||
dictionary = config.get("dictionary")
|
||||
if dictionary:
|
||||
dict_path = workspace / dictionary
|
||||
if dict_path.exists():
|
||||
cmd.extend(["-x", str(dict_path)])
|
||||
|
||||
# Add timeout and memory limit
|
||||
cmd.extend(["-t", str(config.get("timeout", 1000))])
|
||||
cmd.extend(["-m", str(config.get("memory_limit", 50))])
|
||||
|
||||
# Add CPU cores
|
||||
cpu_cores = config.get("cpu_cores", 1)
|
||||
if cpu_cores > 1:
|
||||
cmd.extend(["-j", str(cpu_cores)])
|
||||
|
||||
# Add mutation depth
|
||||
mutation_depth = config.get("mutation_depth", 4)
|
||||
cmd.extend(["-d", str(mutation_depth)])
|
||||
|
||||
# Add power schedule
|
||||
power_schedule = config.get("power_schedule", "fast")
|
||||
cmd.extend(["-p", power_schedule])
|
||||
|
||||
# Add skip deterministic
|
||||
if config.get("skip_deterministic", False):
|
||||
cmd.append("-D")
|
||||
|
||||
# Add custom mutators
|
||||
custom_mutators = config.get("custom_mutators", [])
|
||||
for mutator in custom_mutators:
|
||||
cmd.extend(["-c", mutator])
|
||||
|
||||
# Add advanced features
|
||||
if config.get("cmplog", True):
|
||||
cmd.append("-l")
|
||||
|
||||
if config.get("redqueen", True):
|
||||
cmd.append("-I")
|
||||
|
||||
if config.get("unicorn_mode", False):
|
||||
cmd.append("-U")
|
||||
|
||||
if config.get("persistent_mode", False):
|
||||
cmd.append("-P")
|
||||
|
||||
# Add ignore options
|
||||
if config.get("ignore_timeouts", False):
|
||||
cmd.append("-T")
|
||||
|
||||
if config.get("ignore_crashes", False):
|
||||
cmd.append("-C")
|
||||
|
||||
# Add sync options
|
||||
sync_dir = config.get("sync_dir")
|
||||
if sync_dir:
|
||||
cmd.extend(["-F", sync_dir])
|
||||
|
||||
sync_id = config.get("sync_id")
|
||||
if sync_id:
|
||||
cmd.extend(["-S", sync_id])
|
||||
|
||||
# Add target binary and arguments
|
||||
cmd.append("--")
|
||||
cmd.append(str(target_binary))
|
||||
|
||||
target_args = config.get("target_args", [])
|
||||
cmd.extend(target_args)
|
||||
|
||||
return cmd
|
||||
|
||||
def _setup_aflrs_environment(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Setup environment variables for AFL-RS"""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Add user-specified environment variables
|
||||
env_vars = config.get("env_vars", {})
|
||||
env.update(env_vars)
|
||||
|
||||
# Set AFL-RS specific environment variables
|
||||
env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1"
|
||||
env["AFL_SKIP_CPUFREQ"] = "1"
|
||||
|
||||
# Enable advanced features if requested
|
||||
if config.get("cmplog", True):
|
||||
env["AFL_USE_CMPLOG"] = "1"
|
||||
|
||||
if config.get("redqueen", True):
|
||||
env["AFL_USE_REDQUEEN"] = "1"
|
||||
|
||||
return env
|
||||
|
||||
def _parse_aflrs_results(self, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Parse AFL-RS results from output directory"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for crashes directory
|
||||
crashes_dir = output_dir / "crashes"
|
||||
if not crashes_dir.exists():
|
||||
logger.info("No crashes directory found in AFL-RS output")
|
||||
return findings
|
||||
|
||||
# Process crash files
|
||||
crash_files = [f for f in crashes_dir.iterdir() if f.is_file() and not f.name.startswith(".")]
|
||||
|
||||
for crash_file in crash_files:
|
||||
finding = self._create_aflrs_crash_finding(crash_file, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing AFL-RS results: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _create_aflrs_crash_finding(self, crash_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from AFL-RS crash file"""
|
||||
try:
|
||||
# Parse crash filename
|
||||
filename = crash_file.name
|
||||
crash_info = self._parse_aflrs_filename(filename)
|
||||
|
||||
# Try to read crash file (limited size)
|
||||
crash_content = ""
|
||||
crash_size = 0
|
||||
try:
|
||||
crash_data = crash_file.read_bytes()
|
||||
crash_size = len(crash_data)
|
||||
# Store first 500 bytes as hex
|
||||
crash_content = crash_data[:500].hex()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine severity based on signal or crash type
|
||||
signal = crash_info.get("signal", "")
|
||||
severity = self._get_crash_severity(signal)
|
||||
|
||||
# Create relative path
|
||||
try:
|
||||
rel_path = crash_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(crash_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"AFL-RS Crash: {signal or 'Unknown Signal'}",
|
||||
description=f"AFL-RS discovered a crash in the target program{' with signal ' + signal if signal else ''}",
|
||||
severity=severity,
|
||||
category=self._get_crash_category(signal),
|
||||
file_path=file_path,
|
||||
recommendation=self._get_crash_recommendation(signal),
|
||||
metadata={
|
||||
"crash_id": crash_info.get("id", ""),
|
||||
"signal": signal,
|
||||
"execution_time": crash_info.get("time", ""),
|
||||
"crash_file": crash_file.name,
|
||||
"crash_size": crash_size,
|
||||
"crash_content_hex": crash_content,
|
||||
"fuzzer": "aflrs"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating AFL-RS crash finding: {e}")
|
||||
return None
|
||||
|
||||
def _parse_aflrs_filename(self, filename: str) -> Dict[str, str]:
|
||||
"""Parse AFL-RS crash filename for information"""
|
||||
info = {}
|
||||
|
||||
try:
|
||||
# AFL-RS may use similar format to AFL++
|
||||
# Example: id_000000_sig_11_src_000000_time_12345_op_havoc_rep_128
|
||||
parts = filename.replace("id:", "id_").replace("sig:", "sig_").replace("src:", "src_").replace("time:", "time_").replace("op:", "op_").replace("rep:", "rep_").split("_")
|
||||
|
||||
i = 0
|
||||
while i < len(parts) - 1:
|
||||
if parts[i] in ["id", "sig", "src", "time", "op", "rep"]:
|
||||
info[parts[i]] = parts[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
except Exception:
|
||||
# Fallback: try to extract signal from filename
|
||||
signal_match = re.search(r'sig[_:]?(\d+)', filename)
|
||||
if signal_match:
|
||||
info["signal"] = signal_match.group(1)
|
||||
|
||||
return info
|
||||
|
||||
def _get_crash_severity(self, signal: str) -> str:
|
||||
"""Determine crash severity based on signal"""
|
||||
if not signal:
|
||||
return "medium"
|
||||
|
||||
try:
|
||||
sig_num = int(signal)
|
||||
except ValueError:
|
||||
return "medium"
|
||||
|
||||
# Map common signals to severity
|
||||
if sig_num == 11: # SIGSEGV
|
||||
return "critical"
|
||||
elif sig_num == 6: # SIGABRT
|
||||
return "high"
|
||||
elif sig_num == 4: # SIGILL
|
||||
return "high"
|
||||
elif sig_num == 8: # SIGFPE
|
||||
return "medium"
|
||||
elif sig_num == 9: # SIGKILL
|
||||
return "medium"
|
||||
else:
|
||||
return "medium"
|
||||
|
||||
def _get_crash_category(self, signal: str) -> str:
|
||||
"""Determine crash category based on signal"""
|
||||
if not signal:
|
||||
return "program_crash"
|
||||
|
||||
try:
|
||||
sig_num = int(signal)
|
||||
except ValueError:
|
||||
return "program_crash"
|
||||
|
||||
if sig_num == 11: # SIGSEGV
|
||||
return "memory_corruption"
|
||||
elif sig_num == 6: # SIGABRT
|
||||
return "assertion_failure"
|
||||
elif sig_num == 4: # SIGILL
|
||||
return "illegal_instruction"
|
||||
elif sig_num == 8: # SIGFPE
|
||||
return "arithmetic_error"
|
||||
else:
|
||||
return "program_crash"
|
||||
|
||||
def _get_crash_recommendation(self, signal: str) -> str:
|
||||
"""Generate recommendation based on crash signal"""
|
||||
if not signal:
|
||||
return "Analyze the crash input to reproduce and debug the issue."
|
||||
|
||||
try:
|
||||
sig_num = int(signal)
|
||||
except ValueError:
|
||||
return "Analyze the crash input to reproduce and debug the issue."
|
||||
|
||||
if sig_num == 11: # SIGSEGV
|
||||
return "Segmentation fault detected. Check for buffer overflows, null pointer dereferences, use-after-free, or invalid memory access patterns."
|
||||
elif sig_num == 6: # SIGABRT
|
||||
return "Program abort detected. Check for assertion failures, memory corruption detected by allocator, or explicit abort calls."
|
||||
elif sig_num == 4: # SIGILL
|
||||
return "Illegal instruction detected. Check for code corruption, invalid function pointers, or architecture-specific issues."
|
||||
elif sig_num == 8: # SIGFPE
|
||||
return "Floating point exception detected. Check for division by zero, arithmetic overflow, or invalid floating point operations."
|
||||
else:
|
||||
return f"Program terminated with signal {signal}. Analyze the crash input and use debugging tools to identify the root cause."
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding], output_dir: Path) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
signal_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
# Count by severity
|
||||
severity_counts[finding.severity] += 1
|
||||
|
||||
# Count by category
|
||||
category = finding.category
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
# Count by signal
|
||||
signal = finding.metadata.get("signal", "unknown")
|
||||
signal_counts[signal] = signal_counts.get(signal, 0) + 1
|
||||
|
||||
# Try to read AFL-RS statistics
|
||||
stats = self._read_aflrs_stats(output_dir)
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts,
|
||||
"signal_counts": signal_counts,
|
||||
"unique_crashes": len(set(f.metadata.get("crash_id", "") for f in findings)),
|
||||
"aflrs_stats": stats
|
||||
}
|
||||
|
||||
def _read_aflrs_stats(self, output_dir: Path) -> Dict[str, Any]:
|
||||
"""Read AFL-RS fuzzer statistics"""
|
||||
stats = {}
|
||||
|
||||
try:
|
||||
# Look for AFL-RS stats file
|
||||
stats_file = output_dir / "fuzzer_stats"
|
||||
if stats_file.exists():
|
||||
with open(stats_file, 'r') as f:
|
||||
for line in f:
|
||||
if ':' in line:
|
||||
key, value = line.strip().split(':', 1)
|
||||
stats[key.strip()] = value.strip()
|
||||
|
||||
# Also look for AFL-RS specific files
|
||||
plot_data = output_dir / "plot_data"
|
||||
if plot_data.exists():
|
||||
stats["plot_data_available"] = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading AFL-RS stats: {e}")
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,774 @@
|
||||
"""
|
||||
Atheris Fuzzing Module
|
||||
|
||||
This module uses Atheris for fuzzing Python code to find bugs and security
|
||||
vulnerabilities in Python applications and libraries.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import subprocess
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class AtherisModule(BaseModule):
|
||||
"""Atheris Python fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="atheris",
|
||||
version="2.3.0",
|
||||
description="Coverage-guided Python fuzzing engine for finding bugs in Python code",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["python", "coverage-guided", "native", "sanitizers", "libfuzzer"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_script": {
|
||||
"type": "string",
|
||||
"description": "Path to the Python script containing the fuzz target function"
|
||||
},
|
||||
"target_function": {
|
||||
"type": "string",
|
||||
"default": "TestOneInput",
|
||||
"description": "Name of the target function to fuzz"
|
||||
},
|
||||
"corpus_dir": {
|
||||
"type": "string",
|
||||
"description": "Directory containing initial corpus files"
|
||||
},
|
||||
"dict_file": {
|
||||
"type": "string",
|
||||
"description": "Dictionary file for fuzzing keywords"
|
||||
},
|
||||
"max_total_time": {
|
||||
"type": "integer",
|
||||
"default": 600,
|
||||
"description": "Maximum total time to run fuzzing (seconds)"
|
||||
},
|
||||
"max_len": {
|
||||
"type": "integer",
|
||||
"default": 4096,
|
||||
"description": "Maximum length of test input"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 25,
|
||||
"description": "Timeout for individual test cases (seconds)"
|
||||
},
|
||||
"runs": {
|
||||
"type": "integer",
|
||||
"default": -1,
|
||||
"description": "Number of individual test runs (-1 for unlimited)"
|
||||
},
|
||||
"jobs": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of fuzzing jobs to run in parallel"
|
||||
},
|
||||
"print_final_stats": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Print final statistics"
|
||||
},
|
||||
"print_pcs": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Print newly covered PCs"
|
||||
},
|
||||
"print_coverage": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Print coverage information"
|
||||
},
|
||||
"artifact_prefix": {
|
||||
"type": "string",
|
||||
"default": "crash-",
|
||||
"description": "Prefix for artifact files"
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"description": "Random seed for reproducibility"
|
||||
},
|
||||
"python_path": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Additional Python paths to add to sys.path"
|
||||
},
|
||||
"enable_sanitizers": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Python-specific sanitizers and checks"
|
||||
},
|
||||
"detect_leaks": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Detect memory leaks in native extensions"
|
||||
},
|
||||
"detect_stack_use_after_return": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Detect stack use-after-return"
|
||||
},
|
||||
"setup_code": {
|
||||
"type": "string",
|
||||
"description": "Python code to execute before fuzzing starts"
|
||||
},
|
||||
"enable_value_profile": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable value profiling for better mutation"
|
||||
},
|
||||
"shrink": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Try to shrink the corpus"
|
||||
},
|
||||
"only_ascii": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Only generate ASCII inputs"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exception_type": {"type": "string"},
|
||||
"exception_message": {"type": "string"},
|
||||
"stack_trace": {"type": "string"},
|
||||
"crash_input": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
target_script = config.get("target_script")
|
||||
if not target_script:
|
||||
raise ValueError("target_script is required for Atheris")
|
||||
|
||||
max_total_time = config.get("max_total_time", 600)
|
||||
if max_total_time <= 0:
|
||||
raise ValueError("max_total_time must be positive")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""Execute Atheris Python fuzzing"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running Atheris Python fuzzing")
|
||||
|
||||
# Check Atheris installation
|
||||
await self._check_atheris_installation()
|
||||
|
||||
# Validate target script
|
||||
target_script = workspace / config["target_script"]
|
||||
if not target_script.exists():
|
||||
raise FileNotFoundError(f"Target script not found: {target_script}")
|
||||
|
||||
# Run Atheris fuzzing
|
||||
findings = await self._run_atheris_fuzzing(target_script, config, workspace)
|
||||
|
||||
# Create summary
|
||||
summary = self._create_summary(findings)
|
||||
|
||||
logger.info(f"Atheris found {len(findings)} issues")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Atheris module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _check_atheris_installation(self):
|
||||
"""Check if Atheris is installed"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-c", "import atheris; print(atheris.__version__)",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError("Atheris not installed. Install with: pip install atheris")
|
||||
|
||||
version = stdout.decode().strip()
|
||||
logger.info(f"Using Atheris version: {version}")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Atheris installation check failed: {e}")
|
||||
|
||||
async def _run_atheris_fuzzing(self, target_script: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run Atheris fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Create output directory for artifacts
|
||||
output_dir = workspace / "atheris_output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create wrapper script for fuzzing
|
||||
wrapper_script = await self._create_atheris_wrapper(target_script, config, workspace, output_dir)
|
||||
|
||||
# Build Atheris command
|
||||
cmd = [sys.executable, str(wrapper_script)]
|
||||
|
||||
# Add corpus directory
|
||||
corpus_dir = config.get("corpus_dir")
|
||||
if corpus_dir:
|
||||
corpus_path = workspace / corpus_dir
|
||||
if corpus_path.exists():
|
||||
cmd.append(str(corpus_path))
|
||||
|
||||
# Set up environment
|
||||
env = self._setup_atheris_environment(config)
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
|
||||
# Run Atheris with timeout
|
||||
max_total_time = config.get("max_total_time", 600)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace,
|
||||
env=env
|
||||
)
|
||||
|
||||
# Wait for specified time then terminate
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=max_total_time
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"Atheris fuzzing timed out after {max_total_time} seconds")
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
# Parse results
|
||||
findings = self._parse_atheris_output(
|
||||
stdout.decode(), stderr.decode(), output_dir, workspace
|
||||
)
|
||||
|
||||
# Look for crash files
|
||||
crash_findings = self._parse_crash_files(output_dir, workspace)
|
||||
findings.extend(crash_findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running Atheris process: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in Atheris fuzzing: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _create_atheris_wrapper(self, target_script: Path, config: Dict[str, Any], workspace: Path, output_dir: Path) -> Path:
|
||||
"""Create wrapper script for Atheris fuzzing"""
|
||||
wrapper_path = workspace / "atheris_wrapper.py"
|
||||
|
||||
wrapper_code = f'''#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import atheris
|
||||
import traceback
|
||||
|
||||
# Add Python paths
|
||||
python_paths = {config.get("python_path", [])}
|
||||
for path in python_paths:
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
# Add workspace to Python path
|
||||
sys.path.insert(0, r"{workspace}")
|
||||
|
||||
# Setup code
|
||||
setup_code = """{config.get("setup_code", "")}"""
|
||||
if setup_code:
|
||||
exec(setup_code)
|
||||
|
||||
# Import target script
|
||||
target_module_name = "{target_script.stem}"
|
||||
sys.path.insert(0, r"{target_script.parent}")
|
||||
|
||||
try:
|
||||
target_module = __import__(target_module_name)
|
||||
target_function = getattr(target_module, "{config.get("target_function", "TestOneInput")}")
|
||||
except Exception as e:
|
||||
print(f"Failed to import target: {{e}}")
|
||||
sys.exit(1)
|
||||
|
||||
# Wrapper function to catch exceptions
|
||||
original_target = target_function
|
||||
|
||||
def wrapped_target(data):
|
||||
try:
|
||||
return original_target(data)
|
||||
except Exception as e:
|
||||
# Write crash information
|
||||
crash_info = {{
|
||||
"exception_type": type(e).__name__,
|
||||
"exception_message": str(e),
|
||||
"stack_trace": traceback.format_exc(),
|
||||
"input_data": data[:1000].hex() if isinstance(data, bytes) else str(data)[:1000]
|
||||
}}
|
||||
|
||||
crash_file = r"{output_dir}" + "/crash_" + type(e).__name__ + ".txt"
|
||||
with open(crash_file, "a") as f:
|
||||
f.write(f"Exception: {{type(e).__name__}}\\n")
|
||||
f.write(f"Message: {{str(e)}}\\n")
|
||||
f.write(f"Stack trace:\\n{{traceback.format_exc()}}\\n")
|
||||
f.write(f"Input data (first 1000 chars/bytes): {{crash_info['input_data']}}\\n")
|
||||
f.write("-" * 80 + "\\n")
|
||||
|
||||
# Re-raise to let Atheris handle it
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Configure Atheris
|
||||
atheris.Setup(sys.argv, wrapped_target)
|
||||
|
||||
# Set Atheris options
|
||||
options = []
|
||||
|
||||
options.append(f"-max_total_time={{config.get('max_total_time', 600)}}")
|
||||
options.append(f"-max_len={{config.get('max_len', 4096)}}")
|
||||
options.append(f"-timeout={{config.get('timeout', 25)}}")
|
||||
options.append(f"-runs={{config.get('runs', -1)}}")
|
||||
|
||||
if {config.get('jobs', 1)} > 1:
|
||||
options.append(f"-jobs={{config.get('jobs', 1)}}")
|
||||
|
||||
if {config.get('print_final_stats', True)}:
|
||||
options.append("-print_final_stats=1")
|
||||
else:
|
||||
options.append("-print_final_stats=0")
|
||||
|
||||
if {config.get('print_pcs', False)}:
|
||||
options.append("-print_pcs=1")
|
||||
|
||||
if {config.get('print_coverage', True)}:
|
||||
options.append("-print_coverage=1")
|
||||
|
||||
artifact_prefix = "{config.get('artifact_prefix', 'crash-')}"
|
||||
options.append(f"-artifact_prefix={{r'{output_dir}'}}/" + artifact_prefix)
|
||||
|
||||
seed = {config.get('seed')}
|
||||
if seed is not None:
|
||||
options.append(f"-seed={{seed}}")
|
||||
|
||||
if {config.get('enable_value_profile', False)}:
|
||||
options.append("-use_value_profile=1")
|
||||
|
||||
if {config.get('shrink', True)}:
|
||||
options.append("-shrink=1")
|
||||
|
||||
if {config.get('only_ascii', False)}:
|
||||
options.append("-only_ascii=1")
|
||||
|
||||
dict_file = "{config.get('dict_file', '')}"
|
||||
if dict_file:
|
||||
dict_path = r"{workspace}" + "/" + dict_file
|
||||
if os.path.exists(dict_path):
|
||||
options.append(f"-dict={{dict_path}}")
|
||||
|
||||
# Add options to sys.argv
|
||||
sys.argv.extend(options)
|
||||
|
||||
# Start fuzzing
|
||||
atheris.Fuzz()
|
||||
'''
|
||||
|
||||
with open(wrapper_path, 'w') as f:
|
||||
f.write(wrapper_code)
|
||||
|
||||
return wrapper_path
|
||||
|
||||
def _setup_atheris_environment(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Setup environment variables for Atheris"""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Enable sanitizers if requested
|
||||
if config.get("enable_sanitizers", True):
|
||||
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=1:halt_on_error=1"
|
||||
|
||||
if config.get("detect_leaks", True):
|
||||
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=1"
|
||||
|
||||
if config.get("detect_stack_use_after_return", False):
|
||||
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_stack_use_after_return=1"
|
||||
|
||||
return env
|
||||
|
||||
def _parse_atheris_output(self, stdout: str, stderr: str, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Parse Atheris output for crashes and issues"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Combine stdout and stderr
|
||||
full_output = stdout + "\n" + stderr
|
||||
|
||||
# Look for Python exceptions in output
|
||||
exception_patterns = [
|
||||
r"Traceback \(most recent call last\):(.*?)(?=\n\w|\nDONE|\n=|\Z)",
|
||||
r"Exception: (\w+).*?\nMessage: (.*?)\nStack trace:\n(.*?)(?=\n-{20,}|\Z)"
|
||||
]
|
||||
|
||||
for pattern in exception_patterns:
|
||||
import re
|
||||
matches = re.findall(pattern, full_output, re.DOTALL | re.MULTILINE)
|
||||
for match in matches:
|
||||
finding = self._create_exception_finding(match, full_output, output_dir)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing Atheris output: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _parse_crash_files(self, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Parse crash files created by wrapper"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for crash files
|
||||
crash_files = list(output_dir.glob("crash_*.txt"))
|
||||
|
||||
for crash_file in crash_files:
|
||||
findings.extend(self._parse_crash_file(crash_file, workspace))
|
||||
|
||||
# Also look for Atheris artifact files
|
||||
artifact_files = list(output_dir.glob("crash-*"))
|
||||
for artifact_file in artifact_files:
|
||||
finding = self._create_artifact_finding(artifact_file, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing crash files: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _parse_crash_file(self, crash_file: Path, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Parse individual crash file"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
content = crash_file.read_text()
|
||||
|
||||
# Split by separator
|
||||
crash_entries = content.split("-" * 80)
|
||||
|
||||
for entry in crash_entries:
|
||||
if not entry.strip():
|
||||
continue
|
||||
|
||||
finding = self._parse_crash_entry(entry, crash_file, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing crash file {crash_file}: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _parse_crash_entry(self, entry: str, crash_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Parse individual crash entry"""
|
||||
try:
|
||||
lines = entry.strip().split('\n')
|
||||
|
||||
exception_type = ""
|
||||
exception_message = ""
|
||||
stack_trace = ""
|
||||
input_data = ""
|
||||
|
||||
current_section = None
|
||||
stack_lines = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("Exception: "):
|
||||
exception_type = line.replace("Exception: ", "")
|
||||
elif line.startswith("Message: "):
|
||||
exception_message = line.replace("Message: ", "")
|
||||
elif line.startswith("Stack trace:"):
|
||||
current_section = "stack"
|
||||
elif line.startswith("Input data"):
|
||||
current_section = "input"
|
||||
input_data = line.split(":", 1)[1].strip() if ":" in line else ""
|
||||
elif current_section == "stack":
|
||||
stack_lines.append(line)
|
||||
|
||||
stack_trace = '\n'.join(stack_lines)
|
||||
|
||||
if not exception_type:
|
||||
return None
|
||||
|
||||
# Determine severity based on exception type
|
||||
severity = self._get_exception_severity(exception_type)
|
||||
|
||||
# Create relative path
|
||||
try:
|
||||
rel_path = crash_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(crash_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"Atheris Exception: {exception_type}",
|
||||
description=f"Atheris discovered a Python exception: {exception_type}{': ' + exception_message if exception_message else ''}",
|
||||
severity=severity,
|
||||
category=self._get_exception_category(exception_type),
|
||||
file_path=file_path,
|
||||
recommendation=self._get_exception_recommendation(exception_type, exception_message),
|
||||
metadata={
|
||||
"exception_type": exception_type,
|
||||
"exception_message": exception_message,
|
||||
"stack_trace": stack_trace[:2000] if stack_trace else "", # Limit size
|
||||
"crash_input_preview": input_data[:500] if input_data else "",
|
||||
"fuzzer": "atheris"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing crash entry: {e}")
|
||||
return None
|
||||
|
||||
def _create_exception_finding(self, match, full_output: str, output_dir: Path) -> ModuleFinding:
|
||||
"""Create finding from exception match"""
|
||||
try:
|
||||
if isinstance(match, tuple) and len(match) >= 1:
|
||||
# Handle different match formats
|
||||
if len(match) == 3: # Exception format
|
||||
exception_type, exception_message, stack_trace = match
|
||||
else:
|
||||
stack_trace = match[0]
|
||||
exception_type = "Unknown"
|
||||
exception_message = ""
|
||||
else:
|
||||
stack_trace = str(match)
|
||||
exception_type = "Unknown"
|
||||
exception_message = ""
|
||||
|
||||
# Try to extract exception type from stack trace
|
||||
if not exception_type or exception_type == "Unknown":
|
||||
lines = stack_trace.split('\n')
|
||||
for line in reversed(lines):
|
||||
if ':' in line and any(exc in line for exc in ['Error', 'Exception', 'Warning']):
|
||||
exception_type = line.split(':')[0].strip()
|
||||
exception_message = line.split(':', 1)[1].strip() if ':' in line else ""
|
||||
break
|
||||
|
||||
severity = self._get_exception_severity(exception_type)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"Atheris Exception: {exception_type}",
|
||||
description=f"Atheris discovered a Python exception during fuzzing: {exception_type}",
|
||||
severity=severity,
|
||||
category=self._get_exception_category(exception_type),
|
||||
file_path=None,
|
||||
recommendation=self._get_exception_recommendation(exception_type, exception_message),
|
||||
metadata={
|
||||
"exception_type": exception_type,
|
||||
"exception_message": exception_message,
|
||||
"stack_trace": stack_trace[:2000] if stack_trace else "",
|
||||
"fuzzer": "atheris"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating exception finding: {e}")
|
||||
return None
|
||||
|
||||
def _create_artifact_finding(self, artifact_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from Atheris artifact file"""
|
||||
try:
|
||||
# Try to read artifact content (limited)
|
||||
artifact_content = ""
|
||||
try:
|
||||
content_bytes = artifact_file.read_bytes()[:1000]
|
||||
artifact_content = content_bytes.hex()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create relative path
|
||||
try:
|
||||
rel_path = artifact_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(artifact_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title="Atheris Crash Artifact",
|
||||
description=f"Atheris generated a crash artifact file: {artifact_file.name}",
|
||||
severity="medium",
|
||||
category="program_crash",
|
||||
file_path=file_path,
|
||||
recommendation="Analyze the crash artifact to reproduce and debug the issue. The artifact contains the input that caused the crash.",
|
||||
metadata={
|
||||
"artifact_type": "crash",
|
||||
"artifact_file": artifact_file.name,
|
||||
"artifact_content_hex": artifact_content,
|
||||
"fuzzer": "atheris"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating artifact finding: {e}")
|
||||
return None
|
||||
|
||||
def _get_exception_severity(self, exception_type: str) -> str:
|
||||
"""Determine severity based on exception type"""
|
||||
if not exception_type:
|
||||
return "medium"
|
||||
|
||||
exception_lower = exception_type.lower()
|
||||
|
||||
# Critical security issues
|
||||
if any(term in exception_lower for term in ["segmentationfault", "accessviolation", "memoryerror"]):
|
||||
return "critical"
|
||||
|
||||
# High severity exceptions
|
||||
elif any(term in exception_lower for term in ["attributeerror", "typeerror", "indexerror", "keyerror", "valueerror"]):
|
||||
return "high"
|
||||
|
||||
# Medium severity exceptions
|
||||
elif any(term in exception_lower for term in ["assertionerror", "runtimeerror", "ioerror", "oserror"]):
|
||||
return "medium"
|
||||
|
||||
# Lower severity exceptions
|
||||
elif any(term in exception_lower for term in ["warning", "deprecation"]):
|
||||
return "low"
|
||||
|
||||
else:
|
||||
return "medium"
|
||||
|
||||
def _get_exception_category(self, exception_type: str) -> str:
|
||||
"""Determine category based on exception type"""
|
||||
if not exception_type:
|
||||
return "python_exception"
|
||||
|
||||
exception_lower = exception_type.lower()
|
||||
|
||||
if any(term in exception_lower for term in ["memory", "segmentation", "access"]):
|
||||
return "memory_corruption"
|
||||
elif any(term in exception_lower for term in ["attribute", "type"]):
|
||||
return "type_error"
|
||||
elif any(term in exception_lower for term in ["index", "key", "value"]):
|
||||
return "data_error"
|
||||
elif any(term in exception_lower for term in ["io", "os", "file"]):
|
||||
return "io_error"
|
||||
elif any(term in exception_lower for term in ["assertion"]):
|
||||
return "assertion_failure"
|
||||
else:
|
||||
return "python_exception"
|
||||
|
||||
def _get_exception_recommendation(self, exception_type: str, exception_message: str) -> str:
|
||||
"""Generate recommendation based on exception type"""
|
||||
if not exception_type:
|
||||
return "Analyze the exception and fix the underlying code issue."
|
||||
|
||||
exception_lower = exception_type.lower()
|
||||
|
||||
if "attributeerror" in exception_lower:
|
||||
return "Fix AttributeError by ensuring objects have the expected attributes before accessing them. Add proper error handling and validation."
|
||||
elif "typeerror" in exception_lower:
|
||||
return "Fix TypeError by ensuring correct data types are used. Add type checking and validation for function parameters."
|
||||
elif "indexerror" in exception_lower:
|
||||
return "Fix IndexError by adding bounds checking before accessing list/array elements. Validate indices are within valid range."
|
||||
elif "keyerror" in exception_lower:
|
||||
return "Fix KeyError by checking if keys exist in dictionaries before accessing them. Use .get() method or proper key validation."
|
||||
elif "valueerror" in exception_lower:
|
||||
return "Fix ValueError by validating input values before processing. Add proper input sanitization and validation."
|
||||
elif "memoryerror" in exception_lower:
|
||||
return "Fix MemoryError by optimizing memory usage, processing data in chunks, or increasing available memory."
|
||||
elif "assertionerror" in exception_lower:
|
||||
return "Fix AssertionError by reviewing assertion conditions and ensuring they properly validate the expected state."
|
||||
else:
|
||||
return f"Fix the {exception_type} exception by analyzing the root cause and implementing appropriate error handling and validation."
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
exception_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
# Count by severity
|
||||
severity_counts[finding.severity] += 1
|
||||
|
||||
# Count by category
|
||||
category = finding.category
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
# Count by exception type
|
||||
exception_type = finding.metadata.get("exception_type", "unknown")
|
||||
exception_counts[exception_type] = exception_counts.get(exception_type, 0) + 1
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts,
|
||||
"exception_counts": exception_counts,
|
||||
"unique_exceptions": len(exception_counts),
|
||||
"python_specific_issues": sum(category_counts.get(cat, 0) for cat in ["type_error", "data_error", "python_exception"])
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
"""
|
||||
Cargo Fuzz Module
|
||||
|
||||
This module uses cargo-fuzz for fuzzing Rust code with libFuzzer integration.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple
|
||||
import subprocess
|
||||
import logging
|
||||
import httpx
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
from prefect import get_run_context
|
||||
except ImportError:
|
||||
# Fallback for when not running in Prefect context
|
||||
get_run_context = None
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class CargoFuzzModule(BaseModule):
|
||||
"""Cargo Fuzz Rust fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="cargo_fuzz",
|
||||
version="0.11.2",
|
||||
description="Rust fuzzing integration with libFuzzer using cargo-fuzz",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["rust", "libfuzzer", "cargo", "coverage-guided", "sanitizers"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_dir": {
|
||||
"type": "string",
|
||||
"description": "Path to Rust project directory (with Cargo.toml)"
|
||||
},
|
||||
"fuzz_target": {
|
||||
"type": "string",
|
||||
"description": "Name of the fuzz target to run"
|
||||
},
|
||||
"max_total_time": {
|
||||
"type": "integer",
|
||||
"default": 600,
|
||||
"description": "Maximum total time to run fuzzing (seconds)"
|
||||
},
|
||||
"jobs": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of worker processes"
|
||||
},
|
||||
"corpus_dir": {
|
||||
"type": "string",
|
||||
"description": "Custom corpus directory"
|
||||
},
|
||||
"artifacts_dir": {
|
||||
"type": "string",
|
||||
"description": "Custom artifacts directory"
|
||||
},
|
||||
"sanitizer": {
|
||||
"type": "string",
|
||||
"enum": ["address", "memory", "thread", "leak", "none"],
|
||||
"default": "address",
|
||||
"description": "Sanitizer to use"
|
||||
},
|
||||
"release": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Use release mode"
|
||||
},
|
||||
"debug_assertions": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable debug assertions"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crash_type": {"type": "string"},
|
||||
"artifact_path": {"type": "string"},
|
||||
"stack_trace": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
project_dir = config.get("project_dir")
|
||||
if not project_dir:
|
||||
raise ValueError("project_dir is required")
|
||||
|
||||
fuzz_target = config.get("fuzz_target")
|
||||
if not fuzz_target:
|
||||
raise ValueError("fuzz_target is required")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path, stats_callback=None) -> ModuleResult:
|
||||
"""Execute cargo-fuzz fuzzing"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Initialize last observed stats for summary propagation
|
||||
self._last_stats = {
|
||||
'executions': 0,
|
||||
'executions_per_sec': 0.0,
|
||||
'crashes': 0,
|
||||
'corpus_size': 0,
|
||||
'elapsed_time': 0,
|
||||
}
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running cargo-fuzz Rust fuzzing")
|
||||
|
||||
# Check installation
|
||||
await self._check_cargo_fuzz_installation()
|
||||
|
||||
# Setup project
|
||||
project_dir = workspace / config["project_dir"]
|
||||
await self._setup_cargo_fuzz_project(project_dir, config)
|
||||
|
||||
# Run fuzzing
|
||||
findings = await self._run_cargo_fuzz(project_dir, config, workspace, stats_callback)
|
||||
|
||||
# Create summary and enrich with last observed runtime stats
|
||||
summary = self._create_summary(findings)
|
||||
try:
|
||||
summary.update({
|
||||
'executions': self._last_stats.get('executions', 0),
|
||||
'executions_per_sec': self._last_stats.get('executions_per_sec', 0.0),
|
||||
'corpus_size': self._last_stats.get('corpus_size', 0),
|
||||
'crashes': self._last_stats.get('crashes', 0),
|
||||
'elapsed_time': self._last_stats.get('elapsed_time', 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"cargo-fuzz found {len(findings)} issues")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"cargo-fuzz module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _check_cargo_fuzz_installation(self):
|
||||
"""Check if cargo-fuzz is installed"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"cargo", "fuzz", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError("cargo-fuzz not installed. Install with: cargo install cargo-fuzz")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"cargo-fuzz installation check failed: {e}")
|
||||
|
||||
async def _setup_cargo_fuzz_project(self, project_dir: Path, config: Dict[str, Any]):
|
||||
"""Setup cargo-fuzz project"""
|
||||
if not project_dir.exists():
|
||||
raise FileNotFoundError(f"Project directory not found: {project_dir}")
|
||||
|
||||
cargo_toml = project_dir / "Cargo.toml"
|
||||
if not cargo_toml.exists():
|
||||
raise FileNotFoundError(f"Cargo.toml not found in {project_dir}")
|
||||
|
||||
# Check if fuzz directory exists, if not initialize
|
||||
fuzz_dir = project_dir / "fuzz"
|
||||
if not fuzz_dir.exists():
|
||||
logger.info("Initializing cargo-fuzz project")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"cargo", "fuzz", "init",
|
||||
cwd=project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await process.communicate()
|
||||
|
||||
async def _run_cargo_fuzz(self, project_dir: Path, config: Dict[str, Any], workspace: Path, stats_callback=None) -> List[ModuleFinding]:
|
||||
"""Run cargo-fuzz with real-time statistics reporting"""
|
||||
findings = []
|
||||
|
||||
# Get run_id from Prefect context for statistics reporting
|
||||
run_id = None
|
||||
if get_run_context:
|
||||
try:
|
||||
context = get_run_context()
|
||||
run_id = str(context.flow_run.id)
|
||||
except Exception:
|
||||
logger.warning("Could not get run_id from Prefect context")
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = ["cargo", "fuzz", "run", config["fuzz_target"]]
|
||||
|
||||
# Add options
|
||||
if config.get("jobs", 1) > 1:
|
||||
cmd.extend(["--", f"-jobs={config['jobs']}"])
|
||||
|
||||
max_time = config.get("max_total_time", 600)
|
||||
cmd.extend(["--", f"-max_total_time={max_time}"])
|
||||
|
||||
# Set sanitizer
|
||||
sanitizer = config.get("sanitizer", "address")
|
||||
if sanitizer != "none":
|
||||
cmd.append(f"--sanitizer={sanitizer}")
|
||||
|
||||
if config.get("release", False):
|
||||
cmd.append("--release")
|
||||
|
||||
# Set environment
|
||||
env = os.environ.copy()
|
||||
if config.get("debug_assertions", True):
|
||||
env["RUSTFLAGS"] = env.get("RUSTFLAGS", "") + " -C debug-assertions=on"
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
|
||||
# Run with streaming output processing for real-time stats
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT, # Merge stderr into stdout
|
||||
cwd=project_dir,
|
||||
env=env
|
||||
)
|
||||
|
||||
# Process output in real-time
|
||||
stdout_data, stderr_data = await self._process_streaming_output(
|
||||
process, max_time, config, stats_callback
|
||||
)
|
||||
|
||||
# Parse final results
|
||||
findings = self._parse_cargo_fuzz_output(
|
||||
stdout_data, stderr_data, project_dir, workspace, config
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running cargo-fuzz: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in cargo-fuzz execution: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _parse_cargo_fuzz_output(self, stdout: str, stderr: str, project_dir: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
|
||||
"""Parse cargo-fuzz output"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
full_output = stdout + "\n" + stderr
|
||||
|
||||
# Look for crash artifacts
|
||||
artifacts_dir = project_dir / "fuzz" / "artifacts" / config["fuzz_target"]
|
||||
if artifacts_dir.exists():
|
||||
for artifact in artifacts_dir.iterdir():
|
||||
if artifact.is_file():
|
||||
finding = self._create_artifact_finding(artifact, workspace, full_output)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing cargo-fuzz output: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _create_artifact_finding(self, artifact_path: Path, workspace: Path, output: str) -> ModuleFinding:
|
||||
"""Create finding from artifact file"""
|
||||
try:
|
||||
# Try to determine crash type from filename or content
|
||||
crash_type = "crash"
|
||||
if "leak" in artifact_path.name.lower():
|
||||
crash_type = "memory_leak"
|
||||
elif "timeout" in artifact_path.name.lower():
|
||||
crash_type = "timeout"
|
||||
|
||||
# Extract stack trace from output
|
||||
stack_trace = self._extract_stack_trace_from_output(output, artifact_path.name)
|
||||
|
||||
try:
|
||||
rel_path = artifact_path.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(artifact_path)
|
||||
|
||||
severity = "high" if "crash" in crash_type else "medium"
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"cargo-fuzz {crash_type.title()}",
|
||||
description=f"cargo-fuzz discovered a {crash_type} in the Rust code",
|
||||
severity=severity,
|
||||
category=self._get_crash_category(crash_type),
|
||||
file_path=file_path,
|
||||
recommendation=self._get_crash_recommendation(crash_type),
|
||||
metadata={
|
||||
"crash_type": crash_type,
|
||||
"artifact_path": str(artifact_path),
|
||||
"stack_trace": stack_trace,
|
||||
"fuzzer": "cargo_fuzz"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating artifact finding: {e}")
|
||||
return None
|
||||
|
||||
def _extract_stack_trace_from_output(self, output: str, artifact_name: str) -> str:
|
||||
"""Extract stack trace from output"""
|
||||
try:
|
||||
lines = output.split('\n')
|
||||
stack_lines = []
|
||||
in_stack = False
|
||||
|
||||
for line in lines:
|
||||
if artifact_name in line or "stack backtrace:" in line.lower():
|
||||
in_stack = True
|
||||
continue
|
||||
|
||||
if in_stack:
|
||||
if line.strip() and ("at " in line or "::" in line or line.strip().startswith("0:")):
|
||||
stack_lines.append(line.strip())
|
||||
elif not line.strip() and stack_lines:
|
||||
break
|
||||
|
||||
return '\n'.join(stack_lines[:20]) # Limit stack trace size
|
||||
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _get_crash_category(self, crash_type: str) -> str:
|
||||
"""Get category for crash type"""
|
||||
if "leak" in crash_type:
|
||||
return "memory_leak"
|
||||
elif "timeout" in crash_type:
|
||||
return "performance_issues"
|
||||
else:
|
||||
return "memory_safety"
|
||||
|
||||
def _get_crash_recommendation(self, crash_type: str) -> str:
|
||||
"""Get recommendation for crash type"""
|
||||
if "leak" in crash_type:
|
||||
return "Fix memory leak by ensuring proper cleanup of allocated resources. Review memory management patterns."
|
||||
elif "timeout" in crash_type:
|
||||
return "Fix timeout by optimizing performance, avoiding infinite loops, and implementing reasonable bounds."
|
||||
else:
|
||||
return "Fix the crash by analyzing the stack trace and addressing memory safety issues."
|
||||
|
||||
async def _process_streaming_output(self, process, max_time: int, config: Dict[str, Any], stats_callback=None) -> Tuple[str, str]:
|
||||
"""Process cargo-fuzz output in real-time and report statistics"""
|
||||
stdout_lines = []
|
||||
start_time = datetime.utcnow()
|
||||
last_update = start_time
|
||||
stats_data = {
|
||||
'executions': 0,
|
||||
'executions_per_sec': 0.0,
|
||||
'crashes': 0,
|
||||
'corpus_size': 0,
|
||||
'elapsed_time': 0
|
||||
}
|
||||
|
||||
# Get run_id from Prefect context for statistics reporting
|
||||
run_id = None
|
||||
if get_run_context:
|
||||
try:
|
||||
context = get_run_context()
|
||||
run_id = str(context.flow_run.id)
|
||||
except Exception:
|
||||
logger.debug("Could not get run_id from Prefect context")
|
||||
|
||||
try:
|
||||
# Emit an initial baseline update so dashboards show activity immediately
|
||||
try:
|
||||
await self._send_stats_via_callback(stats_callback, run_id, stats_data)
|
||||
except Exception:
|
||||
pass
|
||||
# Monitor process output in chunks to capture libFuzzer carriage-return updates
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=1.0)
|
||||
if not chunk:
|
||||
# Process finished
|
||||
break
|
||||
|
||||
buffer += chunk.decode('utf-8', errors='ignore')
|
||||
|
||||
# Split on both newline and carriage return
|
||||
if "\n" in buffer or "\r" in buffer:
|
||||
parts = re.split(r"[\r\n]", buffer)
|
||||
buffer = parts[-1]
|
||||
for part in parts[:-1]:
|
||||
line = part.strip()
|
||||
if not line:
|
||||
continue
|
||||
stdout_lines.append(line)
|
||||
self._parse_stats_from_line(line, stats_data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# No output this second; continue to periodic update check
|
||||
pass
|
||||
|
||||
# Periodic update (even if there was no output)
|
||||
current_time = datetime.utcnow()
|
||||
stats_data['elapsed_time'] = int((current_time - start_time).total_seconds())
|
||||
if current_time - last_update >= timedelta(seconds=3):
|
||||
try:
|
||||
self._last_stats = dict(stats_data)
|
||||
except Exception:
|
||||
pass
|
||||
await self._send_stats_via_callback(stats_callback, run_id, stats_data)
|
||||
last_update = current_time
|
||||
|
||||
# Check if max time exceeded
|
||||
if stats_data['elapsed_time'] >= max_time:
|
||||
logger.info("Max time reached, terminating cargo-fuzz")
|
||||
process.terminate()
|
||||
break
|
||||
|
||||
# Wait for process to complete
|
||||
await process.wait()
|
||||
|
||||
# Send final stats update
|
||||
try:
|
||||
self._last_stats = dict(stats_data)
|
||||
except Exception:
|
||||
pass
|
||||
await self._send_stats_via_callback(stats_callback, run_id, stats_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing streaming output: {e}")
|
||||
|
||||
stdout_data = '\n'.join(stdout_lines)
|
||||
return stdout_data, ""
|
||||
|
||||
def _parse_stats_from_line(self, line: str, stats_data: Dict[str, Any]):
|
||||
"""Parse statistics from a cargo-fuzz output line"""
|
||||
try:
|
||||
# cargo-fuzz typically shows stats like:
|
||||
# "#12345: DONE cov: 1234 ft: 5678 corp: 9/10Mb exec/s: 1500 rss: 234Mb"
|
||||
# "#12345: NEW cov: 1234 ft: 5678 corp: 9/10Mb exec/s: 1500 rss: 234Mb L: 45/67 MS: 3 ..."
|
||||
|
||||
# Extract execution count (the #number)
|
||||
exec_match = re.search(r'#(\d+)(?::)?', line)
|
||||
if exec_match:
|
||||
stats_data['executions'] = int(exec_match.group(1))
|
||||
else:
|
||||
# libFuzzer stats format alternative
|
||||
exec_alt = re.search(r'stat::number_of_executed_units:\s*(\d+)', line)
|
||||
if exec_alt:
|
||||
stats_data['executions'] = int(exec_alt.group(1))
|
||||
else:
|
||||
exec_alt2 = re.search(r'executed units:?\s*(\d+)', line, re.IGNORECASE)
|
||||
if exec_alt2:
|
||||
stats_data['executions'] = int(exec_alt2.group(1))
|
||||
|
||||
# Extract executions per second
|
||||
exec_per_sec_match = re.search(r'exec/s:\s*([0-9\.]+)', line)
|
||||
if exec_per_sec_match:
|
||||
stats_data['executions_per_sec'] = float(exec_per_sec_match.group(1))
|
||||
else:
|
||||
eps_alt = re.search(r'stat::execs_per_sec:\s*([0-9\.]+)', line)
|
||||
if eps_alt:
|
||||
stats_data['executions_per_sec'] = float(eps_alt.group(1))
|
||||
|
||||
# Extract corpus size (corp: X/YMb)
|
||||
corp_match = re.search(r'corp(?:us)?:\s*(\d+)', line)
|
||||
if corp_match:
|
||||
stats_data['corpus_size'] = int(corp_match.group(1))
|
||||
|
||||
# Look for crash indicators
|
||||
if any(keyword in line.lower() for keyword in ['crash', 'assert', 'panic', 'abort']):
|
||||
stats_data['crashes'] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing stats from line '{line}': {e}")
|
||||
|
||||
async def _send_stats_via_callback(self, stats_callback, run_id: str, stats_data: Dict[str, Any]):
|
||||
"""Send statistics update via callback function"""
|
||||
if not stats_callback or not run_id:
|
||||
return
|
||||
|
||||
try:
|
||||
# Prepare statistics payload
|
||||
stats_payload = {
|
||||
"run_id": run_id,
|
||||
"workflow": "language_fuzzing",
|
||||
"executions": stats_data['executions'],
|
||||
"executions_per_sec": stats_data['executions_per_sec'],
|
||||
"crashes": stats_data['crashes'],
|
||||
"unique_crashes": stats_data['crashes'], # Assume all crashes are unique for now
|
||||
"corpus_size": stats_data['corpus_size'],
|
||||
"elapsed_time": stats_data['elapsed_time'],
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Call the callback function provided by the Prefect task
|
||||
await stats_callback(stats_payload)
|
||||
logger.info(
|
||||
"LIVE STATS SENT: exec=%s eps=%.2f crashes=%s corpus=%s elapsed=%s",
|
||||
stats_data['executions'],
|
||||
stats_data['executions_per_sec'],
|
||||
stats_data['crashes'],
|
||||
stats_data['corpus_size'],
|
||||
stats_data['elapsed_time'],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error sending stats via callback: {e}")
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
severity_counts[finding.severity] += 1
|
||||
category_counts[finding.category] = category_counts.get(finding.category, 0) + 1
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Go-Fuzz Module
|
||||
|
||||
This module uses go-fuzz for coverage-guided fuzzing of Go packages.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class GoFuzzModule(BaseModule):
|
||||
"""Go-Fuzz Go language fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="go_fuzz",
|
||||
version="1.2.0",
|
||||
description="Coverage-guided fuzzing for Go packages using go-fuzz",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["go", "golang", "coverage-guided", "packages"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"package_path": {
|
||||
"type": "string",
|
||||
"description": "Path to Go package to fuzz"
|
||||
},
|
||||
"fuzz_function": {
|
||||
"type": "string",
|
||||
"default": "Fuzz",
|
||||
"description": "Name of the fuzz function"
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"default": "go_fuzz_workdir",
|
||||
"description": "Working directory for go-fuzz"
|
||||
},
|
||||
"procs": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of parallel processes"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 600,
|
||||
"description": "Total fuzzing timeout (seconds)"
|
||||
},
|
||||
"race": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable race detector"
|
||||
},
|
||||
"minimize": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Minimize crashers"
|
||||
},
|
||||
"sonar": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable sonar mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crash_type": {"type": "string"},
|
||||
"crash_file": {"type": "string"},
|
||||
"stack_trace": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
package_path = config.get("package_path")
|
||||
if not package_path:
|
||||
raise ValueError("package_path is required")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""Execute go-fuzz fuzzing"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running go-fuzz Go fuzzing")
|
||||
|
||||
# Check installation
|
||||
await self._check_go_fuzz_installation()
|
||||
|
||||
# Setup
|
||||
package_path = workspace / config["package_path"]
|
||||
workdir = workspace / config.get("workdir", "go_fuzz_workdir")
|
||||
|
||||
# Build and run
|
||||
findings = await self._run_go_fuzz(package_path, workdir, config, workspace)
|
||||
|
||||
# Create summary
|
||||
summary = self._create_summary(findings)
|
||||
|
||||
logger.info(f"go-fuzz found {len(findings)} issues")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"go-fuzz module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _check_go_fuzz_installation(self):
|
||||
"""Check if go-fuzz is installed"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"go-fuzz", "--help",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
# Try building
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"go", "install", "github.com/dvyukov/go-fuzz/go-fuzz@latest",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await process.communicate()
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"go-fuzz installation failed: {e}")
|
||||
|
||||
async def _run_go_fuzz(self, package_path: Path, workdir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run go-fuzz"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Create workdir
|
||||
workdir.mkdir(exist_ok=True)
|
||||
|
||||
# Build
|
||||
await self._build_go_fuzz(package_path, config)
|
||||
|
||||
# Run fuzzing
|
||||
cmd = ["go-fuzz", "-bin", f"{package_path.name}-fuzz.zip", "-workdir", str(workdir)]
|
||||
|
||||
if config.get("procs", 1) > 1:
|
||||
cmd.extend(["-procs", str(config["procs"])])
|
||||
|
||||
if config.get("race", False):
|
||||
cmd.append("-race")
|
||||
|
||||
if config.get("sonar", False):
|
||||
cmd.append("-sonar")
|
||||
|
||||
timeout = config.get("timeout", 600)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=package_path.parent
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.terminate()
|
||||
await process.wait()
|
||||
|
||||
# Parse results
|
||||
findings = self._parse_go_fuzz_results(workdir, workspace, config)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running go-fuzz: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in go-fuzz execution: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _build_go_fuzz(self, package_path: Path, config: Dict[str, Any]):
|
||||
"""Build go-fuzz binary"""
|
||||
cmd = ["go-fuzz-build"]
|
||||
if config.get("race", False):
|
||||
cmd.append("-race")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=package_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(f"go-fuzz-build failed: {stderr.decode()}")
|
||||
|
||||
def _parse_go_fuzz_results(self, workdir: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
|
||||
"""Parse go-fuzz results"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for crashers
|
||||
crashers_dir = workdir / "crashers"
|
||||
if crashers_dir.exists():
|
||||
for crash_file in crashers_dir.iterdir():
|
||||
if crash_file.is_file() and not crash_file.name.startswith("."):
|
||||
finding = self._create_crash_finding(crash_file, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
# Look for suppressions (potential issues)
|
||||
suppressions_dir = workdir / "suppressions"
|
||||
if suppressions_dir.exists():
|
||||
for supp_file in suppressions_dir.iterdir():
|
||||
if supp_file.is_file():
|
||||
finding = self._create_suppression_finding(supp_file, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing go-fuzz results: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _create_crash_finding(self, crash_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from crash file"""
|
||||
try:
|
||||
# Read crash output
|
||||
crash_content = ""
|
||||
if crash_file.name.endswith(".output"):
|
||||
crash_content = crash_file.read_text()
|
||||
|
||||
# Determine crash type
|
||||
crash_type = "panic"
|
||||
if "runtime error" in crash_content:
|
||||
crash_type = "runtime_error"
|
||||
elif "race" in crash_content:
|
||||
crash_type = "race_condition"
|
||||
|
||||
try:
|
||||
rel_path = crash_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(crash_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"go-fuzz {crash_type.title()}",
|
||||
description=f"go-fuzz discovered a {crash_type} in the Go code",
|
||||
severity=self._get_crash_severity(crash_type),
|
||||
category=self._get_crash_category(crash_type),
|
||||
file_path=file_path,
|
||||
recommendation=self._get_crash_recommendation(crash_type),
|
||||
metadata={
|
||||
"crash_type": crash_type,
|
||||
"crash_file": str(crash_file),
|
||||
"stack_trace": crash_content[:1000],
|
||||
"fuzzer": "go_fuzz"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating crash finding: {e}")
|
||||
return None
|
||||
|
||||
def _create_suppression_finding(self, supp_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from suppression file"""
|
||||
try:
|
||||
try:
|
||||
rel_path = supp_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(supp_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title="go-fuzz Potential Issue",
|
||||
description="go-fuzz identified a potential issue that was suppressed",
|
||||
severity="low",
|
||||
category="potential_issue",
|
||||
file_path=file_path,
|
||||
recommendation="Review suppressed issue to determine if it requires attention.",
|
||||
metadata={
|
||||
"suppression_file": str(supp_file),
|
||||
"fuzzer": "go_fuzz"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating suppression finding: {e}")
|
||||
return None
|
||||
|
||||
def _get_crash_severity(self, crash_type: str) -> str:
|
||||
"""Get crash severity"""
|
||||
if crash_type == "race_condition":
|
||||
return "high"
|
||||
elif crash_type == "runtime_error":
|
||||
return "high"
|
||||
else:
|
||||
return "medium"
|
||||
|
||||
def _get_crash_category(self, crash_type: str) -> str:
|
||||
"""Get crash category"""
|
||||
if crash_type == "race_condition":
|
||||
return "race_condition"
|
||||
elif crash_type == "runtime_error":
|
||||
return "runtime_error"
|
||||
else:
|
||||
return "program_crash"
|
||||
|
||||
def _get_crash_recommendation(self, crash_type: str) -> str:
|
||||
"""Get crash recommendation"""
|
||||
if crash_type == "race_condition":
|
||||
return "Fix race condition by adding proper synchronization (mutexes, channels, etc.)"
|
||||
elif crash_type == "runtime_error":
|
||||
return "Fix runtime error by adding bounds checking and proper error handling"
|
||||
else:
|
||||
return "Analyze the crash and fix the underlying issue"
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
severity_counts[finding.severity] += 1
|
||||
category_counts[finding.category] = category_counts.get(finding.category, 0) + 1
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
LibFuzzer Fuzzing Module
|
||||
|
||||
This module uses LibFuzzer (LLVM's coverage-guided fuzzing engine) to find
|
||||
bugs and security vulnerabilities in C/C++ code.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import subprocess
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class LibFuzzerModule(BaseModule):
|
||||
"""LibFuzzer coverage-guided fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="libfuzzer",
|
||||
version="17.0.0",
|
||||
description="LLVM's coverage-guided fuzzing engine for finding bugs in C/C++ code",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["coverage-guided", "c", "cpp", "llvm", "sanitizers", "memory-safety"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_binary": {
|
||||
"type": "string",
|
||||
"description": "Path to the fuzz target binary (compiled with -fsanitize=fuzzer)"
|
||||
},
|
||||
"corpus_dir": {
|
||||
"type": "string",
|
||||
"description": "Directory containing initial corpus files"
|
||||
},
|
||||
"dict_file": {
|
||||
"type": "string",
|
||||
"description": "Dictionary file for fuzzing keywords"
|
||||
},
|
||||
"max_total_time": {
|
||||
"type": "integer",
|
||||
"default": 600,
|
||||
"description": "Maximum total time to run fuzzing (seconds)"
|
||||
},
|
||||
"max_len": {
|
||||
"type": "integer",
|
||||
"default": 4096,
|
||||
"description": "Maximum length of test input"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 25,
|
||||
"description": "Timeout for individual test cases (seconds)"
|
||||
},
|
||||
"runs": {
|
||||
"type": "integer",
|
||||
"default": -1,
|
||||
"description": "Number of individual test runs (-1 for unlimited)"
|
||||
},
|
||||
"jobs": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of fuzzing jobs to run in parallel"
|
||||
},
|
||||
"workers": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Number of workers for parallel fuzzing"
|
||||
},
|
||||
"reload": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Reload the main corpus periodically"
|
||||
},
|
||||
"print_final_stats": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Print final statistics"
|
||||
},
|
||||
"print_pcs": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Print newly covered PCs"
|
||||
},
|
||||
"print_funcs": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Print newly covered functions"
|
||||
},
|
||||
"print_coverage": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Print coverage information"
|
||||
},
|
||||
"shrink": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Try to shrink the corpus"
|
||||
},
|
||||
"reduce_inputs": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Try to reduce the size of inputs"
|
||||
},
|
||||
"use_value_profile": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Use value profile for fuzzing"
|
||||
},
|
||||
"sanitizers": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["address", "memory", "undefined", "thread", "leak"]},
|
||||
"default": ["address"],
|
||||
"description": "Sanitizers to use during fuzzing"
|
||||
},
|
||||
"artifact_prefix": {
|
||||
"type": "string",
|
||||
"default": "crash-",
|
||||
"description": "Prefix for artifact files"
|
||||
},
|
||||
"exact_artifact_path": {
|
||||
"type": "string",
|
||||
"description": "Exact path for artifact files"
|
||||
},
|
||||
"fork": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Fork mode (number of simultaneous processes)"
|
||||
},
|
||||
"ignore_crashes": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ignore crashes and continue fuzzing"
|
||||
},
|
||||
"ignore_timeouts": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ignore timeouts and continue fuzzing"
|
||||
},
|
||||
"ignore_ooms": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ignore out-of-memory and continue fuzzing"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crash_type": {"type": "string"},
|
||||
"crash_file": {"type": "string"},
|
||||
"stack_trace": {"type": "string"},
|
||||
"sanitizer": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
target_binary = config.get("target_binary")
|
||||
if not target_binary:
|
||||
raise ValueError("target_binary is required for LibFuzzer")
|
||||
|
||||
max_total_time = config.get("max_total_time", 600)
|
||||
if max_total_time <= 0:
|
||||
raise ValueError("max_total_time must be positive")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""Execute LibFuzzer fuzzing"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running LibFuzzer fuzzing campaign")
|
||||
|
||||
# Check if target binary exists
|
||||
target_binary = workspace / config["target_binary"]
|
||||
if not target_binary.exists():
|
||||
raise FileNotFoundError(f"Target binary not found: {target_binary}")
|
||||
|
||||
# Run LibFuzzer
|
||||
findings = await self._run_libfuzzer(target_binary, config, workspace)
|
||||
|
||||
# Create summary
|
||||
summary = self._create_summary(findings)
|
||||
|
||||
logger.info(f"LibFuzzer found {len(findings)} issues")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LibFuzzer module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _run_libfuzzer(self, target_binary: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run LibFuzzer fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Create output directory for artifacts
|
||||
output_dir = workspace / "libfuzzer_output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Build LibFuzzer command
|
||||
cmd = [str(target_binary)]
|
||||
|
||||
# Add corpus directory
|
||||
corpus_dir = config.get("corpus_dir")
|
||||
if corpus_dir:
|
||||
corpus_path = workspace / corpus_dir
|
||||
if corpus_path.exists():
|
||||
cmd.append(str(corpus_path))
|
||||
else:
|
||||
logger.warning(f"Corpus directory not found: {corpus_path}")
|
||||
|
||||
# Add dictionary file
|
||||
dict_file = config.get("dict_file")
|
||||
if dict_file:
|
||||
dict_path = workspace / dict_file
|
||||
if dict_path.exists():
|
||||
cmd.append(f"-dict={dict_path}")
|
||||
|
||||
# Add fuzzing parameters
|
||||
cmd.append(f"-max_total_time={config.get('max_total_time', 600)}")
|
||||
cmd.append(f"-max_len={config.get('max_len', 4096)}")
|
||||
cmd.append(f"-timeout={config.get('timeout', 25)}")
|
||||
cmd.append(f"-runs={config.get('runs', -1)}")
|
||||
|
||||
if config.get("jobs", 1) > 1:
|
||||
cmd.append(f"-jobs={config['jobs']}")
|
||||
|
||||
if config.get("workers", 1) > 1:
|
||||
cmd.append(f"-workers={config['workers']}")
|
||||
|
||||
cmd.append(f"-reload={config.get('reload', 1)}")
|
||||
|
||||
# Add output options
|
||||
if config.get("print_final_stats", True):
|
||||
cmd.append("-print_final_stats=1")
|
||||
|
||||
if config.get("print_pcs", False):
|
||||
cmd.append("-print_pcs=1")
|
||||
|
||||
if config.get("print_funcs", False):
|
||||
cmd.append("-print_funcs=1")
|
||||
|
||||
if config.get("print_coverage", True):
|
||||
cmd.append("-print_coverage=1")
|
||||
|
||||
# Add corpus management options
|
||||
if config.get("shrink", True):
|
||||
cmd.append("-shrink=1")
|
||||
|
||||
if config.get("reduce_inputs", True):
|
||||
cmd.append("-reduce_inputs=1")
|
||||
|
||||
if config.get("use_value_profile", False):
|
||||
cmd.append("-use_value_profile=1")
|
||||
|
||||
# Add artifact options
|
||||
artifact_prefix = config.get("artifact_prefix", "crash-")
|
||||
cmd.append(f"-artifact_prefix={output_dir / artifact_prefix}")
|
||||
|
||||
exact_artifact_path = config.get("exact_artifact_path")
|
||||
if exact_artifact_path:
|
||||
cmd.append(f"-exact_artifact_path={output_dir / exact_artifact_path}")
|
||||
|
||||
# Add fork mode
|
||||
fork = config.get("fork", 0)
|
||||
if fork > 0:
|
||||
cmd.append(f"-fork={fork}")
|
||||
|
||||
# Add ignore options
|
||||
if config.get("ignore_crashes", False):
|
||||
cmd.append("-ignore_crashes=1")
|
||||
|
||||
if config.get("ignore_timeouts", False):
|
||||
cmd.append("-ignore_timeouts=1")
|
||||
|
||||
if config.get("ignore_ooms", False):
|
||||
cmd.append("-ignore_ooms=1")
|
||||
|
||||
# Set up environment for sanitizers
|
||||
env = os.environ.copy()
|
||||
sanitizers = config.get("sanitizers", ["address"])
|
||||
self._setup_sanitizer_environment(env, sanitizers)
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
|
||||
# Run LibFuzzer
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace,
|
||||
env=env
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
# Parse results
|
||||
findings = self._parse_libfuzzer_output(
|
||||
stdout.decode(), stderr.decode(), output_dir, workspace, sanitizers
|
||||
)
|
||||
|
||||
# Look for crash files
|
||||
crash_findings = self._parse_crash_files(output_dir, workspace, sanitizers)
|
||||
findings.extend(crash_findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LibFuzzer: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _setup_sanitizer_environment(self, env: Dict[str, str], sanitizers: List[str]):
|
||||
"""Set up environment variables for sanitizers"""
|
||||
if "address" in sanitizers:
|
||||
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
|
||||
|
||||
if "memory" in sanitizers:
|
||||
env["MSAN_OPTIONS"] = env.get("MSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
|
||||
|
||||
if "undefined" in sanitizers:
|
||||
env["UBSAN_OPTIONS"] = env.get("UBSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
|
||||
|
||||
if "thread" in sanitizers:
|
||||
env["TSAN_OPTIONS"] = env.get("TSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
|
||||
|
||||
if "leak" in sanitizers:
|
||||
env["LSAN_OPTIONS"] = env.get("LSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
|
||||
|
||||
def _parse_libfuzzer_output(self, stdout: str, stderr: str, output_dir: Path, workspace: Path, sanitizers: List[str]) -> List[ModuleFinding]:
|
||||
"""Parse LibFuzzer output for crashes and issues"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Combine stdout and stderr for analysis
|
||||
full_output = stdout + "\n" + stderr
|
||||
|
||||
# Look for crash indicators
|
||||
crash_patterns = [
|
||||
r"ERROR: AddressSanitizer: (.+)",
|
||||
r"ERROR: MemorySanitizer: (.+)",
|
||||
r"ERROR: UndefinedBehaviorSanitizer: (.+)",
|
||||
r"ERROR: ThreadSanitizer: (.+)",
|
||||
r"ERROR: LeakSanitizer: (.+)",
|
||||
r"SUMMARY: (.+Sanitizer): (.+)",
|
||||
r"==\d+==ERROR: libFuzzer: (.+)"
|
||||
]
|
||||
|
||||
for pattern in crash_patterns:
|
||||
matches = re.finditer(pattern, full_output, re.MULTILINE)
|
||||
for match in matches:
|
||||
finding = self._create_crash_finding(
|
||||
match, full_output, output_dir, sanitizers
|
||||
)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
# Look for timeout and OOM issues
|
||||
if "TIMEOUT" in full_output:
|
||||
finding = self._create_timeout_finding(full_output, output_dir)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
if "out-of-memory" in full_output.lower() or "oom" in full_output.lower():
|
||||
finding = self._create_oom_finding(full_output, output_dir)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing LibFuzzer output: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _parse_crash_files(self, output_dir: Path, workspace: Path, sanitizers: List[str]) -> List[ModuleFinding]:
|
||||
"""Parse crash artifact files"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for crash files
|
||||
crash_patterns = ["crash-*", "leak-*", "timeout-*", "oom-*"]
|
||||
for pattern in crash_patterns:
|
||||
crash_files = list(output_dir.glob(pattern))
|
||||
for crash_file in crash_files:
|
||||
finding = self._create_artifact_finding(crash_file, workspace, sanitizers)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing crash files: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _create_crash_finding(self, match, full_output: str, output_dir: Path, sanitizers: List[str]) -> ModuleFinding:
|
||||
"""Create finding from crash match"""
|
||||
try:
|
||||
crash_type = match.group(1) if match.groups() else "Unknown crash"
|
||||
|
||||
# Extract stack trace
|
||||
stack_trace = self._extract_stack_trace(full_output, match.start())
|
||||
|
||||
# Determine sanitizer
|
||||
sanitizer = self._identify_sanitizer(match.group(0), sanitizers)
|
||||
|
||||
# Determine severity based on crash type
|
||||
severity = self._get_crash_severity(crash_type, sanitizer)
|
||||
|
||||
# Create finding
|
||||
finding = self.create_finding(
|
||||
title=f"LibFuzzer Crash: {crash_type}",
|
||||
description=f"LibFuzzer detected a crash with {sanitizer}: {crash_type}",
|
||||
severity=severity,
|
||||
category=self._get_crash_category(crash_type),
|
||||
file_path=None, # LibFuzzer doesn't always provide specific files
|
||||
recommendation=self._get_crash_recommendation(crash_type, sanitizer),
|
||||
metadata={
|
||||
"crash_type": crash_type,
|
||||
"sanitizer": sanitizer,
|
||||
"stack_trace": stack_trace[:2000] if stack_trace else "", # Limit size
|
||||
"fuzzer": "libfuzzer"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating crash finding: {e}")
|
||||
return None
|
||||
|
||||
def _create_timeout_finding(self, output: str, output_dir: Path) -> ModuleFinding:
|
||||
"""Create finding for timeout issues"""
|
||||
try:
|
||||
finding = self.create_finding(
|
||||
title="LibFuzzer Timeout",
|
||||
description="LibFuzzer detected a timeout during fuzzing, indicating potential infinite loop or performance issue",
|
||||
severity="medium",
|
||||
category="performance_issues",
|
||||
file_path=None,
|
||||
recommendation="Review the code for potential infinite loops, excessive computation, or blocking operations that could cause timeouts.",
|
||||
metadata={
|
||||
"issue_type": "timeout",
|
||||
"fuzzer": "libfuzzer"
|
||||
}
|
||||
)
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating timeout finding: {e}")
|
||||
return None
|
||||
|
||||
def _create_oom_finding(self, output: str, output_dir: Path) -> ModuleFinding:
|
||||
"""Create finding for out-of-memory issues"""
|
||||
try:
|
||||
finding = self.create_finding(
|
||||
title="LibFuzzer Out-of-Memory",
|
||||
description="LibFuzzer detected an out-of-memory condition during fuzzing, indicating potential memory leak or excessive allocation",
|
||||
severity="medium",
|
||||
category="memory_management",
|
||||
file_path=None,
|
||||
recommendation="Review memory allocation patterns, check for memory leaks, and consider implementing proper bounds checking.",
|
||||
metadata={
|
||||
"issue_type": "out_of_memory",
|
||||
"fuzzer": "libfuzzer"
|
||||
}
|
||||
)
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating OOM finding: {e}")
|
||||
return None
|
||||
|
||||
def _create_artifact_finding(self, crash_file: Path, workspace: Path, sanitizers: List[str]) -> ModuleFinding:
|
||||
"""Create finding from crash artifact file"""
|
||||
try:
|
||||
crash_type = crash_file.name.split('-')[0] # e.g., "crash", "leak", "timeout"
|
||||
|
||||
# Try to read crash file content (limited)
|
||||
crash_content = ""
|
||||
try:
|
||||
crash_content = crash_file.read_bytes()[:1000].decode('utf-8', errors='ignore')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine severity
|
||||
severity = self._get_artifact_severity(crash_type)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"LibFuzzer Artifact: {crash_type}",
|
||||
description=f"LibFuzzer generated a {crash_type} artifact file indicating a potential issue",
|
||||
severity=severity,
|
||||
category=self._get_crash_category(crash_type),
|
||||
file_path=str(crash_file.relative_to(workspace)),
|
||||
recommendation=self._get_artifact_recommendation(crash_type),
|
||||
metadata={
|
||||
"artifact_type": crash_type,
|
||||
"artifact_file": str(crash_file.name),
|
||||
"crash_content_preview": crash_content,
|
||||
"fuzzer": "libfuzzer"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating artifact finding: {e}")
|
||||
return None
|
||||
|
||||
def _extract_stack_trace(self, output: str, start_pos: int) -> str:
|
||||
"""Extract stack trace from output"""
|
||||
try:
|
||||
lines = output[start_pos:].split('\n')
|
||||
stack_lines = []
|
||||
|
||||
for line in lines[:50]: # Limit to first 50 lines
|
||||
if any(indicator in line for indicator in ["#0", "#1", "#2", "at ", "in "]):
|
||||
stack_lines.append(line.strip())
|
||||
elif stack_lines and not line.strip():
|
||||
break
|
||||
|
||||
return '\n'.join(stack_lines)
|
||||
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _identify_sanitizer(self, crash_line: str, sanitizers: List[str]) -> str:
|
||||
"""Identify which sanitizer detected the issue"""
|
||||
crash_lower = crash_line.lower()
|
||||
|
||||
if "addresssanitizer" in crash_lower:
|
||||
return "AddressSanitizer"
|
||||
elif "memorysanitizer" in crash_lower:
|
||||
return "MemorySanitizer"
|
||||
elif "undefinedbehaviorsanitizer" in crash_lower:
|
||||
return "UndefinedBehaviorSanitizer"
|
||||
elif "threadsanitizer" in crash_lower:
|
||||
return "ThreadSanitizer"
|
||||
elif "leaksanitizer" in crash_lower:
|
||||
return "LeakSanitizer"
|
||||
elif "libfuzzer" in crash_lower:
|
||||
return "LibFuzzer"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
def _get_crash_severity(self, crash_type: str, sanitizer: str) -> str:
|
||||
"""Determine severity based on crash type and sanitizer"""
|
||||
crash_lower = crash_type.lower()
|
||||
|
||||
# Critical issues
|
||||
if any(term in crash_lower for term in ["heap-buffer-overflow", "stack-buffer-overflow", "use-after-free", "double-free"]):
|
||||
return "critical"
|
||||
|
||||
# High severity issues
|
||||
elif any(term in crash_lower for term in ["heap-use-after-free", "stack-use-after-return", "global-buffer-overflow"]):
|
||||
return "high"
|
||||
|
||||
# Medium severity issues
|
||||
elif any(term in crash_lower for term in ["uninitialized", "leak", "race", "deadlock"]):
|
||||
return "medium"
|
||||
|
||||
# Default to high for any crash
|
||||
else:
|
||||
return "high"
|
||||
|
||||
def _get_crash_category(self, crash_type: str) -> str:
|
||||
"""Determine category based on crash type"""
|
||||
crash_lower = crash_type.lower()
|
||||
|
||||
if any(term in crash_lower for term in ["buffer-overflow", "heap-buffer", "stack-buffer", "global-buffer"]):
|
||||
return "buffer_overflow"
|
||||
elif any(term in crash_lower for term in ["use-after-free", "double-free", "invalid-free"]):
|
||||
return "memory_corruption"
|
||||
elif any(term in crash_lower for term in ["uninitialized", "uninit"]):
|
||||
return "uninitialized_memory"
|
||||
elif any(term in crash_lower for term in ["leak"]):
|
||||
return "memory_leak"
|
||||
elif any(term in crash_lower for term in ["race", "data-race"]):
|
||||
return "race_condition"
|
||||
elif any(term in crash_lower for term in ["timeout"]):
|
||||
return "performance_issues"
|
||||
elif any(term in crash_lower for term in ["oom", "out-of-memory"]):
|
||||
return "memory_management"
|
||||
else:
|
||||
return "memory_safety"
|
||||
|
||||
def _get_artifact_severity(self, artifact_type: str) -> str:
|
||||
"""Determine severity for artifact types"""
|
||||
if artifact_type == "crash":
|
||||
return "high"
|
||||
elif artifact_type == "leak":
|
||||
return "medium"
|
||||
elif artifact_type in ["timeout", "oom"]:
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
|
||||
def _get_crash_recommendation(self, crash_type: str, sanitizer: str) -> str:
|
||||
"""Generate recommendation based on crash type"""
|
||||
crash_lower = crash_type.lower()
|
||||
|
||||
if "buffer-overflow" in crash_lower:
|
||||
return "Fix buffer overflow by implementing proper bounds checking, using safe string functions, and validating array indices."
|
||||
elif "use-after-free" in crash_lower:
|
||||
return "Fix use-after-free by setting pointers to NULL after freeing, using smart pointers, or redesigning object lifetime management."
|
||||
elif "double-free" in crash_lower:
|
||||
return "Fix double-free by ensuring each allocation has exactly one corresponding free, or use RAII patterns."
|
||||
elif "uninitialized" in crash_lower:
|
||||
return "Initialize all variables before use and ensure proper constructor implementation."
|
||||
elif "leak" in crash_lower:
|
||||
return "Fix memory leak by ensuring all allocated memory is properly freed, use smart pointers, or implement proper cleanup routines."
|
||||
elif "race" in crash_lower:
|
||||
return "Fix data race by using proper synchronization mechanisms like mutexes, atomic operations, or lock-free data structures."
|
||||
else:
|
||||
return f"Address the {crash_type} issue detected by {sanitizer}. Review code for memory safety and proper resource management."
|
||||
|
||||
def _get_artifact_recommendation(self, artifact_type: str) -> str:
|
||||
"""Generate recommendation for artifact types"""
|
||||
if artifact_type == "crash":
|
||||
return "Analyze the crash artifact file to reproduce the issue and identify the root cause. Fix the underlying bug that caused the crash."
|
||||
elif artifact_type == "leak":
|
||||
return "Investigate the memory leak by analyzing allocation patterns and ensuring proper cleanup of resources."
|
||||
elif artifact_type == "timeout":
|
||||
return "Optimize code performance to prevent timeouts, check for infinite loops, and implement reasonable time limits."
|
||||
elif artifact_type == "oom":
|
||||
return "Reduce memory usage, implement proper memory management, and add bounds checking for allocations."
|
||||
else:
|
||||
return f"Analyze the {artifact_type} artifact to understand and fix the underlying issue."
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
sanitizer_counts = {}
|
||||
crash_type_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
# Count by severity
|
||||
severity_counts[finding.severity] += 1
|
||||
|
||||
# Count by category
|
||||
category = finding.category
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
# Count by sanitizer
|
||||
sanitizer = finding.metadata.get("sanitizer", "unknown")
|
||||
sanitizer_counts[sanitizer] = sanitizer_counts.get(sanitizer, 0) + 1
|
||||
|
||||
# Count by crash type
|
||||
crash_type = finding.metadata.get("crash_type", finding.metadata.get("issue_type", "unknown"))
|
||||
crash_type_counts[crash_type] = crash_type_counts.get(crash_type, 0) + 1
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts,
|
||||
"sanitizer_counts": sanitizer_counts,
|
||||
"crash_type_counts": crash_type_counts,
|
||||
"memory_safety_issues": category_counts.get("memory_safety", 0) +
|
||||
category_counts.get("buffer_overflow", 0) +
|
||||
category_counts.get("memory_corruption", 0),
|
||||
"performance_issues": category_counts.get("performance_issues", 0)
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
OSS-Fuzz Module
|
||||
|
||||
This module integrates with Google's OSS-Fuzz for continuous fuzzing
|
||||
of open source projects.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
|
||||
from . import register_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_module
|
||||
class OSSFuzzModule(BaseModule):
|
||||
"""OSS-Fuzz continuous fuzzing module"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="oss_fuzz",
|
||||
version="1.0.0",
|
||||
description="Google's continuous fuzzing for open source projects integration",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzing",
|
||||
tags=["oss-fuzz", "continuous", "google", "open-source", "docker"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_name": {
|
||||
"type": "string",
|
||||
"description": "OSS-Fuzz project name"
|
||||
},
|
||||
"source_dir": {
|
||||
"type": "string",
|
||||
"description": "Source directory to fuzz"
|
||||
},
|
||||
"build_script": {
|
||||
"type": "string",
|
||||
"default": "build.sh",
|
||||
"description": "Build script path"
|
||||
},
|
||||
"dockerfile": {
|
||||
"type": "string",
|
||||
"default": "Dockerfile",
|
||||
"description": "Dockerfile path"
|
||||
},
|
||||
"project_yaml": {
|
||||
"type": "string",
|
||||
"default": "project.yaml",
|
||||
"description": "Project configuration file"
|
||||
},
|
||||
"sanitizer": {
|
||||
"type": "string",
|
||||
"enum": ["address", "memory", "undefined", "coverage"],
|
||||
"default": "address",
|
||||
"description": "Sanitizer to use"
|
||||
},
|
||||
"architecture": {
|
||||
"type": "string",
|
||||
"enum": ["x86_64", "i386"],
|
||||
"default": "x86_64",
|
||||
"description": "Target architecture"
|
||||
},
|
||||
"fuzzing_engine": {
|
||||
"type": "string",
|
||||
"enum": ["libfuzzer", "afl", "honggfuzz"],
|
||||
"default": "libfuzzer",
|
||||
"description": "Fuzzing engine to use"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 3600,
|
||||
"description": "Fuzzing timeout (seconds)"
|
||||
},
|
||||
"check_build": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Check if build is successful"
|
||||
},
|
||||
"reproduce_bugs": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Try to reproduce existing bugs"
|
||||
}
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bug_type": {"type": "string"},
|
||||
"reproducer": {"type": "string"},
|
||||
"stack_trace": {"type": "string"},
|
||||
"sanitizer": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration"""
|
||||
project_name = config.get("project_name")
|
||||
if not project_name:
|
||||
raise ValueError("project_name is required")
|
||||
|
||||
source_dir = config.get("source_dir")
|
||||
if not source_dir:
|
||||
raise ValueError("source_dir is required")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""Execute OSS-Fuzz integration"""
|
||||
self.start_timer()
|
||||
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
logger.info("Running OSS-Fuzz integration")
|
||||
|
||||
# Check Docker
|
||||
await self._check_docker()
|
||||
|
||||
# Clone/update OSS-Fuzz if needed
|
||||
oss_fuzz_dir = await self._setup_oss_fuzz(workspace)
|
||||
|
||||
# Setup project
|
||||
await self._setup_project(oss_fuzz_dir, config, workspace)
|
||||
|
||||
# Build and run
|
||||
findings = await self._run_oss_fuzz(oss_fuzz_dir, config, workspace)
|
||||
|
||||
# Create summary
|
||||
summary = self._create_summary(findings)
|
||||
|
||||
logger.info(f"OSS-Fuzz found {len(findings)} issues")
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary=summary
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OSS-Fuzz module failed: {e}")
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _check_docker(self):
|
||||
"""Check if Docker is available"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError("Docker not available. OSS-Fuzz requires Docker.")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Docker check failed: {e}")
|
||||
|
||||
async def _setup_oss_fuzz(self, workspace: Path) -> Path:
|
||||
"""Setup OSS-Fuzz repository"""
|
||||
oss_fuzz_dir = workspace / "oss-fuzz"
|
||||
|
||||
if not oss_fuzz_dir.exists():
|
||||
logger.info("Cloning OSS-Fuzz repository")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "clone", "https://github.com/google/oss-fuzz.git",
|
||||
cwd=workspace,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(f"Failed to clone OSS-Fuzz: {stderr.decode()}")
|
||||
|
||||
return oss_fuzz_dir
|
||||
|
||||
async def _setup_project(self, oss_fuzz_dir: Path, config: Dict[str, Any], workspace: Path):
|
||||
"""Setup OSS-Fuzz project"""
|
||||
project_name = config["project_name"]
|
||||
project_dir = oss_fuzz_dir / "projects" / project_name
|
||||
|
||||
# Create project directory if it doesn't exist
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy source if provided
|
||||
source_dir = workspace / config["source_dir"]
|
||||
if source_dir.exists():
|
||||
# Create symlink or copy source
|
||||
logger.info(f"Setting up source directory: {source_dir}")
|
||||
|
||||
# Setup required files if they don't exist
|
||||
await self._create_project_files(project_dir, config, workspace)
|
||||
|
||||
async def _create_project_files(self, project_dir: Path, config: Dict[str, Any], workspace: Path):
|
||||
"""Create required OSS-Fuzz project files"""
|
||||
|
||||
# Create Dockerfile if it doesn't exist
|
||||
dockerfile = project_dir / config.get("dockerfile", "Dockerfile")
|
||||
if not dockerfile.exists():
|
||||
dockerfile_content = f'''FROM gcr.io/oss-fuzz-base/base-builder
|
||||
COPY . $SRC/{config["project_name"]}
|
||||
WORKDIR $SRC/{config["project_name"]}
|
||||
COPY {config.get("build_script", "build.sh")} $SRC/
|
||||
'''
|
||||
dockerfile.write_text(dockerfile_content)
|
||||
|
||||
# Create build.sh if it doesn't exist
|
||||
build_script = project_dir / config.get("build_script", "build.sh")
|
||||
if not build_script.exists():
|
||||
build_content = f'''#!/bin/bash -eu
|
||||
# Build script for {config["project_name"]}
|
||||
# Add your build commands here
|
||||
echo "Building {config['project_name']}..."
|
||||
'''
|
||||
build_script.write_text(build_content)
|
||||
build_script.chmod(0o755)
|
||||
|
||||
# Create project.yaml if it doesn't exist
|
||||
project_yaml = project_dir / config.get("project_yaml", "project.yaml")
|
||||
if not project_yaml.exists():
|
||||
yaml_content = f'''homepage: "https://example.com"
|
||||
language: c++
|
||||
primary_contact: "security@example.com"
|
||||
auto_ccs:
|
||||
- "fuzzing@example.com"
|
||||
sanitizers:
|
||||
- {config.get("sanitizer", "address")}
|
||||
architectures:
|
||||
- {config.get("architecture", "x86_64")}
|
||||
fuzzing_engines:
|
||||
- {config.get("fuzzing_engine", "libfuzzer")}
|
||||
'''
|
||||
project_yaml.write_text(yaml_content)
|
||||
|
||||
async def _run_oss_fuzz(self, oss_fuzz_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run OSS-Fuzz"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
project_name = config["project_name"]
|
||||
sanitizer = config.get("sanitizer", "address")
|
||||
architecture = config.get("architecture", "x86_64")
|
||||
|
||||
# Build project
|
||||
if config.get("check_build", True):
|
||||
await self._build_project(oss_fuzz_dir, project_name, sanitizer, architecture)
|
||||
|
||||
# Check build
|
||||
await self._check_build(oss_fuzz_dir, project_name, sanitizer, architecture)
|
||||
|
||||
# Run fuzzing (limited time for this integration)
|
||||
timeout = min(config.get("timeout", 300), 300) # Max 5 minutes for demo
|
||||
findings = await self._run_fuzzing(oss_fuzz_dir, project_name, sanitizer, timeout, workspace)
|
||||
|
||||
# Reproduce bugs if requested
|
||||
if config.get("reproduce_bugs", False):
|
||||
repro_findings = await self._reproduce_bugs(oss_fuzz_dir, project_name, workspace)
|
||||
findings.extend(repro_findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running OSS-Fuzz: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _build_project(self, oss_fuzz_dir: Path, project_name: str, sanitizer: str, architecture: str):
|
||||
"""Build OSS-Fuzz project"""
|
||||
cmd = [
|
||||
"python3", "infra/helper.py", "build_image", project_name
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=oss_fuzz_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.warning(f"Build image failed: {stderr.decode()}")
|
||||
|
||||
async def _check_build(self, oss_fuzz_dir: Path, project_name: str, sanitizer: str, architecture: str):
|
||||
"""Check OSS-Fuzz build"""
|
||||
cmd = [
|
||||
"python3", "infra/helper.py", "check_build", project_name
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=oss_fuzz_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.warning(f"Build check failed: {stderr.decode()}")
|
||||
|
||||
async def _run_fuzzing(self, oss_fuzz_dir: Path, project_name: str, sanitizer: str, timeout: int, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Run OSS-Fuzz fuzzing"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# This is a simplified version - real OSS-Fuzz runs for much longer
|
||||
cmd = [
|
||||
"python3", "infra/helper.py", "run_fuzzer", project_name,
|
||||
"--", f"-max_total_time={timeout}"
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=oss_fuzz_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=timeout + 60
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.terminate()
|
||||
await process.wait()
|
||||
|
||||
# Parse output for crashes
|
||||
full_output = stdout.decode() + stderr.decode()
|
||||
findings = self._parse_oss_fuzz_output(full_output, workspace, sanitizer)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in OSS-Fuzz execution: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
async def _reproduce_bugs(self, oss_fuzz_dir: Path, project_name: str, workspace: Path) -> List[ModuleFinding]:
|
||||
"""Reproduce existing bugs"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for existing testcases or artifacts
|
||||
testcases_dir = oss_fuzz_dir / "projects" / project_name / "testcases"
|
||||
if testcases_dir.exists():
|
||||
for testcase in testcases_dir.iterdir():
|
||||
if testcase.is_file():
|
||||
finding = self._create_testcase_finding(testcase, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reproducing bugs: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _parse_oss_fuzz_output(self, output: str, workspace: Path, sanitizer: str) -> List[ModuleFinding]:
|
||||
"""Parse OSS-Fuzz output"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Look for common crash indicators
|
||||
lines = output.split('\n')
|
||||
crash_info = None
|
||||
|
||||
for line in lines:
|
||||
if "ERROR:" in line and any(term in line for term in ["AddressSanitizer", "MemorySanitizer", "UBSan"]):
|
||||
crash_info = {
|
||||
"type": self._extract_crash_type(line),
|
||||
"sanitizer": sanitizer,
|
||||
"line": line
|
||||
}
|
||||
elif crash_info and line.strip().startswith("#"):
|
||||
# Stack trace line
|
||||
if "stack_trace" not in crash_info:
|
||||
crash_info["stack_trace"] = []
|
||||
crash_info["stack_trace"].append(line.strip())
|
||||
|
||||
if crash_info:
|
||||
finding = self._create_oss_fuzz_finding(crash_info, workspace)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing OSS-Fuzz output: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _create_oss_fuzz_finding(self, crash_info: Dict[str, Any], workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from OSS-Fuzz crash"""
|
||||
try:
|
||||
bug_type = crash_info.get("type", "unknown")
|
||||
sanitizer = crash_info.get("sanitizer", "unknown")
|
||||
stack_trace = '\n'.join(crash_info.get("stack_trace", [])[:20])
|
||||
|
||||
severity = self._get_oss_fuzz_severity(bug_type)
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"OSS-Fuzz {bug_type.title()}",
|
||||
description=f"OSS-Fuzz detected a {bug_type} using {sanitizer} sanitizer",
|
||||
severity=severity,
|
||||
category=self._get_oss_fuzz_category(bug_type),
|
||||
file_path=None,
|
||||
recommendation=self._get_oss_fuzz_recommendation(bug_type, sanitizer),
|
||||
metadata={
|
||||
"bug_type": bug_type,
|
||||
"sanitizer": sanitizer,
|
||||
"stack_trace": stack_trace,
|
||||
"fuzzer": "oss_fuzz"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating OSS-Fuzz finding: {e}")
|
||||
return None
|
||||
|
||||
def _create_testcase_finding(self, testcase_file: Path, workspace: Path) -> ModuleFinding:
|
||||
"""Create finding from testcase file"""
|
||||
try:
|
||||
try:
|
||||
rel_path = testcase_file.relative_to(workspace)
|
||||
file_path = str(rel_path)
|
||||
except ValueError:
|
||||
file_path = str(testcase_file)
|
||||
|
||||
finding = self.create_finding(
|
||||
title="OSS-Fuzz Testcase",
|
||||
description=f"OSS-Fuzz testcase found: {testcase_file.name}",
|
||||
severity="info",
|
||||
category="testcase",
|
||||
file_path=file_path,
|
||||
recommendation="Analyze testcase to understand potential issues",
|
||||
metadata={
|
||||
"testcase_file": str(testcase_file),
|
||||
"fuzzer": "oss_fuzz"
|
||||
}
|
||||
)
|
||||
|
||||
return finding
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating testcase finding: {e}")
|
||||
return None
|
||||
|
||||
def _extract_crash_type(self, line: str) -> str:
|
||||
"""Extract crash type from error line"""
|
||||
if "heap-buffer-overflow" in line:
|
||||
return "heap_buffer_overflow"
|
||||
elif "stack-buffer-overflow" in line:
|
||||
return "stack_buffer_overflow"
|
||||
elif "use-after-free" in line:
|
||||
return "use_after_free"
|
||||
elif "double-free" in line:
|
||||
return "double_free"
|
||||
elif "memory leak" in line:
|
||||
return "memory_leak"
|
||||
else:
|
||||
return "unknown_crash"
|
||||
|
||||
def _get_oss_fuzz_severity(self, bug_type: str) -> str:
|
||||
"""Get severity for OSS-Fuzz bug type"""
|
||||
if bug_type in ["heap_buffer_overflow", "stack_buffer_overflow", "use_after_free", "double_free"]:
|
||||
return "critical"
|
||||
elif bug_type == "memory_leak":
|
||||
return "medium"
|
||||
else:
|
||||
return "high"
|
||||
|
||||
def _get_oss_fuzz_category(self, bug_type: str) -> str:
|
||||
"""Get category for OSS-Fuzz bug type"""
|
||||
if "overflow" in bug_type:
|
||||
return "buffer_overflow"
|
||||
elif "free" in bug_type:
|
||||
return "memory_corruption"
|
||||
elif "leak" in bug_type:
|
||||
return "memory_leak"
|
||||
else:
|
||||
return "memory_safety"
|
||||
|
||||
def _get_oss_fuzz_recommendation(self, bug_type: str, sanitizer: str) -> str:
|
||||
"""Get recommendation for OSS-Fuzz finding"""
|
||||
if "overflow" in bug_type:
|
||||
return "Fix buffer overflow by implementing proper bounds checking and using safe string functions."
|
||||
elif "use_after_free" in bug_type:
|
||||
return "Fix use-after-free by ensuring proper object lifetime management and setting pointers to NULL after freeing."
|
||||
elif "double_free" in bug_type:
|
||||
return "Fix double-free by ensuring each allocation has exactly one corresponding free operation."
|
||||
elif "leak" in bug_type:
|
||||
return "Fix memory leak by ensuring all allocated memory is properly freed in all code paths."
|
||||
else:
|
||||
return f"Address the {bug_type} issue detected by OSS-Fuzz with {sanitizer} sanitizer."
|
||||
|
||||
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
category_counts = {}
|
||||
sanitizer_counts = {}
|
||||
|
||||
for finding in findings:
|
||||
severity_counts[finding.severity] += 1
|
||||
category_counts[finding.category] = category_counts.get(finding.category, 0) + 1
|
||||
|
||||
sanitizer = finding.metadata.get("sanitizer", "unknown")
|
||||
sanitizer_counts[sanitizer] = sanitizer_counts.get(sanitizer, 0) + 1
|
||||
|
||||
return {
|
||||
"total_findings": len(findings),
|
||||
"severity_counts": severity_counts,
|
||||
"category_counts": category_counts,
|
||||
"sanitizer_counts": sanitizer_counts
|
||||
}
|
||||
Reference in New Issue
Block a user