Zstd.write_skippable_frame(input, skippable) triggers a heap-buffer-overflow (out-of-bounds read) detected by AddressSanitizer. In skippable_frame.c the output buffer is initialized by copying dst_size bytes out of input_data, but input_data is only input_size bytes long, and dst_size = input_size + ZSTD_SKIPPABLEHEADERSIZE + skip_size > input_size.
Affected code
ext/zstdruby/skippable_frame.c:26
size_t dst_size = input_size + ZSTD_SKIPPABLEHEADERSIZE + skip_size;
VALUE output = rb_str_new(input_data, dst_size); // reads dst_size bytes from an input_size-byte buffer
Environment
- OS: Linux 7.1.1-2
- Ruby: ruby 4.0.5
- Compiler: gcc 16.1.1
Reproduction
Build the extension with ASan and run under LD_PRELOAD'd libasan (Ruby itself is not ASan-instrumented, and extconf.rb assigns $CFLAGS, so append the flags inside extconf.rb before create_makefile):
diff --git a/ext/zstdruby/extconf.rb b/ext/zstdruby/extconf.rb
index f963367..bfe8fa6 100644
--- a/ext/zstdruby/extconf.rb
+++ b/ext/zstdruby/extconf.rb
@@ -54,4 +54,7 @@ def ext_export_filename
# add folder, where compiler can search source files
$VPATH << "$(srcdir)"
+$CFLAGS << ' -fsanitize=address -fno-omit-frame-pointer -g -O0'
+$LDFLAGS << ' -fsanitize=address'
+
create_makefile("zstd-ruby/zstdruby")
# repro.rb
require 'zstd-ruby'
payload = "A" * 1024
Zstd.write_skippable_frame(payload, "sample data")
puts "reached end without ASan error"
$ rake install:local
$ LD_PRELOAD=$(gcc -print-file-name=libasan.so) ASAN_OPTIONS=detect_leaks=0 ruby repro.rb
=================================================================
==17416==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7cec25634581 at pc 0x7f5c2755d772 bp 0x7ffc0b087190 sp 0x7ffc0b086938
READ of size 1043 at 0x7cec25634581 thread T0
#0 0x7f5c2755d771 in memcpy (/usr/lib/gcc/x86_64-pc-linux-gnu/16/../../../../lib/libasan.so+0x15d771) (BuildId: b3c854c755b60b866f8e124222a7a865d450079f)
#1 0x7f5c26d0253e in memcpy /usr/include/bits/string_fortified.h:29
#2 0x7f5c26d0253e in ruby_nonempty_memcpy include/ruby/internal/memory.h:759
#3 0x7f5c26d0253e in ruby_nonempty_memcpy include/ruby/internal/memory.h:756
#4 0x7f5c26d0253e in str_enc_new /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/string.c:1089
#5 0x7b5c06ec26ed in rb_write_skippable_frame /home/watson/.rbenv/versions/4.0.5/lib/ruby/gems/4.0.0/gems/zstd-ruby-2.0.6/ext/zstdruby/skippable_frame.c:26
#6 0x7f5c26d8c50b in vm_call_cfunc_with_frame_ /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/vm_insnhelper.c:3901
#7 0x7f5c26da465c in vm_sendish /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/vm_insnhelper.c:6123
#8 0x7f5c26da465c in vm_exec_core /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/insns.def:904
#9 0x7f5c26daa8e9 in vm_exec_loop /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/vm.c:2825
#10 0x7f5c26daa8e9 in rb_vm_exec /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/vm.c:2804
#11 0x7f5c26b68b58 in rb_ec_exec_node /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/eval.c:283
#12 0x7f5c26b6c8da in ruby_run_node /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/eval.c:321
#13 0x56206a66d0ff in rb_main main.c:42
#14 0x56206a66d0ff in main main.c:62
#15 0x7f5c26627c8d (/usr/lib/libc.so.6+0x27c8d) (BuildId: e40d0fb39fa49848e0042984207f7c80fdbc8c7e)
#16 0x7f5c26627dca in __libc_start_main (/usr/lib/libc.so.6+0x27dca) (BuildId: e40d0fb39fa49848e0042984207f7c80fdbc8c7e)
#17 0x56206a66d144 in _start (/home/watson/.rbenv/versions/4.0.5/bin/ruby+0x1144) (BuildId: 9c4c4eb747648adb3b4c85bd02313989f516f550)
0x7cec25634581 is located 0 bytes after 1025-byte region [0x7cec25634180,0x7cec25634581)
allocated by thread T0 here:
#0 0x7f5c27560181 in malloc (/usr/lib/gcc/x86_64-pc-linux-gnu/16/../../../../lib/libasan.so+0x160181) (BuildId: b3c854c755b60b866f8e124222a7a865d450079f)
#1 0x7f5c26b957f2 in rb_gc_impl_malloc gc/default/default.c:8294
#2 0x7f5c26b957f2 in ruby_xmalloc_body /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/gc.c:5144
#3 0x7f5c26b957f2 in ruby_xmalloc /tmp/ruby-build.20260520095325.246649.XiGeNt/ruby-4.0.5/gc.c:5126
SUMMARY: AddressSanitizer: heap-buffer-overflow /usr/include/bits/string_fortified.h:29 in memcpy
Shadow bytes around the buggy address:
0x7cec25634300: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634380: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634400: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634480: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634500: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x7cec25634580:[01]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x7cec25634600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x7cec25634680: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634700: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634780: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7cec25634800: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==17416==ABORTING
Suggested fix
The buffer is fully overwritten by ZSTD_writeSkippableFrame afterward, so it does not need to be initialized from input_data:
- VALUE output = rb_str_new(input_data, dst_size);
+ VALUE output = rb_str_new(NULL, dst_size);
Zstd.write_skippable_frame(input, skippable)triggers a heap-buffer-overflow (out-of-bounds read) detected by AddressSanitizer. Inskippable_frame.cthe output buffer is initialized by copyingdst_sizebytes out ofinput_data, butinput_datais onlyinput_sizebytes long, anddst_size = input_size + ZSTD_SKIPPABLEHEADERSIZE + skip_size > input_size.Affected code
ext/zstdruby/skippable_frame.c:26Environment
Reproduction
Build the extension with ASan and run under LD_PRELOAD'd libasan (Ruby itself is not ASan-instrumented, and extconf.rb assigns
$CFLAGS, so append the flags inside extconf.rb beforecreate_makefile):Suggested fix
The buffer is fully overwritten by
ZSTD_writeSkippableFrameafterward, so it does not need to be initialized frominput_data: