Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/cifuzz.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .github/workflows/reusable-standard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
runs-on:
required: true
type: string
fuzz-replay:
required: false
type: boolean
default: false

env:
PYTHONDEVMODE: 1
Expand Down Expand Up @@ -107,5 +111,12 @@ jobs:
timeout-minutes: 3
run: cmake --build build --target cpptest

- 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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
63 changes: 63 additions & 0 deletions tests/fuzz/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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 "$<TARGET_FILE:pybind_fuzzer>" ${_pybind11_fuzz_corpus}
DEPENDS pybind_fuzzer
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
USES_TERMINAL
COMMENT "Replaying the checked-in fuzz corpus")
58 changes: 58 additions & 0 deletions tests/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -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 <file>`); 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.
Binary file added tests/fuzz/corpus/empty
Binary file not shown.
Binary file added tests/fuzz/corpus/exec_runtime_error
Binary file not shown.
1 change: 1 addition & 0 deletions tests/fuzz/corpus/exec_success
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0x = 1Igetcwd1ªas_tupleªok
Binary file added tests/fuzz/corpus/exec_syntax_error
Binary file not shown.
Binary file added tests/fuzz/corpus/str_only
Binary file not shown.
57 changes: 57 additions & 0 deletions tests/fuzz/fuzz_replay.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Local and CI driver for LLVMFuzzerTestOneInput. OSS-Fuzz supplies its own
// main() through LIB_FUZZING_ENGINE, so this file is not linked there.

#include <cstdint>
#include <exception>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);

namespace {

std::vector<uint8_t> 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<uint8_t> bytes(static_cast<size_t>(nbytes));
if (nbytes > 0) {
input.read(reinterpret_cast<char *>(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] << " <input>...\n";
return 2;
}
try {
for (int i = 1; i < argc; ++i) {
const std::vector<uint8_t> 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;
}
99 changes: 99 additions & 0 deletions tests/fuzz/pybind_fuzzer.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/* 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 <pybind11/embed.h>
#include <pybind11/pybind11.h>

#ifndef PYBIND11_FUZZER_STANDALONE
# include <fuzzer/FuzzedDataProvider.h>
#endif

#include <cstdint>
#include <string>

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<size_t>(*data);
++data;
--size;
const size_t length = (selector * (size + 1)) / 255;
const size_t n = length < size ? length : size;
std::string out(reinterpret_cast<const char *>(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
}
Loading