From 5f09526230d7111679a519b0958cb053bebdc718 Mon Sep 17 00:00:00 2001 From: Arthur Chan Date: Tue, 13 Oct 2026 08:20:04 +0100 Subject: [PATCH 1/2] OSS-Fuzz: Move fuzzer to upstream repository Signed-off-by: Arthur Chan --- .github/workflows/ci.yml | 12 +++ .github/workflows/cifuzz.yml | 37 ++++++++++ .github/workflows/reusable-standard.yml | 8 ++ CMakeLists.txt | 1 + tests/CMakeLists.txt | 5 ++ tests/fuzz/CMakeLists.txt | 63 ++++++++++++++++ tests/fuzz/README.md | 58 +++++++++++++++ tests/fuzz/corpus/empty | Bin 0 -> 5 bytes tests/fuzz/corpus/exec_runtime_error | Bin 0 -> 15 bytes tests/fuzz/corpus/exec_success | 1 + tests/fuzz/corpus/exec_syntax_error | Bin 0 -> 51 bytes tests/fuzz/corpus/str_only | Bin 0 -> 10 bytes tests/fuzz/fuzz_replay.cc | 59 +++++++++++++++ tests/fuzz/pybind_fuzzer.cc | 94 ++++++++++++++++++++++++ 14 files changed, 338 insertions(+) create mode 100644 .github/workflows/cifuzz.yml create mode 100644 tests/fuzz/CMakeLists.txt create mode 100644 tests/fuzz/README.md create mode 100644 tests/fuzz/corpus/empty create mode 100644 tests/fuzz/corpus/exec_runtime_error create mode 100644 tests/fuzz/corpus/exec_success create mode 100644 tests/fuzz/corpus/exec_syntax_error create mode 100644 tests/fuzz/corpus/str_only create mode 100644 tests/fuzz/fuzz_replay.cc create mode 100644 tests/fuzz/pybind_fuzzer.cc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60e6fd5c52..428ca1d74d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,18 @@ jobs: python-version: ${{ matrix.python-version }} cmake-args: ${{ matrix.cmake-args }} + # One job builds the moved OSS-Fuzz harness and replays the checked-in corpus. + # The rest of the matrix does not enable this option. + fuzz-replay: + if: github.event.pull_request.draft == false + name: fuzz replay + uses: ./.github/workflows/reusable-standard.yml + with: + runs-on: ubuntu-latest + python-version: '3.13' + cmake-args: -DPYBIND11_BUILD_FUZZERS=ON + fuzz-replay: true + # Build the test suite against the precompiled pybind11 static library; a # definition missing from the -inl.h split shows up here as a link error. precompile: diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml new file mode 100644 index 0000000000..fb8a6cff1b --- /dev/null +++ b/.github/workflows/cifuzz.yml @@ -0,0 +1,37 @@ +name: CIFuzz + +on: + pull_request: + workflow_dispatch: + +permissions: {} + +jobs: + Fuzzing: + # CIFuzz builds through the OSS-Fuzz project, not this repository's CMake + # cache. The job is allowed to fail until oss-fuzz's pybind11 build.sh + # compiles tests/fuzz/pybind_fuzzer.cc with LIB_FUZZING_ENGINE set + # (google/oss-fuzz#16274). The corpus replay job in ci.yml is the required check. + continue-on-error: true + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Build Fuzzers + id: build + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master + with: + oss-fuzz-project-name: pybind11 + language: c++ + - name: Run Fuzzers + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master + with: + oss-fuzz-project-name: pybind11 + language: c++ + fuzz-seconds: 300 + - name: Upload Crash + uses: actions/upload-artifact@v4 + if: failure() && steps.build.outcome == 'success' + with: + name: artifacts + path: ./out/artifacts diff --git a/.github/workflows/reusable-standard.yml b/.github/workflows/reusable-standard.yml index 59449ac9b9..f9a33f8c31 100644 --- a/.github/workflows/reusable-standard.yml +++ b/.github/workflows/reusable-standard.yml @@ -13,6 +13,10 @@ on: runs-on: required: true type: string + fuzz-replay: + required: false + type: boolean + default: false env: PYTHONDEVMODE: 1 @@ -94,6 +98,10 @@ jobs: - name: Interface test run: cmake --build build --target test_cmake_build + - name: Fuzz corpus replay + if: inputs.fuzz-replay + run: cmake --build build --target fuzz_replay + - name: Visibility test run: cmake --build build --target test_cross_module_rtti diff --git a/CMakeLists.txt b/CMakeLists.txt index 46f0a1f11d..777d719b8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,7 @@ if(NOT DEFINED PYBIND11_USE_CROSSCOMPILING) set(_PYBIND11_USE_CROSSCOMPILING_DEFAULTED ON) endif() option(PYBIND11_USE_CROSSCOMPILING "Respect CMAKE_CROSSCOMPILING" OFF) +option(PYBIND11_BUILD_FUZZERS "Build the OSS-Fuzz harnesses" OFF) cmake_dependent_option( USE_PYTHON_INCLUDE_DIR diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4cbc364e79..36f7221799 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -72,6 +72,7 @@ option(DOWNLOAD_EIGEN "Download EIGEN" OFF) option(PYBIND11_CUDA_TESTS "Enable building CUDA tests" OFF) option(PYBIND11_TEST_SMART_HOLDER "Change the default to smart holder" OFF) option(PYBIND11_TEST_PRECOMPILE "Build the test modules against the precompiled library" OFF) +option(PYBIND11_BUILD_FUZZERS "Build the OSS-Fuzz harnesses" OFF) if(PYBIND11_TEST_PRECOMPILE) # Picked up by every pybind11_add_module() call below; any definition missing @@ -706,4 +707,8 @@ if(NOT PYBIND11_CUDA_TESTS) # Test visibility of common symbols across shared libraries add_subdirectory(test_cross_module_rtti) + + # Optional OSS-Fuzz harness. The subdirectory is a no-op unless + # PYBIND11_BUILD_FUZZERS is ON. + add_subdirectory(fuzz) endif() diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt new file mode 100644 index 0000000000..ba1ec8cbe3 --- /dev/null +++ b/tests/fuzz/CMakeLists.txt @@ -0,0 +1,63 @@ +# Optional OSS-Fuzz harness. Off by default so the normal test matrix does not +# need a libFuzzer toolchain. OSS-Fuzz and the corpus-replay CI job both build +# this same target. +option(PYBIND11_BUILD_FUZZERS "Build the OSS-Fuzz harnesses" OFF) + +# Present even when the harness is not built, so a normal CI configure does not +# fail if something asks for the target by name. +function(pybind11_add_disabled_fuzz_replay) + add_custom_target( + fuzz_replay + COMMAND ${CMAKE_COMMAND} -E echo "PYBIND11_BUILD_FUZZERS did not produce pybind_fuzzer" + COMMAND ${CMAKE_COMMAND} -E false) +endfunction() + +if(NOT PYBIND11_BUILD_FUZZERS) + pybind11_add_disabled_fuzz_replay() + return() +endif() + +possibly_uninitialized(PYTHON_MODULE_EXTENSION Python_INTERPRETER_ID) + +if("${PYTHON_MODULE_EXTENSION}" MATCHES "pypy" + OR "${Python_INTERPRETER_ID}" STREQUAL "PyPy" + OR "${PYTHON_MODULE_EXTENSION}" MATCHES "graalpy") + message(STATUS "Skipping fuzz harness on PyPy or GraalPy") + pybind11_add_disabled_fuzz_replay() + return() +endif() + +if(TARGET Python::Module AND NOT TARGET Python::Python) + message(STATUS "Skipping fuzz harness since no embed libs found") + pybind11_add_disabled_fuzz_replay() + return() +endif() + +set(_pybind11_fuzz_sources pybind_fuzzer.cc) +# OSS-Fuzz exports LIB_FUZZING_ENGINE to the object that provides main(). +# Locally, link a small file-replay driver instead. +if(DEFINED ENV{LIB_FUZZING_ENGINE} AND NOT "$ENV{LIB_FUZZING_ENGINE}" STREQUAL "") + set(_pybind11_fuzz_engine "$ENV{LIB_FUZZING_ENGINE}") +else() + list(APPEND _pybind11_fuzz_sources fuzz_replay.cc) + set(_pybind11_fuzz_engine "") + set(_pybind11_fuzz_standalone ON) +endif() + +add_executable(pybind_fuzzer ${_pybind11_fuzz_sources}) +pybind11_enable_warnings(pybind_fuzzer) +target_link_libraries(pybind_fuzzer PRIVATE pybind11::embed) +if(_pybind11_fuzz_standalone) + target_compile_definitions(pybind_fuzzer PRIVATE PYBIND11_FUZZER_STANDALONE) +else() + target_link_libraries(pybind_fuzzer PRIVATE ${_pybind11_fuzz_engine}) +endif() + +file(GLOB _pybind11_fuzz_corpus CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/corpus/*") +add_custom_target( + fuzz_replay + COMMAND "$" ${_pybind11_fuzz_corpus} + DEPENDS pybind_fuzzer + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + USES_TERMINAL + COMMENT "Replaying the checked-in fuzz corpus") diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000000..55a465575f --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,58 @@ +# Embedding fuzzer + +`pybind_fuzzer` is the existing OSS-Fuzz harness, moved here so it can be +reviewed and updated with the code it exercises. It does not define pybind11 +bindings. Each input is split into strings and used to: + +- start the embedded interpreter once, then run `py::exec` +- import `os` and look up an attribute +- construct `decimal.Decimal` and call a method on it +- construct a `py::str` + +`py::error_already_set` is caught. That is expected for invalid Python, a +missing attribute, or a bad `Decimal` argument. A failure worth reporting is a +crash, sanitizer error, or hang in pybind11's embedding layer or in CPython +reached through that layer. + +## Build and replay + +The target is off unless requested, so the normal test matrix does not need +libFuzzer: + +```bash +cmake -S . -B build -DPYBIND11_BUILD_FUZZERS=ON +cmake --build build --target pybind_fuzzer +cmake --build build --target fuzz_replay +``` + +`fuzz_replay` runs `LLVMFuzzerTestOneInput` on every file in +`tests/fuzz/corpus`. To reproduce one OSS-Fuzz artifact: + +```bash +./build/tests/fuzz/pybind_fuzzer /path/to/crash +``` + +OSS-Fuzz sets `LIB_FUZZING_ENGINE` and links that instead of `fuzz_replay.cc`. +The callback is the same either way. Standalone replay uses the same +`ConsumeRandomLengthString` length split, so the checked-in corpus exercises +the callback without a libFuzzer toolchain. An artifact produced by OSS-Fuzz +should be rerun with libFuzzer (`-runs=1 `); the standalone binary is the +maintainer path for inputs in `tests/fuzz/corpus` and for files reduced to the +same framing. + +## Reducing a crash + +1. Rebuild with `-DPYBIND11_BUILD_FUZZERS=ON` and rerun the artifact. +2. Delete trailing bytes until the crash disappears, then restore the last + required byte. Repeat from the front if the first string is not involved. +3. The remaining strings are, in order: code passed to `py::exec`, the `os` + attribute name, the `Decimal` argument, the `Decimal` method name, and the + `py::str` argument. +4. Turn the surviving call into a normal `tests/test_*.py` regression. If the + crash is only reachable through embedding, add it beside + `tests/test_with_catch` instead. + +Arbitrary `py::exec` is intentionally broad and is the behavior of the current +OSS-Fuzz target. A later harness can drive explicit `class_`, `def`, and caster +operations; that should be a separate target so this move does not change what +OSS-Fuzz is already running. diff --git a/tests/fuzz/corpus/empty b/tests/fuzz/corpus/empty new file mode 100644 index 0000000000000000000000000000000000000000..40b450dd9d8187f90cf9f13a80c3ded26f8ecfd7 GIT binary patch literal 5 KcmZQz00IC21pom6 literal 0 HcmV?d00001 diff --git a/tests/fuzz/corpus/exec_runtime_error b/tests/fuzz/corpus/exec_runtime_error new file mode 100644 index 0000000000000000000000000000000000000000..8831bc678fb4be6a99b12a076c3b46c5c0512a3c GIT binary patch literal 15 WcmXpu)Hh&YV0chenpcvUn+gCOsRXD1 literal 0 HcmV?d00001 diff --git a/tests/fuzz/corpus/exec_success b/tests/fuzz/corpus/exec_success new file mode 100644 index 0000000000..58d0701652 --- /dev/null +++ b/tests/fuzz/corpus/exec_success @@ -0,0 +1 @@ +0x = 1Igetcwd1Šas_tupleŠok \ No newline at end of file diff --git a/tests/fuzz/corpus/exec_syntax_error b/tests/fuzz/corpus/exec_syntax_error new file mode 100644 index 0000000000000000000000000000000000000000..99b507dafce1d43d7521bf5add4e30fe3de8e84b GIT binary patch literal 51 zcmb14NljDG2*}GXiBHUn&o7QoEGa3<2Xb{2byHH4GjkJj9^~c67ndey#OJ1#WaOtX F008kp6SM#T literal 0 HcmV?d00001 diff --git a/tests/fuzz/corpus/str_only b/tests/fuzz/corpus/str_only new file mode 100644 index 0000000000000000000000000000000000000000..9f781f44a9725fe7cdf514591c11db1e472041b1 GIT binary patch literal 10 RcmZQzU|_hKk(!f}4*&}<0_p$& literal 0 HcmV?d00001 diff --git a/tests/fuzz/fuzz_replay.cc b/tests/fuzz/fuzz_replay.cc new file mode 100644 index 0000000000..b30417951c --- /dev/null +++ b/tests/fuzz/fuzz_replay.cc @@ -0,0 +1,59 @@ +// Local and CI driver for LLVMFuzzerTestOneInput. OSS-Fuzz supplies its own +// main() through LIB_FUZZING_ENGINE, so this file is not linked there. + +#include +#include +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +namespace { + +std::vector +read_file(const std::string &path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + throw std::runtime_error("failed to open fuzz input: " + path); + } + input.seekg(0, std::ios::end); + const auto nbytes = input.tellg(); + if (nbytes < 0) { + throw std::runtime_error("failed to size fuzz input: " + path); + } + input.seekg(0, std::ios::beg); + std::vector bytes(static_cast(nbytes)); + if (nbytes > 0) { + input.read(reinterpret_cast(bytes.data()), nbytes); + } + if (!input) { + throw std::runtime_error("failed to read fuzz input: " + path); + } + return bytes; +} + +} // namespace + +int +main(int argc, char **argv) { + if (argc < 2) { + std::cerr << "usage: " << argv[0] << " ...\n"; + return 2; + } + try { + for (int i = 1; i < argc; ++i) { + const std::vector bytes = read_file(argv[i]); + const int rc = LLVMFuzzerTestOneInput(bytes.data(), bytes.size()); + if (rc != 0) { + std::cerr << "fuzz callback failed on " << argv[i] << "\n"; + return rc; + } + } + } catch (const std::exception &ex) { + std::cerr << ex.what() << "\n"; + return 1; + } + return 0; +} diff --git a/tests/fuzz/pybind_fuzzer.cc b/tests/fuzz/pybind_fuzzer.cc new file mode 100644 index 0000000000..73f9cd3df0 --- /dev/null +++ b/tests/fuzz/pybind_fuzzer.cc @@ -0,0 +1,94 @@ +/* Copyright 2023 Google LLC +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#include +#include + +#ifndef PYBIND11_FUZZER_STANDALONE +# include +#endif + +#include +#include + +namespace py = pybind11; + +namespace { + +// Same length split as FuzzedDataProvider::ConsumeRandomLengthString. The +// checked-in corpus is framed for this split. OSS-Fuzz still consumes with +// FuzzedDataProvider below; replay an OSS-Fuzz artifact with libFuzzer. +std::string +consume_string(const uint8_t *&data, size_t &size) { + if (size == 0) { + return {}; + } + const auto selector = static_cast(*data); + ++data; + --size; + const size_t length = (selector * (size + 1)) / 255; + const size_t n = length < size ? length : size; + std::string out(reinterpret_cast(data), n); + data += n; + size -= n; + return out; +} + +int +exercise(const std::string &code, const std::string &attr_name, const std::string &decimal_text, + const std::string &decimal_attr, const std::string &text) { + static py::scoped_interpreter guard{}; + try { + auto locals = py::dict(); + py::exec(code, py::globals(), locals); + } catch (const py::error_already_set &) { + } + + try { + py::object os = py::module_::import("os"); + py::object looked_up = os.attr(attr_name.c_str()); + (void) looked_up; + } catch (const py::error_already_set &) { + } + + try { + py::tuple args = py::make_tuple(decimal_text, py::none()); + (void) args; + py::object Decimal = py::module_::import("decimal").attr("Decimal"); + py::object pi = Decimal(decimal_text); + py::object called = pi.attr(decimal_attr.c_str())(); + (void) called; + } catch (const py::error_already_set &) { + } + + try { + py::object obj = py::str(text); + (void) obj; + } catch (const py::error_already_set &) { + } + return 0; +} + +} // namespace + +extern "C" int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { +#ifdef PYBIND11_FUZZER_STANDALONE + return exercise(consume_string(data, size), consume_string(data, size), consume_string(data, size), + consume_string(data, size), consume_string(data, size)); +#else + FuzzedDataProvider fdp(data, size); + return exercise(fdp.ConsumeRandomLengthString(), fdp.ConsumeRandomLengthString(), + fdp.ConsumeRandomLengthString(), fdp.ConsumeRandomLengthString(), + fdp.ConsumeRandomLengthString()); +#endif +} From 0ff15eed27355f1b3e7fec577e8fe08c13ed7640 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:45:51 +0000 Subject: [PATCH 2/2] style: pre-commit fixes --- tests/fuzz/fuzz_replay.cc | 6 ++---- tests/fuzz/pybind_fuzzer.cc | 27 ++++++++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/fuzz/fuzz_replay.cc b/tests/fuzz/fuzz_replay.cc index b30417951c..c12a3a48d5 100644 --- a/tests/fuzz/fuzz_replay.cc +++ b/tests/fuzz/fuzz_replay.cc @@ -12,8 +12,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); namespace { -std::vector -read_file(const std::string &path) { +std::vector read_file(const std::string &path) { std::ifstream input(path, std::ios::binary); if (!input) { throw std::runtime_error("failed to open fuzz input: " + path); @@ -36,8 +35,7 @@ read_file(const std::string &path) { } // namespace -int -main(int argc, char **argv) { +int main(int argc, char **argv) { if (argc < 2) { std::cerr << "usage: " << argv[0] << " ...\n"; return 2; diff --git a/tests/fuzz/pybind_fuzzer.cc b/tests/fuzz/pybind_fuzzer.cc index 73f9cd3df0..fa7b8e5cdc 100644 --- a/tests/fuzz/pybind_fuzzer.cc +++ b/tests/fuzz/pybind_fuzzer.cc @@ -27,8 +27,7 @@ namespace { // Same length split as FuzzedDataProvider::ConsumeRandomLengthString. The // checked-in corpus is framed for this split. OSS-Fuzz still consumes with // FuzzedDataProvider below; replay an OSS-Fuzz artifact with libFuzzer. -std::string -consume_string(const uint8_t *&data, size_t &size) { +std::string consume_string(const uint8_t *&data, size_t &size) { if (size == 0) { return {}; } @@ -43,9 +42,11 @@ consume_string(const uint8_t *&data, size_t &size) { return out; } -int -exercise(const std::string &code, const std::string &attr_name, const std::string &decimal_text, - const std::string &decimal_attr, const std::string &text) { +int exercise(const std::string &code, + const std::string &attr_name, + const std::string &decimal_text, + const std::string &decimal_attr, + const std::string &text) { static py::scoped_interpreter guard{}; try { auto locals = py::dict(); @@ -80,15 +81,19 @@ exercise(const std::string &code, const std::string &attr_name, const std::strin } // namespace -extern "C" int -LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { #ifdef PYBIND11_FUZZER_STANDALONE - return exercise(consume_string(data, size), consume_string(data, size), consume_string(data, size), - consume_string(data, size), consume_string(data, size)); + return exercise(consume_string(data, size), + consume_string(data, size), + consume_string(data, size), + consume_string(data, size), + consume_string(data, size)); #else FuzzedDataProvider fdp(data, size); - return exercise(fdp.ConsumeRandomLengthString(), fdp.ConsumeRandomLengthString(), - fdp.ConsumeRandomLengthString(), fdp.ConsumeRandomLengthString(), + return exercise(fdp.ConsumeRandomLengthString(), + fdp.ConsumeRandomLengthString(), + fdp.ConsumeRandomLengthString(), + fdp.ConsumeRandomLengthString(), fdp.ConsumeRandomLengthString()); #endif }