Compare commits

...

5 Commits

Author SHA1 Message Date
Bruno Postle e49c47fbd2 fuzz: adapt harness for v0.9.0 rebase
v0.9.0 already carries the ifcopenshell::file rename this harness was
originally adapted for, but moved Logger into the ifcopenshell namespace
too (logger::root() -> ifcopenshell::logger::root()) and never had a
BUILD_ONLY_COMMON_SCHEMAS cmake option - schema selection there has always
been via the SCHEMA_VERSIONS list. Verified with -fsyntax-only against the
system-installed v0.9.0 headers.
2026-08-19 22:21:58 +01:00
Bruno Postle d3cef0b1e6 fuzz: wire Logger output for single-input repro runs
Logger::SetOutput was never called by the harness, so every parse
warning/error (e.g. "Overwriting instance with name #N") was silently
discarded. This directly cost time root-causing a leak: grepping stdout
for an expected warning found nothing, looking like it ruled out a
hypothesis that was actually correct, because the message was just never
printed anywhere.

Only enable it when the binary is given an explicit file argument
(single-input repro, e.g. `-runs=1 <file>`), not during a real campaign
against a corpus directory, where logging on every execution would
dominate the runtime. Verified with -fsyntax-only against this branch's
headers (a real CMake build of ifcviewer-wgpu wasn't attempted, same as
the harness's prior namespace-rename commit).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:19:32 +01:00
Bruno Postle 094488ece5 fuzz: update harness for ifcopenshell::file rename
ifcviewer-wgpu renamed IfcParse::IfcFile to ifcopenshell::file (header
moved to ifcparse/file.h) and Base::toString to Base::to_string. Update
the harness and README to match; verified with a syntax-only compile
against this branch's headers.
2026-08-19 22:19:32 +01:00
Bruno Postle 4b11e62edf fuzz: correct stale comment about lazy parsing in ifcparse fuzzer
Construction of IfcFile already tokenizes, type-checks, and resolves
every attribute of every instance, so toString() isn't what makes
tokenizer/argument bugs reachable.
2026-08-19 22:19:32 +01:00
Bruno Postle 8739f6c13e Add opt-in libFuzzer harness for IfcParse::IfcFile
A coverage-guided libFuzzer harness (src/ifcfuzz/ifcparse_fuzzer.cpp) that
constructs IfcFile directly from in-memory input and calls toString() on
every parsed instance to force full lazy attribute evaluation, rather than
only observing IfcConvert's exit code from a fuzzed subprocess.

Gated behind a new BUILD_FUZZERS option (OFF by default) so it has no
effect on existing builds; enabling it requires a Clang toolchain built
with -fsanitize=fuzzer. -fsanitize=fuzzer itself stays scoped to the one
new target rather than going into the global compiler flags, since it
supplies its own main() and would otherwise break every other target
including CMake's own compiler checks.

Already found and fixed three real bugs this way: two null-pointer
dereferences (in header parsing and reference resolution) and a leak of
IfcSpfLexer on early return/exception during file scanning.

See src/ifcfuzz/README.md for build and usage instructions.
2026-08-19 22:19:32 +01:00
5 changed files with 262 additions and 0 deletions
+9
View File
@@ -109,6 +109,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."
@@ -758,6 +763,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(
+37
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
# 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)
+91
View File
@@ -0,0 +1,91 @@
# ifcparse_fuzzer
A libFuzzer harness for `ifcopenshell::file`. It parses fuzzer input entirely
in-memory (no subprocess, no temp files), then walks every parsed instance
and calls `to_string()` on it. Constructing the file already tokenizes,
type-checks, and resolves every attribute of every instance, so
`to_string()` mainly adds coverage of the reserialization/formatting code
path rather than the parser itself.
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 \
-DSCHEMA_VERSIONS="2x3;4;4x3_add2"
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 <file>` 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.
### Log output
`Logger` output is only wired up when the binary is run against an explicit
file argument (e.g. `-runs=1 <file>`), not during a real campaign against a
corpus directory - logging every parse warning on every execution of a
fuzzing campaign would dominate the runtime. Repro runs print
`[Warning]`/`[Error]` messages to stderr.
### 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.
+89
View File
@@ -0,0 +1,89 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// libFuzzer entry point for ifcopenshell::file. 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/file.h"
#include "ifcparse/logger.h"
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <limits>
#include <sstream>
#include <sys/stat.h>
namespace {
bool is_regular_file(const char* path) {
struct stat st;
return ::stat(path, &st) == 0 && S_ISREG(st.st_mode);
}
} // namespace
// libFuzzer runs in two modes: a real fuzzing campaign (given corpus
// directories to mutate from, executed millions of times) and a
// single-input repro (given one or more explicit file paths, e.g.
// `-runs=1 crashes/<hash>/input`). logger::set_output is only wired up for
// the latter -- logging every parse warning to a stream on every execution
// of a real campaign would dominate the runtime.
extern "C" int LLVMFuzzerInitialize(int* argc, char*** argv) {
for (int i = 1; i < *argc; ++i) {
if (is_regular_file((*argv)[i])) {
ifcopenshell::logger::root().set_output(&std::cerr, &std::cerr);
break;
}
}
return 0;
}
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (size == 0 || size > static_cast<size_t>(std::numeric_limits<int>::max())) {
return 0;
}
try {
ifcopenshell::file ifc_file(const_cast<void*>(static_cast<const void*>(data)), static_cast<int>(size));
if (ifc_file.good()) {
// Constructing the file already tokenizes and type-checks every
// attribute of every instance (and resolves references), so
// most tokenizer/argument bugs are reachable without going any
// further. to_string() is still exercised here since
// reserialization walks a different code path and may surface
// additional faults.
std::ostringstream discard;
for (const auto& entity : ifc_file) {
try {
entity.second.to_string(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;
}
+36
View File
@@ -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" "$@"