
Extending Ruzzy with LibAFL
SOURCE
The Trail of Bits Blog
DATE
READ
12 min read
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 …
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: # Install Rust nightly via rustup RUN wget -qO- https://sh.rustup.rs | sh -s – \ -y \ –default-toolchain nightly \ –component llvm-tools ENV PATH="/root/.cargo/bin:${PATH}" # Clone LibAFL RUN git clone –depth 1 https://github.com/AFLplusplus/LibAFL /libafl # Build libFuzzer.a from LibAFL’s libfuzzer runtime WORKDIR /libafl/crates/libafl_libfuzzer_runtime RUN bash build.sh Figure 1: Building LibAFL’s libFuzzer.a (Dockerfile.LibAFL) 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: FUZZER_NO_MAIN_LIB_ENV = ‘FUZZER_NO_MAIN_LIB’ … fuzzer_no_main_lib = ENV.fetch(FUZZER_NO_MAIN_LIB_ENV, nil) if fuzzer_no_main_lib LOGGER.info(“Using #{FUZZER_NO_MAIN_LIB_ENV}=#{fuzzer_no_main_lib}”) unless File.exist?(fuzzer_no_main_lib) LOGGER.error("#{FUZZER_NO_MAIN_LIB_ENV} file does not exist: #{fuzzer_no_main_lib}") exit(1) end else fuzzer_no_main_libs = [ ’libclang_rt.fuzzer_no_main.a’, ’libclang_rt.fuzzer_no_main-aarch64.a’, ’libclang_rt.fuzzer_no_main-x86_64.a’ ] fuzzer_no_main_lib = fuzzer_no_main_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself) unless fuzzer_no_main_lib LOGGER.error(“Could not find fuzzer_no_main using #{CC}.”) LOGGER.error(“Please include #{CC} in your path or specify #{FUZZER_NO_MAIN_LIB_ENV} ENV variable.”) exit(1) end end Figure 2: Allowing an ENV override for the fuzzing library (ext/cruzzy/extconf.rb) Now, let’s build Ruzzy with LibAFL’s libFuzzer.a: # Copy LibAFL’s libFuzzer.a from builder stage COPY –from=libafl-builder /libafl/crates/libafl_libfuzzer_runtime/ libFuzzer.a /usr/lib/libFuzzer.a # Point Ruzzy at LibAFL’s libFuzzer instead of clang’s built-in ENV FUZZER_NO_MAIN_LIB="/usr/lib/libFuzzer.a" WORKDIR ruzzy/ COPY . . RUN gem build RUN RUZZY_DEBUG=1 gem install –development –verbose ruzzy-.gem Figure 3: Building Ruzzy with LibAFL using a custom FUZZER_NO_MAIN_LIB (Dockerfile.LibAFL) However, this produces the following error: INFO – : Using FUZZER_NO_MAIN_LIB=/usr/lib/libFuzzer.a DEBUG – : Search for libclang_rt.asan.a using clang-21: success=true exists=false DEBUG – : Search for libclang_rt.asan-aarch64.a using clang-21: success=true exists=true DEBUG – : Search for libclang_rt.asan-x86_64.a using clang-21: success=true exists=false DEBUG – : Creating /usr/lib/llvm-21/lib/clang/21/lib/linux/libclang_rt.asan-aarch64.a sanitizer archive at /tmp/20260320-20-683d0b DEBUG – : Merging sanitizer at /tmp/20260320-20-683d0b with libFuzzer at /usr/lib/libFuzzer.a to asan_with_fuzzer.so /usr/bin/ld: /usr/lib/libFuzzer.a(libFuzzer.o): .preinit_array section is not allowed in DSO /usr/bin/ld: failed to set dynamic section sizes: nonrepresentable section on output clang++-21: error: linker command failed with exit code 1 (use -v to see invocation) ERROR – : The clang++-21 shared object merging command failed. *** extconf.rb failed *** Figure 4: Failure linking libFuzzer.a 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: $ objdump -h /usr/lib/libFuzzer.a | grep ‘init_array’ 3100 .init_array 00000228 … 5047 .preinit_array 00000008 … 32136 .init_array.00099 00000008 … 37083 .init_array.90 00000010 … $ objdump -h libclang_rt.fuzzer-aarch64.a | grep ‘init_array’ 40 .init_array 00000008 … 57 .init_array 00000008 … $ objdump -h libclang_rt.fuzzer_no_main-aarch64.a | grep ‘init_array’ 40 .init_array 00000008 … 57 .init_array 00000008 … $ objdump -h libclang_rt.fuzzer_interceptors-aarch64.a | grep ‘init_array’ 21 .preinit_array 00000008 … Figure 5: .init_array vs. .preinit_array in LibAFL vs. libFuzzer 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: diff –git a/Dockerfile.LibAFL b/Dockerfile.LibAFL index 5d0f9516..df6be2e2 100644 — a/Dockerfile.LibAFL +++ b/Dockerfile.LibAFL @@ -54,9 +54,12 @@ RUN echo “deb http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION && echo “deb-src http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION main” » /etc/apt/sources.list.d/ llvm.list \ && wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key > /etc/apt/trusted.gpg.d/apt.llvm.org.asc +# Install lld alongside clang. LibAFL’s libFuzzer.a contains a .preinit_array +# .preinit_array section that the GNU linker rejects in shared objects. +# lld handles this correctly. RUN apt update && apt install -y \ build-essential \ clang-$LLVM_VERSION \ + lld-$LLVM_VERSION \ && rm -rf /var/lib/apt/lists/ ENV APP_DIR="/app” @@ -69,6 +72,10 @@ ENV LDSHARED=“clang-$LLVM_VERSION -shared” ENV LDSHAREDXX=“clang++-$LLVM_VERSION -shared” ENV ASAN_SYMBOLIZER_PATH="/usr/bin/llvm-symbolizer-$LLVM_VERSION" +# Use lld for linking. LibAFL’s libFuzzer.a contains a .preinit_array section +# that the GNU linker rejects in shared objects. lld handles this correctly. +ENV LD=“lld-$LLVM_VERSION” + ENV MAKE=“make –environment-overrides V=1” ENV ASAN_OPTIONS=“symbolize=1:allocator_may_return_null=1: detect_leaks=0:use_sigaltstack=0” diff –git a/ext/cruzzy/extconf.rb b/ext/cruzzy/extconf.rb index 6f474e62..260fcae6 100644 — a/ext/cruzzy/extconf.rb +++ b/ext/cruzzy/extconf.rb @@ -19,6 +19,7 @@ LOGGER.level = ENV.key?(‘RUZZY_DEBUG’) ? Logger::DEBUG : Logger::INFO CC = ENV.fetch(‘CC’, ‘clang’) CXX = ENV.fetch(‘CXX’, ‘clang++’) AR = ENV.fetch(‘AR’, ‘ar’) +LD = ENV.fetch(‘LD’, ’ld’) FUZZER_NO_MAIN_LIB_ENV = ‘FUZZER_NO_MAIN_LIB’ LOGGER.debug(“Ruby CC: #{RbConfig::CONFIG[‘CC’]}”) @@ -66,6 +67,7 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp ‘-ldl’, ‘-lstdc++’, ‘-shared’, + “-fuse-ld=#{LD}”, ‘-o’, merged_output ) @@ -145,5 +147,6 @@ merge_sanitizer_libfuzzer_lib( $LOCAL_LIBS = fuzzer_no_main_lib $LIBS « ’ -lstdc++’ +$DLDFLAGS « " -fuse-ld=#{LD}" create_makefile(‘cruzzy/cruzzy’) Figure 6: Allow a user-specified ld binary 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: $ docker run –rm ruzzy-libafl -runs=100000 thread ‘RUST_BACKTRACE=1 environment variable to display a backtrace fatal runtime error: failed to initiate panic, error 2786066624, aborting /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15: [BUG] Aborted at 0x0000000000000009 ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [aarch64-linux] – Control frame information ———————————————– c:0005 p:—- s:0022 e:000021 l:y b:—- CFUNC :c_fuzz c:0004 p:0011 s:0016 e:000015 l:y b:0001 METHOD /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15 c:0003 p:0008 s:0010 E:001390 l:y b:0001 METHOD /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:28 c:0002 p:0010 s:0006 e:000005 l:n b:—- EVAL -e:1 [FINISH] c:0001 p:0000 s:0003 E:000940 l:y b:—- DUMMY [FINISH] – Ruby level backtrace information —————————————- -e:1:in ‘