diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index aca5cb63c8..96f42a0f8c 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -96,6 +96,11 @@ if(BUILD_BONSAIVIEWER AND NOT BUILD_BONSAIVIEWER_WGPU) endif() option(BUILD_PACKAGE "" OFF) +option( + BUILD_FUZZERS + "Build libFuzzer security-fuzzing harnesses. Requires a Clang toolchain configured with -fsanitize=fuzzer (typically also address,undefined) via CMAKE_CXX_FLAGS." + OFF +) option( IFCOPENSHELL_DEPLOY_QT_RUNTIME "Deploy Qt runtime dependencies for installed Qt applications." @@ -749,6 +754,10 @@ if(BUILD_BONSAIVIEWER) add_subdirectory(../src/bonsaiviewer bonsaiviewer) endif() +if(BUILD_FUZZERS) + add_subdirectory(../src/ifcfuzz ifcfuzz) +endif() + # Cmake uninstall target if(NOT TARGET uninstall) configure_file( diff --git a/src/ifcfuzz/CMakeLists.txt b/src/ifcfuzz/CMakeLists.txt new file mode 100644 index 0000000000..cda383f915 --- /dev/null +++ b/src/ifcfuzz/CMakeLists.txt @@ -0,0 +1,37 @@ +################################################################################ +# # +# This file is part of IfcOpenShell. # +# # +# IfcOpenShell is free software: you can redistribute it and/or modify # +# it under the terms of the Lesser GNU General Public License as published by # +# the Free Software Foundation, either version 3.0 of the License, or # +# (at your option) any later version. # +# # +# IfcOpenShell is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# Lesser GNU General Public License for more details. # +# # +# You should have received a copy of the Lesser GNU General Public License # +# along with this program. If not, see . # +# # +################################################################################ + +# libFuzzer harness(es) for IfcOpenShell. Only built when BUILD_FUZZERS is ON, +# which is expected to be paired with a Clang toolchain configured with +# -fsanitize=fuzzer (and typically also address,undefined) in +# CMAKE_CXX_FLAGS - this target does not add sanitizer flags itself. + +include_directories("${CMAKE_SOURCE_DIR}/../src") + +add_executable(ifcparse_fuzzer ifcparse_fuzzer.cpp) +target_include_directories(ifcparse_fuzzer PRIVATE "${CMAKE_SOURCE_DIR}/../src") +target_link_libraries(ifcparse_fuzzer PRIVATE IfcParse) + +# -fsanitize=fuzzer supplies its own main() and libFuzzer's driver, so it +# must stay scoped to this one executable rather than going in the global +# CMAKE_CXX_FLAGS - every other target (including CMake's own compiler +# checks) would otherwise fail to link. ASan/UBSan, by contrast, are applied +# globally via CMAKE_CXX_FLAGS so that IfcParse itself is instrumented. +target_compile_options(ifcparse_fuzzer PRIVATE -fsanitize=fuzzer) +target_link_options(ifcparse_fuzzer PRIVATE -fsanitize=fuzzer) diff --git a/src/ifcfuzz/README.md b/src/ifcfuzz/README.md new file mode 100644 index 0000000000..df1a07d157 --- /dev/null +++ b/src/ifcfuzz/README.md @@ -0,0 +1,82 @@ +# ifcparse_fuzzer + +A libFuzzer harness for `IfcParse::IfcFile`. It parses fuzzer input entirely +in-memory (no subprocess, no temp files), then walks every parsed instance +and calls `toString()` on it to force full lazy attribute evaluation - +IfcOpenShell only tokenizes/evaluates on demand, so just constructing +`IfcFile` barely exercises the parser. + +Disabled by default (`BUILD_FUZZERS=OFF`); building it needs Clang, not GCC. + +## Build + +libFuzzer (`-fsanitize=fuzzer`) is only implemented by Clang, and it +supplies its own `main()`, so it has to be built in its own directory, +separate from any normal GCC build of IfcOpenShell - putting +`-fsanitize=fuzzer` in the global flags would break every other target, +including CMake's own compiler check. That's why `BUILD_FUZZERS` only adds +`-fsanitize=fuzzer` to this one target (see `CMakeLists.txt`); ASan/UBSan +are applied globally instead, so that `IfcParse` itself is instrumented. + +```bash +mkdir -p build-fuzz && cd build-fuzz +cmake ../cmake \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1 -fno-omit-frame-pointer" \ + -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1 -fno-omit-frame-pointer" \ + -DBUILD_FUZZERS=ON -DMINIMAL_BUILD=ON \ + -DBUILD_IFCGEOM=OFF -DBUILD_CONVERT=OFF -DWITH_OPENCASCADE=OFF \ + -DBUILD_ONLY_COMMON_SCHEMAS=ON +cmake --build . --target ifcparse_fuzzer -- -j$(nproc) +``` + +`-fno-sanitize-recover=all` matters: without it, most UBSan checks just log +and continue rather than aborting, so a fuzzing session would run straight +past real bugs without ever capturing them as a crash artifact. + +`-DBUILD_IFCGEOM=OFF -DBUILD_CONVERT=OFF -DWITH_OPENCASCADE=OFF +-DMINIMAL_BUILD=ON` keep the build scoped to `IfcParse` (the code this +harness actually exercises) so it doesn't also have to compile/instrument +OpenCASCADE-dependent geometry code. + +## Run + +```bash +mkdir -p corpus # or point at your own seed corpus of .ifc files +./src/ifcfuzz/run.sh corpus/ +``` + +`run.sh` just sets sane sanitizer defaults and execs the binary - any +libFuzzer flag can be passed through, e.g. `-jobs=4 -workers=4` for +parallel fuzzing, or `-runs=0 ` to run once against a specific input. + +No seed corpus or dictionary ships in this repo. Any small set of valid and +invalid `.ifc` files works as a starting corpus; a dictionary of STEP/IFC +tokens (`ISO-10303-21`, `HEADER`, common `IFCxxx` entity names, etc.) passed +via `-dict=` measurably helps the mutator get past the header boilerplate. + +### Leak detection + +`run.sh` sets `ASAN_OPTIONS=detect_leaks=0` by default. A leak that used to +fire on almost any malformed header (`IfcSpfLexer` allocated in +`in_memory_file_storage::read_from_stream`, not freed if header parsing +returned early or threw) has been fixed, but a second, narrower leak +remains in entity attribute parsing when a syntactically valid header is +followed by malformed entity data. libFuzzer treats a detected leak like a +crash and halts the *entire* session on the first occurrence, so leak +detection stays off by default until that one's fixed too. Run a separate, +short, deliberate pass with `ASAN_OPTIONS=detect_leaks=1` instead if you're +specifically hunting for leaks. + +## Minimizing and deduplicating crashes + +Not covered by `run.sh` - use libFuzzer's own flags directly: + +```bash +./ifcparse_fuzzer -minimize_crash=1 -max_total_time=60 -exact_artifact_path=minimized crash-input +``` + +Sanitizer reports for two different bugs can look identical at a glance +(same `SUMMARY` line) if the bug is a duplicated code pattern hit from +multiple call sites - check the full symbolized stack trace, not just the +summary, before assuming two crashes are the same bug. diff --git a/src/ifcfuzz/ifcparse_fuzzer.cpp b/src/ifcfuzz/ifcparse_fuzzer.cpp new file mode 100644 index 0000000000..82cca6a680 --- /dev/null +++ b/src/ifcfuzz/ifcparse_fuzzer.cpp @@ -0,0 +1,62 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// libFuzzer entry point for IfcParse::IfcFile. Parses the input entirely +// in-memory (no subprocess, no temp files) so a coverage-guided fuzzer can +// reach the tokenizer and argument parser directly instead of only ever +// observing IfcConvert's exit code. + +#include "ifcparse/IfcFile.h" + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0 || size > static_cast(std::numeric_limits::max())) { + return 0; + } + + try { + IfcParse::IfcFile file(const_cast(static_cast(data)), static_cast(size)); + + if (file.good()) { + // IfcOpenShell parses lazily: merely constructing IfcFile only + // tokenizes the header and indexes instance byte offsets. + // toString() forces every attribute of every instance to be + // fully parsed, which is where most tokenizer/argument bugs + // would actually be reachable. + std::ostringstream discard; + for (const auto& entity : file) { + try { + entity.second->toString(discard); + } catch (const std::exception&) { + // Malformed attributes are expected on fuzzed input. + } + } + } + } catch (const std::exception&) { + // IfcException (and friends) is expected control flow for malformed + // input, not a bug. Only crashes caught by ASan/UBSan/libFuzzer + // itself - which bypass try/catch - are findings. + } + + return 0; +} diff --git a/src/ifcfuzz/run.sh b/src/ifcfuzz/run.sh new file mode 100755 index 0000000000..d66e2af769 --- /dev/null +++ b/src/ifcfuzz/run.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Minimal runner for ifcparse_fuzzer. See README.md for build instructions +# and an explanation of the ASan/UBSan options set below. +# +# Usage: run.sh [libFuzzer args...] +# FUZZER_BIN=./build-fuzz/ifcfuzz/ifcparse_fuzzer ./run.sh corpus/ +# +# Env overrides: +# FUZZER_BIN path to the built harness (default: ./build-fuzz/ifcfuzz/ifcparse_fuzzer) +# SYMBOLIZER path to llvm-symbolizer, if not already on PATH + +set -euo pipefail + +BIN="${FUZZER_BIN:-./build-fuzz/ifcfuzz/ifcparse_fuzzer}" + +if [ ! -x "$BIN" ]; then + echo "error: fuzzer binary not found or not executable: $BIN" >&2 + echo "build it first - see README.md in this directory" >&2 + exit 1 +fi + +SYM_OPT="" +if [ -n "${SYMBOLIZER:-}" ] && [ -x "$SYMBOLIZER" ]; then + SYM_OPT=":external_symbolizer_path=$SYMBOLIZER" +fi + +# detect_leaks defaults OFF: entity attribute parsing can still leak when +# malformed entity data follows a syntactically valid header (a narrower +# case than the old header-parse leak, which has been fixed). libFuzzer +# treats a leak like a crash and halts the whole session on the first one, +# so leak detection needs to run as a separate, deliberate, short pass +# instead of the default campaign mode. +export ASAN_OPTIONS="${ASAN_OPTIONS:-abort_on_error=1:symbolize=1:detect_leaks=0$SYM_OPT}" +export UBSAN_OPTIONS="${UBSAN_OPTIONS:-abort_on_error=1:print_stacktrace=1:symbolize=1$SYM_OPT}" + +exec "$BIN" "$@"