Skip to content
Chimera readability score 58 out of 100, Graduate reading level.

LibAFL is all the rage in the fuzzing community these days, especially with LLVM’s libFuzzer being placed in maintenance mode. Written in Rust, LibAFL claims improved performance, modularity, state-of-the-art fuzzing techniques, and libFuzzer compatibility. For these reasons, I set out to add LibAFL support to Ruzzy, our coverage-guided fuzzer for pure Ruby code and Ruby C extensions. This gives Ruby developers and security researchers access to a more advanced and actively maintained fuzzing engine without changing how they write their fuzzing harnesses.
Ruzzy was originally built on top of LLVM’s libFuzzer, so using LibAFL’s compatibility layer should be easy enough. However, digging around in the internals of complex systems is never quite as simple as it seems. In this post, I will investigate some of the deep plumbing inside these fuzzing engines, take a detour into executable and linkable format (ELF) files, and ultimately add LibAFL support to Ruzzy.
Building with libafl_libfuzzer
Ruzzy currently supports Linux, so I use a Dockerfile for development and for production fuzzing campaigns. To that end, using a similar Dockerfile for LibAFL support is the simplest integration point. LibAFL provides excellent documentation and build scripts to use it as a standalone library. We need to build LibAFL as a standalone library because Ruzzy uses libFuzzer as a library.
Following along with the standalone libafl_libfuzzer
documentation, and with the build.sh
script in hand, we can build libFuzzer.a
. This is the archive that will ultimately be linked into Ruzzy’s C extension and used to fuzz our target. Here are the relevant lines from our new Dockerfile:
This all goes smoothly and gives us our desired output: libFuzzer.a
. Next, we need to make a slight tweak to Ruzzy’s mechanism for determining a fuzzer_no_main
library. Using fuzzer_no_main
and -fsanitize=fuzzer-no-link
is libFuzzer’s standard mechanism for fuzzing code that provides its own main
function. This makes sense for interpreted languages because the interpreter, well, brings its own main
.
To accomplish the desired flexibility in Ruzzy, we simply need to prioritize an ENV variable, if present, that specifies the fuzzer_no_main
library path, then fall back to Clang’s defaults if not:
Now, let’s build Ruzzy with LibAFL’s libFuzzer.a
:
However, this produces the following error:
The key error here is “.preinit_array
section is not allowed in DSO.” This was a new one for me. What is a .preinit_array
section, and what is this error trying to tell me? The relevant ELF documentation states the following:
Finally, an executable file may have pre-initialization functions. These functions are executed after the dynamic linker has built the process image and performed relocations but before any shared object initialization functions. Pre-initialization functions are not permitted in shared objects.
...
The DT_PREINIT_ARRAY table is processed only in an executable file; it is ignored if contained in a shared object.
So dynamic shared objects (DSOs) cannot contain a .preinit_array
section. This is exactly what the error told us. .init
, .ctors
, .init_array
, and .preinit_array
are all mechanisms for running code before main
starts in an ELF binary. Exploring each of these and the order in which they’re run is beyond the scope of this post (see this explanation), but suffice it to say we need to sidestep this libafl_libfuzzer
implementation detail. Here’s how LibAFL and libFuzzer differ in this regard:
The figure above shows that LibAFL’s archive contains both .init_array
and .preinit_array
sections whereas Clang’s libFuzzer splits them across different files. Since LibAFL uses the same interceptor code as Clang, it also defines the same .preinit_array
. The problem is that LibAFL provides libfuzzer_no_link_main
and libfuzzer_interceptors
features, but we cannot easily toggle them at build time.
This leaves us with two options: the proper solution, which is to propose a change upstream that allows these features to be toggled at build time, and the hacky, make-it-work solution. I wanted to keep moving forward and see this work end-to-end, so I started with the hacky solution. This required having a trick up our sleeve: GNU ld
enforces the .preinit_array
-in-a-DSO constraint, but LLVM ld
does not. So we can modify Ruzzy’s build procedure to allow passing a user defined ld
path at build time:
And now the Docker build works! But building the fuzzing libraries, Ruby C extension, and Docker image is only the first step. We still have to run the fuzzer, which comes with its own set of challenges.
As for the proper fix I mentioned earlier, we did propose it upstream in this pull request. Once that’s merged, we can run the build script with --cargo-args "--no-default-features --features no_link_main"
and avoid the ld
hack. Now, on to running the fuzzer.
Fuzzing with LibAFL
Ruzzy includes its own “dummy” C extension for testing the fuzzer and making sure everything is working as expected. We can use this to test out our LibAFL changes and make sure they’re working properly. After building the fuzzer and finally being able to start it, I got the following error:
The key error here is “No maps available; cannot fuzz!” This LibAFL error occurs when the SanitizerCoverage state is not initialized properly. To understand this discrepancy between LibAFL and libFuzzer, we must first understand what SanitizerCoverage is and how it works.
SanitizerCoverage tracks code coverage information during a fuzzing campaign to improve performance. Simple heuristics like “if we’ve discovered new code coverage, then continue to mutate relevant inputs to better explore these code paths” are powerful fuzzing primitives. The underlying theory is that higher code coverage results in more crashes and bugs (I’m oversimplifying, but you get the point). To that end, a fuzzing engine needs a mechanism for initializing and tracking coverage information.
SanitizerCoverage offers a variety of ways to track coverage information, all of which require a mechanism to initialize state at the beginning of a fuzzing campaign. For example, the documentation offers pc-guard
, 8bit-counters
, bool-flag
, and pc-table
tracing mechanisms, each with a corresponding init
function. These init
functions are eventually lowered and represented as .init_array
entries in ELF files (.init_array
strikes again). This means that, ultimately, coverage initialization functionality is called when the DSO is loaded at runtime.
Back to the error at hand: why is LibAFL saying “No maps available; cannot fuzz!” while LLVM’s libFuzzer starts up just fine? The key distinction is that libFuzzer lazily allows new coverage counter arrays to be included at runtime and does not complain if none exist at startup. LibAFL, however, requires them to be defined when the fuzzer starts. Compare the following sequence of events:
- LibAFL
LLVMFuzzerRunDriver
- Calls
fuzz::fuzz
- Calls
fuzz_with!
- Checks if coverage counters exist
- Calls
- libFuzzer
LLVMFuzzerRunDriver
- Calls
FuzzerDriver
- Eventually calls
Fuzzer::Loop
- Does not check if coverage counters exist
- Calls
So coverage init
functions are called at DSO load time, after which the fuzzing engine may or may not check for their existence depending on implementation. To fully understand the cause of this error, we have to go back and better understand how Ruzzy runs its “dummy” C extension. The Ruzzy Docker image runs the “dummy” code by default via its entrypoint:
Ruzzy.dummy
corresponds to the following code:
If you’re searching for the bug, then the body of dummy_test_one_input
may provide a hint. The issue here is that require 'dummy/dummy'
is called too late. This require
statement is actually loading the compiled Ruby C extension shared object. Remember what we learned above about loading shared objects? This shared object contains an .init_array
function that initializes the coverage counter state. libFuzzer lazily uses coverage counter state, so it is not so sensitive about the ordering of events. LibAFL, however, requires that this state already be initialized before it begins fuzzing.
Ruzzy.dummy
calls fuzz
with a lambda that calls dummy_test_one_input
. But because dummy_test_one_input
is passed in a lambda and not invoked until the fuzzer starts, LibAFL errors out in the call to c_fuzz
(c_fuzz
calls LLVMFuzzerRunDriver
). This makes sense given that the initial Ruby error traceback pointed at c_fuzz
. So we end up with a quite minimal patch:
With the ld
and initialization patches, LibAFL finally works (!):
This AddressSanitizer output shows that LibAFL starts cleanly and quickly finds the intentional bug in dummy.c
. The heap-use-after-free in the dummy C extension confirms the full pipeline is working: instrumentation, coverage tracking, tracing, and crash detection are all functioning as expected.
Try out Ruzzy with LibAFL
We recently released version 0.8.0 of Ruzzy, which includes LibAFL support. Give it a spin on your next Ruby project or audit. I worked with Claude on implementing this improvement, and sometimes it would race so far ahead to the finish line that it would take me two days to catch up. Getting a working implementation is still the end goal, and reverse engineering a patch is a lot easier after it is working, but deeply understanding the patch is valuable too. I learned a lot about ELF binaries, fuzzing engine internals, linkers, and compilers throughout this process. LLMs are a useful tool not only for getting stuff done, but also for understanding the world around us.
If you’d like to read more about fuzzing, check out the following resources:
- Our fuzzing chapter in the Testing Handbook
- Continuously fuzzing Python C extensions
- Breaking the Solidity Compiler with a Fuzzer
As always, contact us if you need help with your next Ruby project or fuzzing campaign.

Facts Only

Ruzzy is a coverage-guided fuzzer for pure Ruby code and Ruby C extensions.
LibAFL is a Rust-based fuzzing engine with improved performance and modularity compared to LLVM’s libFuzzer.
LLVM’s libFuzzer is in maintenance mode, prompting the shift to LibAFL.
Ruzzy originally used libFuzzer, and LibAFL offers a compatibility layer for easier integration.
The integration required building LibAFL as a standalone library, specifically `libFuzzer.a`.
A linker error occurred due to a `.preinitarray` section in LibAFL’s archive, which is not allowed in dynamic shared objects (DSOs).
The solution involved using LLVM’s linker instead of GNU’s to bypass the constraint.
LibAFL requires coverage counters to be initialized before fuzzing, unlike libFuzzer’s lazy initialization.
Adjustments were made to Ruzzy’s initialization sequence to ensure coverage state was ready before fuzzing began.
Version 0.8.0 of Ruzzy now includes LibAFL support.
The integration was tested using a "dummy" C extension, confirming successful bug detection.
The process provided insights into ELF binaries, fuzzing engine internals, and compiler behavior.
Collaboration with AI tools like Claude accelerated development, though human review was essential for understanding and refinement.

Executive Summary

Ruzzy, a coverage-guided fuzzer for Ruby code and C extensions, has integrated LibAFL, a Rust-based fuzzing engine, to replace its reliance on LLVM’s libFuzzer, which is now in maintenance mode. The transition aimed to leverage LibAFL’s performance, modularity, and advanced fuzzing techniques while maintaining compatibility with existing fuzzing harnesses. The integration process involved building LibAFL as a standalone library and addressing technical challenges, such as linker errors related to ELF file sections and coverage initialization discrepancies between LibAFL and libFuzzer. A key issue was the presence of a `.preinitarray` section in LibAFL’s archive, which is not allowed in dynamic shared objects (DSOs). The solution involved using LLVM’s linker instead of GNU’s to bypass this constraint, with a proposed upstream fix to toggle features at build time. Additionally, LibAFL required coverage counters to be initialized before fuzzing, unlike libFuzzer’s lazy approach, necessitating adjustments to Ruzzy’s initialization sequence. The successful integration was confirmed by detecting intentional bugs in test cases, demonstrating the pipeline’s functionality. Version 0.8.0 of Ruzzy now includes LibAFL support, offering Ruby developers and security researchers access to a more advanced fuzzing engine.
The process highlighted deeper technical insights into ELF binaries, fuzzing engine internals, and compiler behavior, underscoring the complexity of integrating modern fuzzing tools with interpreted languages. The collaboration with AI tools like Claude accelerated development, though human oversight remained critical for understanding and refining the implementation. The update positions Ruzzy as a more robust tool for Ruby fuzzing, with potential implications for security research and software reliability in Ruby ecosystems.

Full Take

The integration of LibAFL into Ruzzy reflects a broader trend in software security: the shift toward more modular, performant, and actively maintained fuzzing tools. The technical challenges encountered—such as linker constraints and coverage initialization—highlight the complexities of modern fuzzing engines, particularly when interfacing with interpreted languages like Ruby. The reliance on workarounds, like using LLVM’s linker to bypass GNU’s restrictions, underscores the tension between innovation and compatibility in software development. While the solution is functional, it also reveals a potential fragility in the ecosystem, where upstream changes or linker updates could disrupt the integration.
The narrative also touches on the role of AI in software development. The mention of Claude accelerating progress raises questions about the balance between automation and human oversight. While AI can rapidly generate solutions, the deeper understanding required to debug and refine them remains a human strength. This dynamic mirrors broader debates about the role of AI in engineering: is it a tool for augmentation or a crutch that risks obscuring underlying complexities?
From a security perspective, the successful integration of LibAFL into Ruzzy is a positive development, offering Ruby developers access to state-of-the-art fuzzing techniques. However, the reliance on a compatibility layer and workarounds introduces potential maintenance burdens. If LibAFL’s upstream changes break the integration, Ruzzy’s users could face disruptions. This scenario underscores the importance of robust testing and community engagement in open-source projects.
**Bridge Questions:**
How might the reliance on workarounds like linker swaps affect the long-term maintainability of Ruzzy’s LibAFL integration?
What are the implications of AI-driven development acceleration for the depth of understanding among engineers?
How does the shift from libFuzzer to LibAFL reflect broader trends in the evolution of fuzzing tools, and what might this mean for other language ecosystems?
**Patterns detected:** None. The analysis focuses on technical challenges and solutions without evident manipulation patterns.

Sentinel — Human

Confidence

LIKELY_HUMAN (confidence: 0.15)