Add REPE and JSON RPC registry fuzz targets - #2737
Merged
Merged
Conversation
The registry parses bytes it does not own, reachable by anyone who can send a message: a span handed to registry::call, the same span handed across an FFI boundary by repe::plugin_call, and a string_view handed to the JSON RPC call. None of them carry a null terminator. #2732 fixed three out-of-bounds reads on that surface and there was no fuzzer watching it. Both targets allocate every buffer at exactly the size of its contents, so a read one byte past the end lands in ASAN's redzone rather than in slack the allocator happened to leave behind. That is what made the #2732 defects visible in the first place. Random bytes almost never look like a REPE message or a JSON RPC envelope, so each input is replayed three ways: raw, which fuzzes the header and envelope parsers; wrapped in valid framing, which fuzzes the dispatch and the reader behind it; and wrapped against each registered path, so the reader is reached before the fuzzer has learned what a valid JSON pointer or method name looks like. The REPE target drives plugin_call alongside registry::call and reads each response back as a message. The registered surface covers the three shapes that read differently: plain members, std::function members (the only way to reach the reader that parses call arguments), and a variant, whose resolution re-parses speculatively and is the likeliest place for a reader to walk off the end. Both scripts that build these discover fuzzing/*.cpp, so neither needed a change.
Coverage gaps and wasted budget, not defects. The targets did reach live code -- the 15 paths resolve and the framing is valid -- but they spent half their time on nothing and left the riskiest registration unfuzzed. Member functions declared in a glz::meta reach different endpoint registrations than std::function members do, and one of them, register_member_function_with_params_endpoint, parks its argument in a static thread_local. That is exactly the cross-input state that turns a crash into one that will not reproduce from a saved input, and it had no coverage at all. Both targets now register a second object whose members are member functions. The claim in the first version -- that plain member functions are not reflected as callables -- was wrong: they are, when a glz::meta lists them. Only pure reflection of a bare struct skips them, which is what the original probe happened to test. plugin_call ran once per drive. It forwards to the same registry::call overload the drive already used, with the same span, so it was doubling the work of every input for one wrapper line. Once per input now, which roughly halves the time per input. The root endpoint was missing from both path lists. It reads the whole registered object in one document -- nested struct, containers, optional and variant together -- so it is the widest single reader available, and in JSON RPC it was unreachable from the wrapped pass entirely. The wide object is now registered last so that the root resolves to it. The BEVE body_format bit was inert: nothing on the request side reads hdr.body_format, so half the flag space produced no new coverage while implying binary bodies were being fuzzed. Replaced with hdr.ec, which does have a branch behind it -- the registry echoes an errored request back without dispatching it, and nothing else here ever set it. The REPE target parsed its response straight out of a std::string, whose data()[size()] is an implicit '\0' with allocator slack behind it. That is the one thing this target is built to avoid, and the JSON RPC target already routed its response through exact_buffer. Fixed to match. Also dropped two dead clamps in the query/body split whose conditions could not fire. Verified: both build under the repo's own CMake flags, all new paths resolve to real handlers rather than method-not-found, and 60,000 rounds each through a local seed-and-mutate driver under ASan and UBSan are clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The REPE and JSON RPC registries parse bytes they do not own, straight off a socket, and had no fuzz coverage. #2732 found three over-reads there by hand; this is the coverage that would have found them.
fuzzing/repe_registry.cppandfuzzing/jsonrpc_registry.cpp. Both build scripts globfuzzing/*.cpp, so onlyCMakeLists.txtneeded the twocreate_fuzztestlines.Design
Every buffer is allocated at exactly its content size. An over-read then lands in an ASan redzone instead of allocator slack. This is the property that makes registry over-reads visible at all.
Each input is replayed three ways, so the reader is reached before the fuzzer has to discover valid framing on its own:
The REPE target drives
glz::repe::plugin_callalongsideregistry::call.The registered API mixes value endpoints (nested structs, vectors, maps, optionals, variants) with
std::functionmembers. Thestd::functionmembers matter: plain member functions are not reflected as callables, so an API without them answers "Method not found" and the argument-parsing reader is never reached. A probe confirmed this before the targets were written.Verification
Apple clang ships no libFuzzer runtime (
libclang_rt.fuzzer_osx.anot found), so the CMakehaslibfuzzercheck fails locally and the targets link the fallbackmain.cpp. I drove them instead with a deterministic seed-plus-mutation harness under ASan/UBSan: 150,000 rounds each, clean.That proves the targets reach live code and survive it. It is not a fuzzing run — real coverage-guided fuzzing needs a toolchain that has libFuzzer, which OSS-Fuzz picks up via
ossfuzz.sh.