Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,44 @@ on:

jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# https://www.ruby-lang.org/en/downloads/branches/
ruby-version:
- "3.2"
- "3.3"
- "3.4"
- "4.0"
include:
- os: ubuntu-latest
ruby-version: "3.2"
ldshared-flags: "-shared"
- os: ubuntu-latest
ruby-version: "3.3"
ldshared-flags: "-shared"
- os: ubuntu-latest
ruby-version: "3.4"
ldshared-flags: "-shared"
- os: ubuntu-latest
ruby-version: "4.0"
ldshared-flags: "-shared"
- os: macos-latest
ruby-version: "4.0"
ldshared-flags: "-dynamic -bundle -undefined dynamic_lookup"
steps:
- uses: actions/checkout@v4
- name: Set up Ruby ${{ matrix.ruby-version }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
- name: Install Homebrew LLVM
if: runner.os == 'macOS'
run: |
brew install llvm
echo "/opt/homebrew/opt/llvm/bin" >> "$GITHUB_PATH"
- run: gem build
- run: gem install --verbose ruzzy-*.gem
env:
RUZZY_DEBUG: "1"
MAKE: "make --environment-overrides V=1"
CC: "clang"
CXX: "clang++"
LDSHARED: "clang -shared"
LDSHAREDXX: "clang++ -shared"
CC: clang
CXX: clang++
LDSHARED: clang ${{ matrix.ldshared-flags }}
LDSHAREDXX: clang++ ${{ matrix.ldshared-flags }}
33 changes: 32 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
workflow_dispatch:

jobs:
test:
test-linux:
runs-on: ubuntu-latest
strategy:
matrix:
Expand Down Expand Up @@ -48,3 +48,34 @@ jobs:
--env LD_PRELOAD=$(docker run --entrypoint ruby ruzzy -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
--entrypoint rake \
ruzzy test

test-macos:
runs-on: macos-latest
strategy:
matrix:
include:
- ruby-version: "4.0"
llvm-version: "21"
steps:
- uses: actions/checkout@v4
- name: Install Homebrew LLVM and Ruby
run: |
brew install llvm@${{ matrix.llvm-version }} ruby@${{ matrix.ruby-version }}
echo "$(brew --prefix llvm@${{ matrix.llvm-version }})/bin" >> "$GITHUB_PATH"
echo "$(brew --prefix ruby@${{ matrix.ruby-version }})/bin" >> "$GITHUB_PATH"
- run: gem build
- run: gem install --verbose --development ruzzy-*.gem
env:
RUZZY_DEBUG: "1"
MAKE: "make --environment-overrides V=1"
CC: clang
CXX: clang++
LDSHARED: clang -dynamic -bundle -undefined dynamic_lookup
LDSHAREDXX: clang++ -dynamic -bundle -undefined dynamic_lookup
- name: Run tests
# Need this rake loader hack to avoid SIP-protected programs that strip DYLD_*
run: |
LOADER=$(ruby -rrake -e 'puts Gem.loaded_specs["rake"].full_gem_path')/lib/rake/rake_test_loader.rb
DYLD_INSERT_LIBRARIES=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
ASAN_OPTIONS="allocator_may_return_null=1:detect_leaks=0:use_sigaltstack=0:abort_on_error=0" \
ruby -w -Ilib "$LOADER" test/test_*.rb
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- macOS support for pure Ruby and C extension fuzzing ([#11](https://github.com/trailofbits/ruzzy/issues/11))

### Changed

- Fixed argv0 handling of libFuzzer re-exec commands ([#30](https://github.com/trailofbits/ruzzy/issues/30))

## [0.8.0] - 2026-04-27

### Added
Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Table of contents:
- [Fuzzing Ruby C extensions](#fuzzing-ruby-c-extensions)
- [API](#api)
- [FuzzedDataProvider](#fuzzeddataprovider)
- [Notes for macOS users](#notes-for-macos-users)
- [Trophy case](#trophy-case)
- [Developing](#developing)
- [Compiling](#compiling)
Expand All @@ -26,7 +27,7 @@ Table of contents:

# Installing

Currently, Ruzzy only supports Linux x86-64 and AArch64/ARM64. If you'd like to run Ruzzy on a Mac or Windows, you can build the [`Dockerfile`](https://github.com/trailofbits/ruzzy/blob/main/Dockerfile) and/or use the [development environment](#developing). Ruzzy requires a recent version of `clang` (tested back to `14.0.0`), preferably the [latest release](https://github.com/llvm/llvm-project/releases).
Ruzzy supports Linux (x86-64, AArch64/ARM64) and macOS (Apple Silicon). On Windows, you can build the [`Dockerfile`](https://github.com/trailofbits/ruzzy/blob/main/Dockerfile) and/or use the [development environment](#developing). Ruzzy requires a recent version of `clang` (tested back to `14.0.0`), preferably the [latest release](https://github.com/llvm/llvm-project/releases). For macOS-specific setup, see [notes for macOS users](#notes-for-macos-users).

Install Ruzzy with the following command:

Expand Down Expand Up @@ -280,6 +281,51 @@ Ruzzy.fuzz(test_one_input)

All methods return default values (`0`, `""`, `false`, `min`) when data is exhausted.

# Notes for macOS users

Ruzzy on macOS requires Homebrew-installed LLVM (Apple Clang does not include libFuzzer) and a non-system Ruby (the system Ruby at `/usr/bin/ruby` is SIP-protected, which strips `DYLD_*` environment variables before Ruby starts).

## Prerequisites

```bash
brew install llvm ruby
```

Any non-system Ruby works (`brew`, `rbenv`, `asdf`), but see the [caveats](#caveats) below for shim-based version managers.

## Installing

Use the Homebrew Clang paths and macOS-appropriate linker flags:

```bash
MAKE="make --environment-overrides V=1" \
CC="$(brew --prefix llvm)/bin/clang" \
CXX="$(brew --prefix llvm)/bin/clang++" \
LDSHARED="$(brew --prefix llvm)/bin/clang -dynamic -bundle -undefined dynamic_lookup" \
LDSHAREDXX="$(brew --prefix llvm)/bin/clang++ -dynamic -bundle -undefined dynamic_lookup" \
gem install ruzzy
```

## Running

Use `DYLD_INSERT_LIBRARIES` instead of `LD_PRELOAD`:

```bash
DYLD_INSERT_LIBRARIES=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
ruby -e 'require "ruzzy"; Ruzzy.dummy'
```

`Ruzzy::ASAN_PATH` and `Ruzzy::UBSAN_PATH` resolve to `.dylib` files on macOS.

## Caveats

- **Version manager shims (`asdf`, `rbenv`) strip `DYLD_*` env vars.** These shims use `#!/usr/bin/env bash` and `/usr/bin/env` is SIP-protected, so macOS strips `DYLD_INSERT_LIBRARIES` before Ruby starts. Either use Homebrew Ruby (which has no shim) or invoke the absolute path to the installed Ruby binary:
```bash
DYLD_INSERT_LIBRARIES=$(/path/to/ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
/path/to/ruby your_fuzzer.rb
```
- **Recent LLVM required.** Some older versions of Homebrew LLVM (notably 19.x) have a bug where `DYLD_INSERT_LIBRARIES`'ing the ASan dylib hangs the process during startup. If you see Ruzzy hang on launch, update Homebrew LLVM (`brew upgrade llvm`).

# Trophy case

Bugs found using Ruzzy:
Expand Down
85 changes: 78 additions & 7 deletions ext/cruzzy/extconf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
LOGGER = Logger.new($stderr)
LOGGER.level = ENV.key?('RUZZY_DEBUG') ? Logger::DEBUG : Logger::INFO

HOST_OS = RbConfig::CONFIG['host_os']
MACOS = !!(HOST_OS =~ /darwin/)
DLEXT = MACOS ? 'dylib' : 'so'

# These ENV variables really shouldn't be used because we don't support
# compilers other than clang, like gcc, etc. Instead prefer to properly include
# clang in your PATH. But they're here if you really need them. Also note that
Expand All @@ -19,9 +23,10 @@
CC = ENV.fetch('CC', 'clang')
CXX = ENV.fetch('CXX', 'clang++')
AR = ENV.fetch('AR', 'ar')
LD = ENV.fetch('LD', 'ld')
LD = ENV.fetch('LD', MACOS ? '/usr/bin/ld' : 'ld')
FUZZER_NO_MAIN_LIB_ENV = 'FUZZER_NO_MAIN_LIB'

LOGGER.debug("Ruby OS: #{HOST_OS}")
LOGGER.debug("Ruby CC: #{RbConfig::CONFIG['CC']}")
LOGGER.debug("Ruby CXX: #{RbConfig::CONFIG['CXX']}")
LOGGER.debug("Ruby AR: #{RbConfig::CONFIG['AR']}")
Expand All @@ -38,6 +43,44 @@ def get_clang_file_name(file_name)
end

def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_output, *preinits)
if MACOS
# The same weak-symbol problem the Atheris doc below describes also occurs
# on macOS: if only the bare sanitizer dylib is DYLD_INSERT_LIBRARIES'd,
# its weak __sanitizer_cov_* stubs land in the global symbol table first,
# and a later-loaded instrumented C extension's sancov UNDEFs bind to
# those no-ops instead of libFuzzer's strong implementation. So macOS
# needs the same merge concept — libFuzzer and the sanitizer in the same
# preloaded image — even though the mechanics differ.
#
# The macOS sanitizer ships as a dylib (libclang_rt.asan_osx_dynamic.dylib),
# not a static archive, so we can't ar-strip preinits or --whole-archive
# merge. Instead, build a thin wrapper dylib that statically pulls in
# libFuzzer via -force_load and lists the sanitizer dylib as a runtime
# dependency. DYLD_INSERT_LIBRARIES of the wrapper auto-loads the
# sanitizer dylib via dyld dep resolution, so both ASan's weak sancov
# stubs and libFuzzer's strong sancov implementations enter the global
# namespace at the same load step — strong wins, just like the Linux
# merge guarantees.
LOGGER.debug("Building macOS wrapper dylib at #{merged_output} (libFuzzer #{fuzzer_no_main_lib} + #{sanitizer_lib})")

_, status = Open3.capture2(
CXX,
'-dynamiclib',
"-Wl,-force_load,#{fuzzer_no_main_lib}",
sanitizer_lib,
'-lpthread',
'-lc++',
"-Wl,-install_name,@rpath/#{File.basename(merged_output)}",
'-o',
merged_output
)
unless status.success?
LOGGER.error("The #{CXX} dylib build command failed.")
exit(1)
end
return
end

# https://github.com/google/atheris/blob/master/native_extension_fuzzing.md#why-this-is-necessary
Tempfile.create do |file|
LOGGER.debug("Creating #{sanitizer_lib} sanitizer archive at #{file.path}")
Expand Down Expand Up @@ -90,7 +133,8 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp
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'
'libclang_rt.fuzzer_no_main-x86_64.a',
'libclang_rt.fuzzer_no_main_osx.a'
]
fuzzer_no_main_lib = fuzzer_no_main_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself)

Expand All @@ -104,7 +148,8 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp
asan_libs = [
'libclang_rt.asan.a',
'libclang_rt.asan-aarch64.a',
'libclang_rt.asan-x86_64.a'
'libclang_rt.asan-x86_64.a',
'libclang_rt.asan_osx_dynamic.dylib'
]
asan_lib = asan_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself)

Expand All @@ -116,15 +161,16 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp
merge_sanitizer_libfuzzer_lib(
asan_lib,
fuzzer_no_main_lib,
'asan_with_fuzzer.so',
"asan_with_fuzzer.#{DLEXT}",
'asan_preinit.cc.o',
'asan_preinit.cpp.o'
)

ubsan_libs = [
'libclang_rt.ubsan_standalone.a',
'libclang_rt.ubsan_standalone-aarch64.a',
'libclang_rt.ubsan_standalone-x86_64.a'
'libclang_rt.ubsan_standalone-x86_64.a',
'libclang_rt.ubsan_osx_dynamic.dylib'
]
ubsan_lib = ubsan_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself)

Expand All @@ -136,15 +182,40 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp
merge_sanitizer_libfuzzer_lib(
ubsan_lib,
fuzzer_no_main_lib,
'ubsan_with_fuzzer.so',
"ubsan_with_fuzzer.#{DLEXT}",
'ubsan_init_standalone_preinit.cc.o',
'ubsan_init_standalone_preinit.cpp.o'
)

# The LOCAL_LIBS variable allows linking arbitrary libraries into Ruby C
# extensions. It is supported by the Ruby mkmf library and C extension Makefile.
# For more information, see https://github.com/ruby/ruby/blob/master/lib/mkmf.rb.
$LOCAL_LIBS = fuzzer_no_main_lib
#
# On macOS we deliberately skip statically linking libFuzzer into cruzzy.bundle.
# Doing so would produce two libFuzzer instances in the process at runtime:
# one inside cruzzy.bundle (statically linked), one inside the
# DYLD_INSERT_LIBRARIES'd asan_with_fuzzer.dylib (force_load'd in the merge
# step). cruzzy's call to LLVMFuzzerRunDriver is a direct branch to its local
# copy because macOS's ld64 emits non-preemptible calls to symbols it
# resolves at link time. Meanwhile a sancov-instrumented C extension loaded
# later (e.g. dummy.bundle) has its sancov refs as flat-namespace UNDEFs
# that dyld resolves at runtime, picking the wrapper's strong symbols.
# Result: the fuzz driver and the instrumented code register with different
# libFuzzer instances that share no state, so libFuzzer sees no coverage
# feedback (corpus stays at 1 entry, "Is the code instrumented for
# coverage?" warning).
#
# Linux is unaffected because ELF shared objects default to semantic
# interposition: cruzzy.so's call to LLVMFuzzerRunDriver goes through the
# PLT and resolves to whichever copy is first in the global symbol table,
# i.e. the LD_PRELOAD'd asan_with_fuzzer.so. Both copies converge on the
# preloaded instance, so the duplicate is harmless.
#
# With macOS's mkmf default of `-undefined dynamic_lookup`, leaving
# $LOCAL_LIBS unset lets cruzzy.bundle ship with UNDEF refs to
# LLVMFuzzerRunDriver and __sanitizer_cov_*. These resolve at runtime to
# the single libFuzzer instance in the preloaded wrapper.
$LOCAL_LIBS = fuzzer_no_main_lib unless MACOS

$LIBS << ' -lstdc++'
$DLDFLAGS << " -fuse-ld=#{LD}"
Expand Down
5 changes: 3 additions & 2 deletions lib/ruzzy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ module Ruzzy
ARGV0 = ENV.fetch('RUZZY_ARGV0', $PROGRAM_NAME)
DEFAULT_ARGS = [ARGV0] + ARGV
EXT_PATH = Pathname.new(__FILE__).parent.parent / 'ext' / 'cruzzy'
ASAN_PATH = (EXT_PATH / 'asan_with_fuzzer.so').to_s
UBSAN_PATH = (EXT_PATH / 'ubsan_with_fuzzer.so').to_s
DLEXT = RbConfig::CONFIG['host_os'] =~ /darwin/ ? 'dylib' : 'so'
ASAN_PATH = (EXT_PATH / "asan_with_fuzzer.#{DLEXT}").to_s
UBSAN_PATH = (EXT_PATH / "ubsan_with_fuzzer.#{DLEXT}").to_s

def fuzz(test_one_input, args = DEFAULT_ARGS)
c_fuzz(test_one_input, args)
Expand Down
Loading