
Ampliando Ruzzy con LibAFL
FUENTE
The Trail of Bits Blog
DATE
READ
13 min de lectura
LibAFL ha ganado popularidad en la comunidad de pruebas de fuzzing debido a su mejor rendimiento y diseño basado en Rust, especialmente ya que libFuzzer de LLVM está en modo de mantenimiento. El autor tenía como objetivo …
LibAFL está de moda en la comunidad de fuzzing estos días, especialmente con libFuzzer de LLVM puesto en modo de mantenimiento. Escrito en Rust, LibAFL afirma rendimiento mejorado, modularidad, técnicas de fuzzing de vanguardia y compatibilidad con libFuzzer. Por estas razones, me propuse añadir soporte para LibAFL a Ruzzy, nuestro fuzzer guiado por cobertura para código Ruby puro y extensiones en C de Ruby. Esto da a los desarrolladores de Ruby e investigadores de seguridad acceso a un motor de fuzzing más avanzado y activamente mantenido sin cambiar la forma en que escriben sus harness de fuzzing. Ruzzy fue originalmente construido sobre libFuzzer de LLVM, por lo que usar la capa de compatibilidad de LibAFL debería ser lo suficientemente fácil. Sin embargo, hurgar en los interiores de sistemas complejos nunca es tan simple como parece. En esta entrada, investigaré parte de la plomería profunda dentro de estos motores de fuzzing, tomaré un desvío hacia archivos en formato ejecutable y enlazable (ELF) y, en última instancia, añadiré soporte para LibAFL a Ruzzy. Construcción con libafl_libfuzzer Ruzzy actualmente soporta Linux, así que uso un Dockerfile para el desarrollo y para campañas de fuzzing en producción. Con ese fin, usar un Dockerfile similar para el soporte de LibAFL es el punto de integración más simple. LibAFL proporciona una excelente documentación y scripts de compilación para usarlo como una biblioteca independiente. Necesitamos compilar LibAFL como una biblioteca independiente porque Ruzzy usa libFuzzer como biblioteca. Siguiendo la documentación de libafl_libfuzzer independiente, y con el script build.sh en mano, podemos compilar libFuzzer.a. Este es el archivo que, en última instancia, se enlazará en la extensión C de Ruzzy y se usará para fuzzear nuestro objetivo. Aquí están las líneas relevantes de nuestro nuevo 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
Figura 1: Construcción de libFuzzer.a de LibAFL (Dockerfile.LibAFL)
Todo esto transcurre sin problemas y nos da el resultado deseado: libFuzzer.a. A continuación, necesitamos hacer un pequeño ajuste al mecanismo de Ruzzy para determinar una biblioteca fuzzer_no_main. Usar fuzzer_no_main y -fsanitize=fuzzer-no-link es el mecanismo estándar de libFuzzer para fuzzear código que proporciona su propia función main. Esto tiene sentido para lenguajes interpretados porque el intérprete, bueno, trae su propio main. Para lograr la flexibilidad deseada en Ruzzy, simplemente necesitamos priorizar una variable ENV, si está presente, que especifique la ruta de la biblioteca fuzzer_no_main, y luego usar los valores por defecto de Clang si no está presente:
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
Figura 2: Permitirse una override de ENV para la biblioteca de fuzzing (ext/cruzzy/extconf.rb)
Ahora, vamos a construir Ruzzy con libFuzzer.a de LibAFL:
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
Figura 3: Construcción de Ruzzy con LibAFL usando un FUZZER_NO_MAIN_LIB personalizado (Dockerfile.LibAFL)
Sin embargo, esto produce el siguiente 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 ***
La clave del error aquí es .preinit_array section no está permitido en DSO. Esto fue algo nuevo para mí.
La documentación ELF relevante afirma lo siguiente: Finalmente, un archivo ejecutable puede tener funciones de preinicialización. Estas funciones se ejecutan después de que el enlazador dinámico ha construido la imagen del proceso y realizado las relocaciones, pero antes de que se ejecuten las funciones de inicialización de los objetos compartidos. Las funciones de preinicialización no están permitidas en objetos compartidos. … La tabla DT_PREINIT_ARRAY se procesa solo en un archivo ejecutable; se ignora si está contenida en un objeto compartido. Por lo tanto, los DSOs dinámicos no pueden contener una sección .preinit_array. Esto es exactamente lo que decía el error. .init, .ctors, .init_array y .preinit_array son todos mecanismos para ejecutar código antes de que comience main en un binario ELF. Explorar cada uno de estos y el orden en que se ejecutan está fuera del alcance de este post (véase esta explicación), pero basta con decir que necesitamos esquivar este detalle de implementación de libafl_libfuzzer. Así es como LibAFL y libFuzzer difieren en este respecto: $ 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 … Figura 5: .init_array vs. .preinit_array en LibAFL vs. libFuzzer
La figura de arriba muestra que el archivo de LibAFL contiene tanto las secciones .init_array como .preinit_array, mientras que el libFuzzer de Clang las reparte entre archivos diferentes. Como LibAFL usa el mismo código interceptor que Clang, también define la misma .preinit_array. El problema es que LibAFL proporciona libfuzzer_no_link_main y libfuzzer_interceptors, pero no podemos alternarlas fácilmente en tiempo de compilación. Esto nos deja con dos opciones: la solución adecuada, que es proponer un cambio upstream que permita activar o desactivar estas características en tiempo de compilación, y la solución hacky, de hacerlo funcionar. Quería seguir avanzando y ver este trabajo de principio a fin, así que empecé con la solución hacky. Esto requirió tener un truco bajo la manga: GNU ld aplica la restricción .preinit_array-in-a-DSO, pero LLVM ld no. Así que podemos modificar el procedimiento de compilación de Ruzzy para permitir pasar una ruta de ld definida por el usuario en tiempo de compilación:
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’) Figura 6: Permitir un binario ld definido por el usuario
Y ahora la construcción de Docker funciona. Pero construir las bibliotecas de fuzzing, la extensión C de Ruby y la imagen de Docker es solo el primer paso. Aún debemos ejecutar el fuzzing, que viene con su propio conjunto de desafíos. En cuanto a la corrección adecuada que mencioné antes, ya la propusimos en upstream en esta solicitud de extracción. Una vez que se integre, podemos ejecutar el script de compilación con –cargo-args –no-default-features –features no_link_main y evitar el truco de ld. Ahora, pasemos a ejecutar el fuzzing. Fuzzing con LibAFL Ruzzy incluye su propia extensión C “dummy” para probar el fuzzing y asegurarse de que todo funcione como se espera. Podemos usar esto para probar nuestros cambios de LibAFL y asegurarnos de que funcionen correctamente. Después de compilar el fuzzing y finalmente poder iniciarlo, obtuve el siguiente error:
$ docker run –rm ruzzy-libafl -runs=100000 thread ’’ (9) panicked at src/fuzz.rs:275:5: No maps available; cannot fuzz!
note: run with 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 ’’ /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:28:in ‘dummy’ /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15:in ‘fuzz’ /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15:in ‘c_fuzz’ … Figura 7: Error de tiempo de ejecución al iniciar el fuzzing
El error clave aquí es No maps available; cannot fuzz! Este error de LibAFL ocurre cuando el estado de SanitizerCoverage no está inicializado adecuadamente. Para entender esta discrepancia entre LibAFL y libFuzzer, primero debemos entender qué es SanitizerCoverage y cómo funciona. SanitizerCoverage rastrea la información de cobertura de código durante una campaña de fuzzing para mejorar el rendimiento. Heurísticas simples como si hemos descubierto nueva cobertura de código, entonces continuar mutando entradas relevantes para explorar mejor estos caminos de código, son primitivas de fuzzing potentes. La teoría subyacente es que una mayor cobertura de código resulta en más fallos y errores (estoy simplificando, pero se entiende el punto). Con ello, un motor de fuzzing necesita un mecanismo para inicializar y rastrear la información de cobertura. SanitizerCoverage ofrece una variedad de formas de rastrear la información de cobertura, todas las cuales requieren un mecanismo para inicializar el estado al inicio de una campaña de fuzzing. Por ejemplo, la documentación ofrece mecanismos de trazado pc-guard, contadores de 8 bits, bool-flag y pc-table, cada uno con una función de inicialización correspondiente. Estas funciones de inicialización se traducen y se representan eventualmentes como entradas .init_array en archivos ELF (.init_array vuelve a aparecer). Esto significa que, en última instancia, la funcionalidad de inicialización de cobertura se llama cuando el DSO se carga en tiempo de ejecución. Volviendo al error en cuestión: ¿por qué LibAFL dice No maps available; cannot fuzz! mientras que el libFuzzer de LLVM se inicia sin problemas? La distinción clave es que libFuzzer permite de forma perezosa que se incluyan nuevas matrices de contadores de cobertura en tiempo de ejecución y no se queja si no existen al inicio. LibAFL, sin embargo, requiere que estén definidas cuando arranca el fuzzing. Compare la siguiente secuencia de eventos: LibAFL LLVMFuzzerRunDriver Calls fuzz::fuzz Calls fuzz_with! Checks if coverage counters exist libFuzzer LLVMFuzzerRunDriver Calls FuzzerDriver Eventualmente llama a Fuzzer::Loop No comprueba si existen contadores de cobertura. Por lo tanto, las funciones de inicialización de cobertura se llaman en el momento de carga del DSO, tras lo cual el motor de fuzzing puede o no verificar su existencia dependiendo de la implementación. Para entender plenamente la causa de este error, tenemos que regresar y entender mejor cómo Ruzzy ejecuta su extensión C “dummy”. La imagen de Docker de Ruzzy ejecuta el código “dummy” por defecto a través de su entrypoint:
#!/bin/bash
LD_PRELOAD=$(ruby -e ‘require ruzzy; print Ruzzy::ASAN_PATH’)
ruby -e ‘require ruzzy; Ruzzy.dummy’ – $@
Figura 8: Entrada de la imagen Docker (entrypoint.sh)
Ruzzy.dummy corresponde al siguiente código: def fuzz(test_one_input, args = DEFAULT_ARGS) c_fuzz(test_one_input, args)
STEP 3: Call Ruzzy.c_fuzz (in C extension)
end def dummy_test_one_input(data)
STEP 4: Eventually call Ruzzy.dummy_test_one_input
This ‘require’ depends on LD_PRELOAD, so it’s placed inside the function
scope. This allows us to access EXT_PATH for LD_PRELOAD and not have a
circular dependency.
require ‘dummy/dummy’ c_dummy_test_one_input(data) end def dummy
STEP 1: Call Ruzzy.dummy
fuzz(->(data) { dummy_test_one_input(data) })
STEP 2: Call Ruzzy.fuzz
end Figura 9: Cadena de llamadas de Ruzzy.dummy (lib/ruzzy.rb)
Si buscas el fallo, entonces el cuerpo de dummy_test_one_input puede aportar una pista. El problema aquí es que la instrucción require ‘dummy/dummy’ se llama demasiado tarde. Esta instrucción require está cargando en realidad la extensión C compartida compilada de Ruby. ¿Recuerdas lo que aprendimos arriba sobre cargar objetos compartidos? Este objeto compartido contiene una función .init_array que inicializa el estado de los contadores de cobertura. libFuzzer utiliza perezosamente el estado de cobertura, por lo que no es tan sensible al orden de los eventos. LibAFL, sin embargo, exige que este estado ya esté inicializado antes de empezar a fuzzing. Ruzzy.dummy llama a fuzz con una lambda que llama a dummy_test_one_input. Pero como dummy_test_one_input se pasa en una lambda y no se invoca hasta que el fuzzing comienza, LibAFL falla en la llamada a c_fuzz (c_fuzz llama a LLVMFuzzerRunDriver). Esto tiene sentido dado que la traza de error inicial de Ruby apuntaba a c_fuzz. Así que nos quedamos con un parche bastante mínimo: diff –git a/lib/ruzzy.rb b/lib/ruzzy.rb index d5e9ae61..be5f8339 100644 — a/lib/ruzzy.rb +++ b/lib/ruzzy.rb @@ -25,6 +25,11 @@ module Ruzzy end def dummy
Load the instrumented shared object before calling fuzz so its coverage
maps are registered before LLVMFuzzerRunDriver starts. Some fuzzer
runtimes (e.g. LibAFL) require coverage maps to exist upfront.
- require ‘dummy/dummy’
- fuzz(->(data) { dummy_test_one_input(data) }) end Figura 10: Parche de inicialización de Ruzzy.dummy
Con los parches de ld e inicialización, LibAFL finalmente funciona (!): $ docker run –rm ruzzy-libafl -runs=100000 … (CLIENT) corpus: 3, objectives: 0, executions: 7593, exec/sec: 0.000, size_edges: 12/21 (57%), edges_stability: 11/11 (100%), edges: 12/21 (57%)
==9==ERROR: AddressSanitizer: heap-use-after-free on address 0xfcbfab6655c0 at pc 0xffffab9c1888 bp 0xffffee4ce430 sp 0xffffee4ce428 READ of size 1 at 0xfcbfab6655c0 thread T0 #0 0xffffab9c1884 in _c_dummy_test_one_input /usr/local/bundle/gems/ ruzzy-0.7.0/ext/dummy/dummy.c:18:24 … Figura 11: Fuzzing de Ruzzy con LibAFL
Esta salida de AddressSanitizer muestra que LibAFL arranca limpio y encuentra rápidamente el fallo intencional en dummy.c. La fuga de memoria heap-use-after-free en la extensión C dummy confirma que toda la tubería funciona: la instrumentación, el seguimiento de cobertura, el trazado y la detección de fallos están funcionando como se esperaba.
Probar Ruzzy con LibAFL
Lanzamos recientemente la versión 0.8.0 de Ruzzy, que incluye soporte para LibAFL. Pruébalo en tu próximo proyecto de Ruby o en una auditoría. Trabajé con Claude para implementar esta mejora, y a veces avanzaba tan rápido hacia la meta que me llevaba dos días ponernos al día. Obtener una implementación que funcione es todavía el objetivo final, y volver a trabajar un parche es mucho más fácil una vez que funciona, pero entender profundamente el parche también es valioso. Aprendí mucho sobre binarios ELF, internals del motor de fuzzing, enlazadores y compiladores a lo largo de este proceso. Los LLMs son una herramienta útil no solo para hacer las cosas, sino también para entender el mundo que nos rodea. Si te gustaría leer más sobre fuzzing, consulta los siguientes recursos: Nuestro capítulo de fuzzing en el Testing Handbook, Fuzzing continuo de extensiones C de Python, Rompiendo el compilador de Solidity con un Fuzzer. Como siempre, contáctanos si necesitas ayuda con tu próximo proyecto Ruby o campaña de fuzzing.