-
Notifications
You must be signed in to change notification settings - Fork 36
Directed fuzzing for goblin project with sydr‐fuzz (LibAFL‐DiFuzz backend)
This article discusses a methodology for directed fuzzing of Rust code using the Sydr-Fuzz interface, which is built on top of the LibAFL-DiFuzz fuzzer. Sydr-Fuzz provides a convenient way to run hybrid fuzzing by combining dynamic symbolic execution based on Sydr tool with modern fuzzers. In addition, Sydr-Fuzz supports corpus minimization, coverage collection in human-readable formats, bug detection via security predicates, and crash analysis using Casr.
The next step in the development of Sydr-Fuzz was the addition of support for directed fuzzing using LibAFL-DiFuzz. This approach allows the analysis to focus on selected code locations, making it especially useful for in-depth exploration of individual components. LibAFL-DiFuzz, built on the modular architecture of LibAFL, makes it possible to specify one or more target points that define the direction of the analysis. A static analysis step is required, during which special metrics used in the fuzzing process are calculated. During analysis, LibAFL-DiFuzz tracks the program’s execution state and manages the energy distribution of inputs, increasing the probability of reaching the target points.
To demonstrate the capabilities of hybrid directed fuzzing with LibAFL-DiFuzz via Sydr-Fuzz, we will use the goblin library, written in Rust.
To prepare a fuzzing target, you'll need to define the target points that should be reached and analysed, as well as one or more wrappers that make it possible to reach those points through fuzzing. For demonstration purposes, we will take the functions goblin::Object::parse and goblin::elf::Elf::parse along with the wrappers — one used in this guide and one for elf::Elf::parse.
Next, you'll need to prepare a special build file for the target — Makefile.toml. You can generate Makefile.toml from a template using the gen_target.py script by specifying argument values specific to the fuzzing target. To see the required arguments, you can run the following command:
$ python3 gen_target.py -h
In the generated file, build targets are defined for Sydr symbolic execution (debug), for LibAFL-DiFuzz fuzzing (target), as well as additional build targets for coverage analysis (coverage) and crash analysis (casr). A detailed description of the Makefile.toml
file will be provided in the section "Building for LibAFL-DiFuzz".
For now, let’s look at target points and the build process for the fuzzing target.
Target points are specified in the config.toml file in the following format:
[[target]]
file = "/goblin/src/archive/mod.rs"
line = 130
[[target]]
file = "/goblin/src/archive/mod.rs"
line = 164
[[target]]
file = "/goblin/src/archive/mod.rs"
line = 342
[[target]]
file = "/goblin/src/pe/debug.rs"
line = 172
For convenience, we build the fuzzing target inside a Docker image, which contains needed environment and compiles the fuzzing targets. Let’s take a look at the Dockerfile_libafl file:
ARG BASE_IMAGE="sydr/ubuntu22.04-sydr-fuzz"
FROM $BASE_IMAGE
ARG SYDR_ARCHIVE="./sydr.zip"
WORKDIR /
# Only for goblin.
RUN rustup override set nightly-2025-05-01
# Clone target from GitHub.
RUN git clone https://github.com/m4b/goblin.git
WORKDIR /goblin
# Checkout specified commit. It could be updated later.
RUN git checkout 59ec2f3c57c53aa828b6a4cb4730d1efe3e43a05
COPY build-config/Cargo.toml fuzz/
COPY sydr_*.rs fuzz/fuzz_targets/
# Copy LibAFL-DiFuzz target template.
COPY directed_target /directed_target
WORKDIR /directed_target
# Build goblin for LibAFL-DiFuzz.
ADD ${SYDR_ARCHIVE} ./
RUN unzip -o ${SYDR_ARCHIVE} && rm ${SYDR_ARCHIVE}
RUN OUT_DIR=/ cargo make all
WORKDIR /
# Create corpus.
RUN git clone https://github.com/JonathanSalwan/binary-samples /corpus && \
rm -rf /corpus/anti-disassembler && rm /corpus/MIT_LICENSE && rm /corpus/README.md
# Set SYDR_LLVM_COV env.
ENV SYDR_LLVM_COV=/usr/bin/llvm-cov-20
First, the Goblin project is cloned from GitHub, and then a specific version is checkout for analysis. Next, the necessary files are copied into the Docker image. We need to pay attention to the fact that a special archive, ${SYDR_ARCHIVE}, is required. This archive contains the binary files and libraries needed for LibAFL-DiFuzz to work. By default, its path is set to ./sydr.zip, but you can specify a different path at build time. The command OUT_DIR=/ cargo make all
builds the difuzz, target, debug, coverage, and casr targets from Makefile.toml. Let’s build the image using the command:
$ sudo docker build --build-arg SYDR_ARCHIVE="difuzz.zip" -t oss-sydr-fuzz-libafl-goblin -f ./Dockerfile_libafl .
Let’s look at the contents of the file Makefile.toml. This file defines the build targets and their dependencies, and for each target, you can specify a script that performs a particular action. In our case, the first step is to run a static analysis of the program:
[tasks.difuzz_unix]
script_runner = "@shell"
script = '''
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a insert -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse.rs
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a insert -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse_elf.rs
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a comment -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse.rs
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a comment -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse_elf.rs
cd ${EXAMPLE_DIR}
export CARGO_TARGET_DIR="${EXAMPLE_DIR}/target"
cargo clean
export RUSTC=${RUST_DIR_ABS}/libafl_rustc
RUSTFLAGS="--emit=llvm-bc -C debuginfo=2 -C debug-assertions=yes -C opt-level=0 -C target-cpu=native" cargo build --bin sydr_parse
llvm-link-18 -o=${EXAMPLE_DIR}/target/debug/sydr_parse.bc ${EXAMPLE_DIR}/target/debug/deps/*.bc
${DIFUZZ_DIR_ABS}/difuzz-rust -r sydr_parse::main -c ${PROJECT_DIR}/config.toml -b ${EXAMPLE_DIR}/target/debug/sydr_parse.bc -e ${OUT_DIR_ABS}/ets_parse.toml ${DIFUZZ_ARGS}
cargo clean
RUSTFLAGS="--emit=llvm-bc -C debuginfo=2 -C debug-assertions=yes -C opt-level=0 -C target-cpu=native" cargo build --bin sydr_parse_elf
llvm-link-18 -o=${EXAMPLE_DIR}/target/debug/sydr_parse_elf.bc ${EXAMPLE_DIR}/target/debug/deps/*.bc
${DIFUZZ_DIR_ABS}/difuzz-rust -r sydr_parse_elf::main -c ${PROJECT_DIR}/config.toml -b ${EXAMPLE_DIR}/target/debug/sydr_parse_elf.bc -e ${OUT_DIR_ABS}/ets_parse_elf.toml ${DIFUZZ_ARGS}
'''
Initially, the script insert_forkserver.py inserts a the fuzzing initialization function call into the main function, and then comments it out, since we don’t need this call yet at the static analysis stage. Next, we build the fuzzing target binary via Cargo with special arguments in RUSTFLAGS. One of the options — --emit=llvm-bc
— saves the program’s LLVM-IR modules. Then, using the llvm-link-18 tool, we combine all these modules into a single unified module required for static analysis. After that, the DiFuzz-Rust tool performs static analysis (the line right below llvm-link-18).
Let's run difuzz task and then we'll see the following DiFuzz-Rust tool log:


After the analysis, the output will include the files ets_parse.toml and ets_parse_elf.toml, as well as the program’s call graph and CFGs of the target functions together with their dominator trees in .DOT format. The ets_parse*.toml files are used during the program’s rebuild with the instrumenting compiler libafl_rustc.
Now we perform the build of the fuzzing target with the instrumentation required for the LibAFL-DiFuzz fuzzer:
[tasks.target_unix]
script_runner = "@shell"
script = '''
cd ${EXAMPLE_DIR}
export RUSTC=${RUST_DIR_ABS}/libafl_rustc
export LIBAFL_PROJECT_DIR="goblin"
${ETS_SHARED_MANAGER} -a remove -n parse
${ETS_SHARED_MANAGER} -a remove -n parse_elf
${ETS_SHARED_MANAGER} -a create -n parse
${ETS_SHARED_MANAGER} -a create -n parse_elf
${ETS_SHARED_MANAGER} -a parse -n parse -i ${OUT_DIR_ABS}/ets_parse.toml
${ETS_SHARED_MANAGER} -a parse -n parse_elf -i ${OUT_DIR_ABS}/ets_parse_elf.toml
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a uncomment -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse.rs
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a uncomment -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse_elf.rs
export CARGO_TARGET_DIR="${EXAMPLE_DIR}/target"
export LIBAFL_SHARED_NAME="parse"
cargo clean
cargo build --bin sydr_parse
mv ${EXAMPLE_DIR}/target/debug/sydr_parse ${OUT_DIR_ABS}/parse_libafl_target
export LIBAFL_SHARED_NAME="parse_elf"
cargo clean
cargo build --bin sydr_parse_elf
mv ${EXAMPLE_DIR}/target/debug/sydr_parse_elf ${OUT_DIR_ABS}/parse_elf_libafl_target
${ETS_SHARED_MANAGER} -a dump -n parse -o ${OUT_DIR_ABS}/ets_parse.toml
${ETS_SHARED_MANAGER} -a dump -n parse_elf -o ${OUT_DIR_ABS}/ets_parse_elf.toml
${ETS_SHARED_MANAGER} -a remove -n parse
${ETS_SHARED_MANAGER} -a remove -n parse_elf
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a remove -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse.rs
python3 ${DIFUZZ_DIR_ABS}/insert_forkserver.py -a remove -l rust -f ${EXAMPLE_DIR}/fuzz_targets/sydr_parse_elf.rs
'''
dependencies = ["difuzz"]
Here we specify the path to our libafl_rustc compiler, set the value of the LIBAFL_PROJECT_DIR
variable required for instrumentation, which should point to the project directory/subdirectory to be instrumented. The special manager ETS_SHARED_MANAGER enables parallel compilation of modules using shared memory. With its help, we process the ets.toml file, filling in the necessary information and creating the environment for compiling the target. The insert_forkserver.py script then uncomment the call to the fuzzing initialization function. Now we can proceed to building the target! We set the name of the environment in the LIBAFL_SHARED_NAME
variable and simply build the target via Cargo, BUT only specifying the argument --bin TARGET_BIN with the name of the target binary. At the end, the compilation environment is removed, and the call to the fuzzer initialization function is deleted.
By setting the environment variable LIBAFL_DEBUG_PASS=2
, we compile our fuzzing target. We will see the following output, which indicates that the instrumentation was successfully performed on one of the functions.

Tasks for debug, coverage, casr are much easier to make, they can be inspected in Makefile.toml file.
To run hybrid directed fuzzing via Sydr-Fuzz for two wrappers, you need to create two configuration files: parse-libafl.toml and parse_elf-libafl.toml. The files should include:
- the exit-on-time parameter, which sets the time after which fuzzing stops if no new coverage is found (optional),
- a [sydr] table specifying Sydr arguments (args, jobs) and the command line to launch the target program (target),
- a [difuzz] table specifying the path to the LibAFL-DiFuzz fuzzer (path), its arguments (args), the command line to launch the target program (target), and the path to the binary built for Casr analysis (if crash analysis is needed) (casr_bin),
- and a [cov] table specifying the command line (target field) to launch the target program for coverage collection (optional).
For example, for parse, the configuration file will look like this:
exit-on-time = 7200
[sydr]
args = "--wait-jobs -s 90 -j2"
target = "/sydr_parse @@"
jobs = 2
[difuzz]
path = "/directed_target/sydr/difuzz/libafl_difuzz"
target = "/parse_libafl_target @@"
args = "-j4 --sync-limit 200 --sync-jobs 2 --panic-analysis rust -l64 -i /corpus -e /ets_parse.toml"
casr_bin = "/casr_parse"
[cov]
target = "/cov_parse @@"
In this config, it is important to pay attention to the fuzzer option --panic-analysis rust
. This option enables the handling of panic!
errors in Rust, meaning that when this option is enabled, inputs that cause a panic will be treated as crash.
Now we can run hybrid fuzzing with Sydr-Fuzz, specifying parse-libafl.toml config file:
$ sydr-fuzz -c parse-libafl.toml run
Fuzzing has started, the LibAFL-DiFuzz processes are running and beginning to send statistics on their current status:

Almost immediately, information begins to appear about the reached target points specified in the config.toml file:

The information about reaching a target point indicates the time it was achieved. If a target point is reached multiple times, it means the fuzzer was able to generate several inputs that hit that target.
Sometimes the logs contain the following entries:

These entries indicate whether LibAFL-DiFuzz was able (or unable) to import N new files generated by Sydr into its corpus.
Finally, at the end of the analysis, information is displayed about the minimum time to reach the target, as well as data on error-finding time and crash sorting:

The result of directed fuzzing is a set of objective-inputs. Each such input is characterized by causing at least one of the following states in the target program:
- a crash,
- reaching a specified target in the code,
- a timeout.
Post-processing of the results involves two stages:
-
Minimization: For a group of inputs that produce the same outcome (e.g., reaching the same target), only the first generated file is kept. This allows accurate measurement of the TTE (Time To Exposure) metric using a representative sample. Minimization can be disabled via the configuration option
xmin = false
, which is useful for keeping the full dataset. - Clustering: The inputs remaining after minimization are grouped (clustered) by the target points they reached. For each target, a separate directory is created, named according to its location in the code. If an input triggers multiple targets, it appears in all corresponding clusters. Structurally, the output directory with clusters looks like this:

As a result of the hybrid directed fuzzing, 8 target points were reached. Directories were created in the format difuzz/crashes_TARGET_NAME:TARGET_LINE, where input files that hit these points were copied. It can be seen that 3 crash_*
files (causing crashes) and 8 target_*
files (simply reaching the target point) were found.
In directed fuzzing, coverage is collected not only for the final corpus but also for all objective files remaining after minimization. This allows you to see the code coverage for the target points (and crashes) as well.
Coverage collection via Sydr-Fuzz is launched using the command:
$ sydr-fuzz -c parse-libafl.toml cov-html
The resulting log shows the progress:

In the HTML report, you can confirm that a target point, for example /goblin/src/elf/mod.rs:365, was reached:

Crash analysis of inputs generated during fuzzing can be performed using the Casr tool. To do this, run the following command:
$ sydr-fuzz -c parse-libafl.toml casr
At this stage, only objective inputs that cause crashes are analyzed. Casr produces a separate cluster hierarchy in the directory parse-libafl-out/casr
. Running the analysis makes sense once there are not only inputs that reach target points, but also crash-inputs (these start with crash_*
after fuzzing). Running the analysis on goblin produces the following output:

In this case, 2 clusters were created, with a total of 3 crashes of type RUST_PANIC
.
This article presented an approach to hybrid directed fuzzing of Rust code, combining the LibAFL-DiFuzz fuzzer and the Sydr symbolic executor via the Sydr-Fuzz tool. The workflow for preparing the target program was demonstrated using the goblin project. It showed that Sydr-Fuzz can automate key stages of the process: directed fuzzing, minimization and clustering of results, coverage collection, and crash analysis using the Casr utility.