ruzzy 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0abba0ffb63d4c50fbbb33fb3624299f8421293a02d65e199525c75b4dbd5f46
4
- data.tar.gz: 4787489da1373bb820cd66d62a913a4c9c33c49bda68f251dae25745ccac3679
3
+ metadata.gz: 7d564227ec95d197cadc085f2190a7089fbe5610d4e6f7c9d9ff5a84a4243208
4
+ data.tar.gz: aa7c37d65b75d202e650832ffad9589c3a6a750d162760d0af934256268115aa
5
5
  SHA512:
6
- metadata.gz: c029eca231fbeb0d8b19b469a6cb2de99117a939e35c9f42622f803d06a0b4ad85ceacc25dff1bba9e56b1179358ec8fff054a35d894faa11398727891697216
7
- data.tar.gz: cb761ff27a1fa8d462295d9022d03490338d2608d94cddc8454fb2919fd308e8ee7ea18b4735e601574ca1366de266607c2966be18e27af21ab1c3e243f60237
6
+ metadata.gz: 54cc91f765a3622756e3925f62e29003ef3169a8a413af4b450b320bdea979125ede2a203be2b2fac52114abf52b3b735f8d97e395f9181eb01f080b09648988
7
+ data.tar.gz: cc8895f40905092d68bbdad2557fd691c189b04f336bb360c0f0a1b3f82a52d0227f82e966248b2b70cfd694b35e464ecd967a76d0e0426e45f5ffa2e941444f
data/ext/cruzzy/cruzzy.c CHANGED
@@ -194,11 +194,11 @@ static void event_hook_branch(VALUE counter_hash, rb_trace_arg_t *tracearg) {
194
194
 
195
195
  static void enable_branch_coverage_hooks()
196
196
  {
197
- // Call Coverage.setup(branches: true) to activate branch coverage hooks.
197
+ // Call Coverage.start(branches: true) to activate branch coverage hooks.
198
198
  // Branch coverage hooks will not be activated without this call despite
199
199
  // adding the event hooks. I suspect rb_set_coverages must be called
200
200
  // first, which initializes some global state that we do not have direct
201
- // access to. Calling setup initializes coverage state here:
201
+ // access to. Calling start initializes coverage state here:
202
202
  // https://github.com/ruby/ruby/blob/v3_3_0/ext/coverage/coverage.c#L112-L120
203
203
  // If rb_set_coverages is not called, then rb_get_coverages returns a NULL
204
204
  // pointer, which appears to effectively disable coverage collection here:
@@ -207,10 +207,10 @@ static void enable_branch_coverage_hooks()
207
207
  VALUE coverage_mod = rb_const_get(rb_cObject, rb_intern("Coverage"));
208
208
  VALUE hash_arg = rb_hash_new();
209
209
  rb_hash_aset(hash_arg, ID2SYM(rb_intern("branches")), Qtrue);
210
- rb_funcall(coverage_mod, rb_intern("setup"), 1, hash_arg);
210
+ rb_funcall(coverage_mod, rb_intern("start"), 1, hash_arg);
211
211
  }
212
212
 
213
- static VALUE c_trace_branch(VALUE self)
213
+ static VALUE c_trace(VALUE self, VALUE harness_path)
214
214
  {
215
215
  VALUE counter_hash = rb_hash_new();
216
216
 
@@ -230,7 +230,7 @@ static VALUE c_trace_branch(VALUE self)
230
230
 
231
231
  enable_branch_coverage_hooks();
232
232
 
233
- return Qnil;
233
+ return rb_require(StringValueCStr(harness_path));
234
234
  }
235
235
 
236
236
  void Init_cruzzy()
@@ -245,5 +245,5 @@ void Init_cruzzy()
245
245
  rb_define_module_function(ruzzy, "c_libfuzzer_is_loaded", &c_libfuzzer_is_loaded, 0);
246
246
  rb_define_module_function(ruzzy, "c_trace_cmp8", &c_trace_cmp8, 2);
247
247
  rb_define_module_function(ruzzy, "c_trace_div8", &c_trace_div8, 1);
248
- rb_define_module_function(ruzzy, "c_trace_branch", &c_trace_branch, 0);
248
+ rb_define_module_function(ruzzy, "c_trace", &c_trace, 1);
249
249
  }
@@ -19,6 +19,7 @@ LOGGER.level = ENV.key?('RUZZY_DEBUG') ? Logger::DEBUG : Logger::INFO
19
19
  CC = ENV.fetch('CC', 'clang')
20
20
  CXX = ENV.fetch('CXX', 'clang++')
21
21
  AR = ENV.fetch('AR', 'ar')
22
+ LD = ENV.fetch('LD', 'ld')
22
23
  FUZZER_NO_MAIN_LIB_ENV = 'FUZZER_NO_MAIN_LIB'
23
24
 
24
25
  LOGGER.debug("Ruby CC: #{RbConfig::CONFIG['CC']}")
@@ -64,7 +65,9 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp
64
65
  '-Wl,--no-whole-archive',
65
66
  '-lpthread',
66
67
  '-ldl',
68
+ '-lstdc++',
67
69
  '-shared',
70
+ "-fuse-ld=#{LD}",
68
71
  '-o',
69
72
  merged_output
70
73
  )
@@ -75,18 +78,24 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib, fuzzer_no_main_lib, merged_outp
75
78
  end
76
79
  end
77
80
 
78
- fuzzer_no_main_libs = [
79
- 'libclang_rt.fuzzer_no_main.a',
80
- 'libclang_rt.fuzzer_no_main-aarch64.a',
81
- 'libclang_rt.fuzzer_no_main-x86_64.a'
82
- ]
83
- fuzzer_no_main_lib = fuzzer_no_main_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself)
81
+ fuzzer_no_main_lib = ENV.fetch(FUZZER_NO_MAIN_LIB_ENV, nil)
84
82
 
85
- unless fuzzer_no_main_lib
86
- LOGGER.warn("Could not find fuzzer_no_main using #{CC}.")
87
- fuzzer_no_main_lib = ENV.fetch(FUZZER_NO_MAIN_LIB_ENV, nil)
88
- if fuzzer_no_main_lib.nil?
89
- LOGGER.error("Could not find fuzzer_no_main in #{FUZZER_NO_MAIN_LIB_ENV}.")
83
+ if fuzzer_no_main_lib
84
+ LOGGER.info("Using #{FUZZER_NO_MAIN_LIB_ENV}=#{fuzzer_no_main_lib}")
85
+ unless File.exist?(fuzzer_no_main_lib)
86
+ LOGGER.error("#{FUZZER_NO_MAIN_LIB_ENV} file does not exist: #{fuzzer_no_main_lib}")
87
+ exit(1)
88
+ end
89
+ else
90
+ fuzzer_no_main_libs = [
91
+ 'libclang_rt.fuzzer_no_main.a',
92
+ 'libclang_rt.fuzzer_no_main-aarch64.a',
93
+ 'libclang_rt.fuzzer_no_main-x86_64.a'
94
+ ]
95
+ fuzzer_no_main_lib = fuzzer_no_main_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself)
96
+
97
+ unless fuzzer_no_main_lib
98
+ LOGGER.error("Could not find fuzzer_no_main using #{CC}.")
90
99
  LOGGER.error("Please include #{CC} in your path or specify #{FUZZER_NO_MAIN_LIB_ENV} ENV variable.")
91
100
  exit(1)
92
101
  end
@@ -137,4 +146,7 @@ merge_sanitizer_libfuzzer_lib(
137
146
  # For more information, see https://github.com/ruby/ruby/blob/master/lib/mkmf.rb.
138
147
  $LOCAL_LIBS = fuzzer_no_main_lib
139
148
 
149
+ $LIBS << ' -lstdc++'
150
+ $DLDFLAGS << " -fuse-ld=#{LD}"
151
+
140
152
  create_makefile('cruzzy/cruzzy')
data/ext/dummy/dummy.c CHANGED
@@ -6,17 +6,18 @@
6
6
  // https://llvm.org/docs/LibFuzzer.html#toy-example
7
7
  static int _c_dummy_test_one_input(const uint8_t *data, size_t size)
8
8
  {
9
- char test[] = {'a', 'b', 'c'};
9
+ volatile char boom = 'x';
10
10
 
11
- if (size > 0 && data[0] == 'H') {
12
- if (size > 1 && data[1] == 'I') {
13
- // This code exists specifically to test the driver and ensure
14
- // libFuzzer is functioning as expected, so we can safely ignore
15
- // the warning.
16
- #pragma clang diagnostic push
17
- #pragma clang diagnostic ignored "-Warray-bounds"
18
- test[1024] = 'd';
19
- #pragma clang diagnostic pop
11
+ if (size == 2) {
12
+ if (data[0] == 'H') {
13
+ if (data[1] == 'I') {
14
+ // Intentional heap-use-after-free for testing purposes
15
+ char * volatile ptr = malloc(128);
16
+ ptr[0] = 'x';
17
+ free(ptr);
18
+ boom = ptr[0];
19
+ (void) boom;
20
+ }
20
21
  }
21
22
  }
22
23
 
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruzzy
4
+ # Splits raw fuzzer bytes into typed Ruby values.
5
+ #
6
+ # FuzzedDataProvider wraps a binary string (typically from libFuzzer via
7
+ # Ruzzy.fuzz) and provides methods to consume typed values from it. This
8
+ # enables fuzz targets that test APIs accepting typed arguments rather than
9
+ # raw byte strings.
10
+ #
11
+ # Following libFuzzer's FuzzedDataProvider.h design, strings and raw bytes
12
+ # are consumed from the *front* of the buffer, while integers are consumed
13
+ # from the *end*. This bidirectional consumption lets the fuzzer modify
14
+ # structural decisions (integers controlling lengths, indices, variant
15
+ # selection) independently from content (string payloads, raw bytes),
16
+ # improving mutation quality.
17
+ #
18
+ # @example Basic usage in a fuzz target
19
+ # test_one_input = lambda do |data|
20
+ # fdp = Ruzzy::FuzzedDataProvider.new(data)
21
+ # name = fdp.consume_random_length_string(50)
22
+ # age = fdp.consume_int_in_range(0, 150)
23
+ # score = fdp.consume_float_in_range(0.0, 100.0)
24
+ # role = fdp.pick_value_in_list(['admin', 'user', 'guest'])
25
+ # User.new(name: name, age: age, score: score, role: role).validate!
26
+ # end
27
+ # Ruzzy.fuzz(test_one_input)
28
+ class FuzzedDataProvider
29
+ def initialize(data)
30
+ @data = data
31
+ # Front cursor for strings/bytes (advances forward)
32
+ @front = 0
33
+ # Back cursor for integers (advances backward)
34
+ @back = @data.bytesize
35
+ end
36
+
37
+ # Returns the number of unconsumed bytes remaining.
38
+ def remaining_bytes
39
+ @back - @front
40
+ end
41
+
42
+ # --- Byte and String methods (consume from front) ---
43
+
44
+ # Consume up to +count+ raw bytes from the front of the buffer.
45
+ # Returns a binary-encoded String.
46
+ def consume_bytes(count)
47
+ count = clamp_count(count)
48
+ result = @data.byteslice(@front, count)
49
+ @front += count
50
+ result.force_encoding(Encoding::BINARY)
51
+ end
52
+
53
+ # Consume a variable-length string from the front of the buffer.
54
+ # The string terminates when a backslash followed by a non-backslash
55
+ # byte is encountered, or when +max_length+ characters are consumed.
56
+ # This encoding lets the fuzzer easily control string length through
57
+ # single-byte mutations.
58
+ #
59
+ # Matches libFuzzer's ConsumeRandomLengthString.
60
+ def consume_random_length_string(max_length = remaining_bytes)
61
+ result = +''
62
+ max_length.times do
63
+ break if remaining_bytes.zero?
64
+
65
+ byte = consume_front_byte
66
+ char = byte.chr(Encoding::BINARY)
67
+
68
+ if char == '\\' && remaining_bytes.positive?
69
+ next_byte = consume_front_byte
70
+ next_char = next_byte.chr(Encoding::BINARY)
71
+ break if next_char != '\\'
72
+
73
+ result << '\\'
74
+ else
75
+ result << char
76
+ end
77
+ end
78
+ result
79
+ end
80
+
81
+ # Consume all remaining bytes. Returns a binary-encoded String.
82
+ def consume_remaining_bytes
83
+ consume_bytes(remaining_bytes)
84
+ end
85
+
86
+ # Consume all remaining bytes as a String.
87
+ def consume_remaining_as_string
88
+ consume_remaining_bytes
89
+ end
90
+
91
+ # --- Integer methods (consume from end) ---
92
+
93
+ # Consume an unsigned integer from the end of the buffer.
94
+ # Reads up to +count+ bytes in little-endian order from the back.
95
+ # Returns 0 when no data remains.
96
+ def consume_uint(count)
97
+ return 0 if count <= 0 || remaining_bytes.zero?
98
+
99
+ actual = [count, remaining_bytes].min
100
+ result = 0
101
+ actual.times do |i|
102
+ @back -= 1
103
+ result |= @data.getbyte(@back) << (i * 8)
104
+ end
105
+ result
106
+ end
107
+
108
+ # Consume a signed integer from the end of the buffer.
109
+ # Reads +count+ bytes and interprets as two's complement.
110
+ # Returns 0 when no data remains.
111
+ def consume_int(count)
112
+ unsigned = consume_uint(count)
113
+ return 0 if count.zero?
114
+
115
+ bits = count * 8
116
+ max_unsigned = 1 << bits
117
+ half = max_unsigned >> 1
118
+
119
+ unsigned >= half ? unsigned - max_unsigned : unsigned
120
+ end
121
+
122
+ # Consume an integer in [min, max] from the end of the buffer.
123
+ # Returns +min+ when no data remains.
124
+ # Raises ArgumentError if min > max.
125
+ #
126
+ # Matches libFuzzer's ConsumeIntegralInRange: consumes only as many
127
+ # bytes from the end as needed to cover the range.
128
+ def consume_int_in_range(min, max)
129
+ raise ArgumentError, "min (#{min}) must be <= max (#{max})" if min > max
130
+
131
+ range = max - min
132
+ return min if range.zero?
133
+
134
+ # Consume bytes from the end, one at a time, until we've covered the range.
135
+ # This matches libFuzzer: only consume bytes while (range >> offset) > 0.
136
+ result = 0
137
+ offset = 0
138
+ while offset < 64 && (range >> offset).positive? && remaining_bytes.positive?
139
+ @back -= 1
140
+ result = (result << 8) | @data.getbyte(@back)
141
+ offset += 8
142
+ end
143
+
144
+ if range == (1 << offset) - 1
145
+ # range+1 is a power of 2, modulo is identity
146
+ min + result
147
+ else
148
+ min + (result % (range + 1))
149
+ end
150
+ end
151
+
152
+ # Consume a boolean from the end of the buffer.
153
+ # Returns false when no data remains.
154
+ def consume_bool
155
+ (consume_uint(1) & 1) == 1
156
+ end
157
+
158
+ # --- Float methods (consume from end) ---
159
+
160
+ # Consume a Float in [0.0, 1.0] from the end of the buffer.
161
+ # Returns 0.0 when no data remains.
162
+ def consume_probability
163
+ raw = consume_uint(8)
164
+ raw.to_f / 18_446_744_073_709_551_615.0 # 2^64 - 1
165
+ end
166
+
167
+ # Consume a Float in [min, max] from the end of the buffer.
168
+ # Returns +min+ when no data remains.
169
+ # Raises ArgumentError if min > max.
170
+ def consume_float_in_range(min, max)
171
+ raise ArgumentError, 'min must be <= max' if min > max
172
+ return min if min == max
173
+
174
+ range = max - min
175
+ if range.infinite?
176
+ # Overflow: split the range and recurse
177
+ mid = min / 2.0 + max / 2.0
178
+ if consume_bool
179
+ consume_float_in_range(mid, max)
180
+ else
181
+ consume_float_in_range(min, mid)
182
+ end
183
+ else
184
+ min + range * consume_probability
185
+ end
186
+ end
187
+
188
+ # Consume a Float spanning the full double range.
189
+ # Matches libFuzzer's ConsumeFloatingPoint.
190
+ def consume_float
191
+ consume_float_in_range(-Float::MAX, Float::MAX)
192
+ end
193
+
194
+ # --- Selection methods ---
195
+
196
+ # Return a random element from +list+, consuming bytes from the end.
197
+ # Raises ArgumentError if the list is empty.
198
+ def pick_value_in_list(list)
199
+ raise ArgumentError, 'list must not be empty' if list.empty?
200
+
201
+ list[consume_int_in_range(0, list.length - 1)]
202
+ end
203
+
204
+ private
205
+
206
+ # Consume a single byte from the front.
207
+ def consume_front_byte
208
+ return 0 if remaining_bytes.zero?
209
+
210
+ byte = @data.getbyte(@front)
211
+ @front += 1
212
+ byte
213
+ end
214
+
215
+ def clamp_count(count)
216
+ count = 0 if count.negative?
217
+ count = remaining_bytes if count > remaining_bytes
218
+ count
219
+ end
220
+ end
221
+ end
data/lib/ruzzy.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pathname'
4
+ require 'ruzzy/fuzzed_data_provider'
4
5
 
5
- # A Ruby C extension fuzzer
6
+ # A coverage-guided fuzzer for pure Ruby code and Ruby C extensions
6
7
  module Ruzzy
7
8
  require 'cruzzy/cruzzy'
8
9
 
@@ -15,10 +16,6 @@ module Ruzzy
15
16
  c_fuzz(test_one_input, args)
16
17
  end
17
18
 
18
- def dummy
19
- fuzz(->(data) { Ruzzy.dummy_test_one_input(data) })
20
- end
21
-
22
19
  def dummy_test_one_input(data)
23
20
  # This 'require' depends on LD_PRELOAD, so it's placed inside the function
24
21
  # scope. This allows us to access EXT_PATH for LD_PRELOAD and not have a
@@ -28,9 +25,33 @@ module Ruzzy
28
25
  c_dummy_test_one_input(data)
29
26
  end
30
27
 
28
+ def dummy
29
+ # Load the instrumented shared object before calling fuzz so its coverage
30
+ # maps are registered before LLVMFuzzerRunDriver starts. Some fuzzer
31
+ # runtimes (e.g. LibAFL) require coverage maps to exist upfront.
32
+ require 'dummy/dummy'
33
+
34
+ fuzz(->(data) { dummy_test_one_input(data) })
35
+ end
36
+
37
+ def trace(harness_script)
38
+ harness_path = Pathname.new(harness_script)
39
+
40
+ # Mimic require_relative. If harness script is provided as an absolute path,
41
+ # then use that. If not, then assume the script is in the same directory as
42
+ # as the tracer script, i.e. the caller.
43
+ if !harness_path.absolute?
44
+ caller_path = Pathname.new(caller_locations.first.path)
45
+ harness_path = (caller_path.parent / harness_path).realpath
46
+ end
47
+
48
+ c_trace(harness_path.to_s)
49
+ end
50
+
31
51
  module_function :fuzz
32
- module_function :dummy
33
52
  module_function :dummy_test_one_input
53
+ module_function :dummy
54
+ module_function :trace
34
55
  end
35
56
 
36
57
  # Hook Integer operations for tracing in SantizerCoverage
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruzzy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Trail of Bits
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-02-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rake
@@ -66,7 +65,6 @@ dependencies:
66
65
  - - "~>"
67
66
  - !ruby/object:Gem::Version
68
67
  version: '1.60'
69
- description:
70
68
  email: support@trailofbits.com
71
69
  executables: []
72
70
  extensions:
@@ -79,11 +77,11 @@ files:
79
77
  - ext/dummy/dummy.c
80
78
  - ext/dummy/extconf.rb
81
79
  - lib/ruzzy.rb
80
+ - lib/ruzzy/fuzzed_data_provider.rb
82
81
  homepage: https://rubygems.org/gems/ruzzy
83
82
  licenses:
84
83
  - AGPL-3.0-only
85
84
  metadata: {}
86
- post_install_message:
87
85
  rdoc_options: []
88
86
  require_paths:
89
87
  - lib
@@ -98,8 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
96
  - !ruby/object:Gem::Version
99
97
  version: '0'
100
98
  requirements: []
101
- rubygems_version: 3.5.3
102
- signing_key:
99
+ rubygems_version: 4.0.6
103
100
  specification_version: 4
104
- summary: A Ruby C extension fuzzer
101
+ summary: A coverage-guided fuzzer for pure Ruby code and Ruby C extensions
105
102
  test_files: []