Skip to content

Add REPE and JSON RPC registry fuzz targets - #2737

Merged
stephenberry merged 2 commits into
mainfrom
feat/rpc-fuzz-targets
Jul 31, 2026
Merged

Add REPE and JSON RPC registry fuzz targets#2737
stephenberry merged 2 commits into
mainfrom
feat/rpc-fuzz-targets

Conversation

@stephenberry

Copy link
Copy Markdown
Owner

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.cpp and fuzzing/jsonrpc_registry.cpp. Both build scripts glob fuzzing/*.cpp, so only CMakeLists.txt needed the two create_fuzztest lines.

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:

  • raw, which exercises the header and envelope parsers
  • wrapped in valid framing, with the query/body split chosen by the fuzzer
  • wrapped against each of 15 registered paths in turn

The REPE target drives glz::repe::plugin_call alongside registry::call.

The registered API mixes value endpoints (nested structs, vectors, maps, optionals, variants) with std::function members. The std::function members 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.a not found), so the CMake haslibfuzzer check fails locally and the targets link the fallback main.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.

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.
@stephenberry
stephenberry merged commit cce1da6 into main Jul 31, 2026
59 checks passed
@stephenberry
stephenberry deleted the feat/rpc-fuzz-targets branch July 31, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant