
A coverage-guided fuzzer for pure Ruby code and Ruby C extensions
A coverage-guided fuzzer for pure Ruby code and Ruby C extensions.
Ruzzy is heavily inspired by Google's Atheris, a Python fuzzer. Ruzzy uses libFuzzer (or LibAFL) for its coverage instrumentation and fuzzing engine. Ruzzy also supports AddressSanitizer and UndefinedBehaviorSanitizer when fuzzing C extensions. If you'd like to learn more about the inspiration behind Ruzzy, see our paper: Design and Implementation of a Coverage-Guided Ruby Fuzzer.
Table of contents:
Ruzzy supports Linux (x86-64, AArch64/ARM64) and macOS (Apple Silicon). On Windows, you can build the Dockerfile and/or use the development environment. Ruzzy requires a recent version of clang (tested back to 14.0.0), preferably the latest release. For macOS-specific setup, see notes for macOS users.
Install Ruzzy with the following command:
MAKE="make --environment-overrides V=1" \
CC="/path/to/clang" \
CXX="/path/to/clang++" \
LDSHARED="/path/to/clang -shared" \
LDSHAREDXX="/path/to/clang++ -shared" \
gem install ruzzy
There's a lot going on here, so let's break it down:
MAKE environment variable overrides the make command when compiling the Ruzzy C extension. This tells make to respect subsequent environment variables when compiling the extension.clang binaries. This ensures we have the latest clang features, which are necessary for proper fuzzing.If you run into issues installing, then you can run the following command to get debugging output:
RUZZY_DEBUG=1 gem install --verbose ruzzy
If your pure Ruby fuzzing target makes heavy usage of regexps then install regexp_parser:
gem install regexp_parser
Once installed, Ruzzy will automatically use this functionality to sample (i.e. solve) regexps it encounters while fuzzing. This allows the fuzzer to progress past regexp conditions and unlock additional coverage.
Ruzzy includes a toy example to demonstrate how it works. First, set the following environment variable:
export ASAN_OPTIONS="allocator_may_return_null=1:detect_leaks=0:use_sigaltstack=0"
ASAN_OPTIONSYou can then run the example with the following command:
LD_PRELOAD=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
ruby -e 'require "ruzzy"; Ruzzy.dummy'
LD_PRELOAD is required for the same reasons as Atheris. However, unlike ASAN_OPTIONS, you probably do not want to export it as it may interfere with other programs.
It should quickly produce a crash like the following:
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 2527961537
...
==45==ERROR: AddressSanitizer: heap-use-after-free on address 0x50c0009bab80 at pc 0xffff99ea1b44 bp 0xffffce8a67d0 sp 0xffffce8a67c8
...
SUMMARY: AddressSanitizer: heap-use-after-free /var/lib/gems/3.1.0/gems/ruzzy-0.8.0/ext/dummy/dummy.c:18:24 in _c_dummy_test_one_input
...
==45==ABORTING
MS: 4 EraseBytes-CopyPart-CopyPart-ChangeBit-; base unit: 410e5346bca8ee150ffd507311dd85789f2e171e
0x48,0x49,
HI
artifact_prefix='./'; Test unit written to ./crash-253420c1158bc6382093d409ce2e9cff5806e980
Base64: SEk=
We can see that it correctly found the input ("HI") that produced a memory violation. For more information, see dummy.c to see why this violation occurred.
You can re-run the crash case with the following command:
LD_PRELOAD=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
ruby -e 'require "ruzzy"; Ruzzy.dummy' \
./crash-253420c1158bc6382093d409ce2e9cff5806e980
The following sanitizers are available:
Ruzzy::ASAN_PATH for AddressSanitizerRuzzy::UBSAN_PATH for UndefinedBehaviorSanitizerLet's fuzz a small Ruby script as an example. Fuzzing pure Ruby code requires two Ruby scripts: a tracer script and a fuzzing harness. The tracer script is required due to an implementation detail of the Ruby interpreter.
First, the tracer script, let's call it test_tracer.rb:
# frozen_string_literal: true
require 'ruzzy'
Ruzzy.trace('test_harness.rb')
Next, the fuzzing harness, let's call it test_harness.rb:
# frozen_string_literal: true
require 'ruzzy'
def fuzzing_target(input)
if input.length == 4
if input[0] == 'F'
if input[1] == 'U'
if input[2] == 'Z'
if input[3] == 'Z'
raise
end
end
end
end
end
end
test_one_input = lambda do |data|
fuzzing_target(data) # Your fuzzing target would go here
return 0
end
Ruzzy.fuzz(test_one_input)
You can run this file and start fuzzing with the following command:
LD_PRELOAD=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
ruby test_tracer.rb
It should quickly produce a crash like the following:
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 2311041000
...
/app/ruzzy/bin/test_harness.rb:12:in `block in <top (required)>': unhandled exception
from /var/lib/gems/3.1.0/gems/ruzzy-0.8.0/lib/ruzzy.rb:15:in `c_fuzz'
from /var/lib/gems/3.1.0/gems/ruzzy-0.8.0/lib/ruzzy.rb:15:in `fuzz'
from /app/ruzzy/bin/test_harness.rb:35:in `<top (required)>'
from bin/test_tracer.rb:7:in `require_relative'
from bin/test_tracer.rb:7:in `<main>'
...
SUMMARY: libFuzzer: fuzz target exited
MS: 1 CopyPart-; base unit: 24b4b428cf94c21616893d6f94b30398a49d27cc
0x46,0x55,0x5a,0x5a,
FUZZ
artifact_prefix='./'; Test unit written to ./crash-aea2e3923af219a8956f626558ef32f30a914ebc
Base64: RlVaWg==
We can see that it correctly found the input ("FUZZ") that produced an exception.