Guard against size_t overflow in ByteBuffer growth arithmetic on 32-bit builds

XMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Minor - P4
    • None
    • Affects Version/s: None
    • Component/s: BSON
    • None
    • Ruby Drivers
    • None
    • None
    • None
    • None
    • None
    • None

      Description

      rb_bson_expand_buffer computes its new allocation size with unchecked
      size_t arithmetic. On 32-bit builds, where size_t is 4 bytes, the
      doubling at ext/bson/bytebuf.c:58 wraps modulo 2^32:

      const size_t required_size = buffer_ptr->write_position - buffer_ptr->read_position + length;  /* :51 */
      ...
      const size_t new_size = required_size * 2;   /* :58 -- wraps when required_size > 2^31 */
      new_b_ptr = ALLOC_N(char, new_size);         /* :59 -- undersized block */
      memcpy(new_b_ptr, READ_PTR(buffer_ptr), READ_SIZE(buffer_ptr));  /* :60 -- overflow */
      buffer_ptr->size = new_size;                 /* :65 -- wrapped value recorded as capacity */
      

      ALLOC_N(char, new_size) does not catch this: sizeof(char) is 1, so
      ruby_xmalloc2's multiplication-overflow check never fires. The wrapped value
      is then stored as the buffer's capacity at :65, so every subsequent
      ENSURE_BSON_WRITE trusts it. ENSURE_BSON_WRITE
      (ext/bson/bson-native.h:65) has the same defect in its own
      write_position + length comparison.

      Reported as SECBUG-4387 (Aegis finding 41451a3919ea).

      Reproduction

      Confirmed with a real SIGSEGV on 32-bit Ruby 3.1.2 (i386-linux-gnu,
      size_t=4) against an unmodified bson-ruby 5.2.0 native extension:

      MiB = 2**20
      # 1. Seed 300 MiB -> capacity 600 MiB.
      b = BSON::ByteBuffer.new("\xAA".b * (300 * MiB))
      # 2. Fill to exactly capacity. write_position + len == size is not > size,
      #    so no reallocation: the buffer stays 600 MiB rather than doubling.
      chunk = "\xBB".b * (50 * MiB)
      6.times { b.put_bytes(chunk) }
      # 3. One 1500 MiB put_bytes. required_size = 2100 MiB > 2^31.
      b.put_bytes("\xCC".b * (1500 * MiB))
      
      READ_SIZE = 629145600 (600 MiB), capacity = 600 MiB
      required_size = 2202009600 (0x83400000) > 2^31 = true
      new_size      = 109051904 (0x06800000) = 104 MiB   <-- 32-bit wrap
      bytebuf.c:60 memcpy: 600 MiB -> 104 MiB block (overflow 496 MiB)
      
      [BUG] Segmentation fault at 0x49d9c000
      ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [i386-linux-gnu]
      c:0003 p:---- s:0017 e:000016 CFUNC  :put_bytes
      EBP: 0x06800000     <-- the wrapped new_size
      ruby exit=139 (SIGSEGV)
      

      Peak live footprint is about 2.2 GiB, which fits within a real 32-bit Linux
      3 GiB user address space, so this is not an artifact of the emulated
      environment used to reproduce it.

      Preconditions

      The wrap requires a single put_bytes of length L into a buffer already
      holding READ_SIZE bytes, such that READ_SIZE + L > 2^31. Because a
      32-bit Ruby String is capped at 2^31-1 bytes, and incremental growth exhausts
      the address space at roughly 0.88 GiB, this means both READ_SIZE and
      L must exceed about 548 MiB. A single large write into a fresh buffer
      cannot wrap: new_size = 2L stays below 2^32 and the allocation simply
      fails with a clean NoMemoryError.

      Impact assessment

      • 64-bit builds are unaffected; required_size cannot approach 2^64.
      • The result is an immediate, uncatchable SIGSEGV rather than a catchable
        NoMemoryError. That availability difference is the real defect: the
        host process dies unrecoverably.
      • This is not a usable write primitive. The overflow amount is
        2^32 - READ_SIZE - 2*length; reducing it to a survivable few kilobytes
        requires about 3.5 GiB live, which is out of reach in a 32-bit address
        space. Every achievable configuration overflows by hundreds of MiB and
        faults immediately. The SECBUG description's claim that the overflow is
        attacker-tunable and that execution continues past it does not hold.
      • No 32-bit platform is currently built or tested in CI.

      Exposure via mongo-ruby-driver

      Not reachable through the driver against a trustworthy server. The driver
      breaks the conjunction above:

      • Mongo::Protocol::Msg#validate_document_size! (msg.rb:301) only
        inspects section[:type] == 1, so the payload 0 main command document is
        never size-checked. However @sections (msg.rb:81-84) places payload 0
        first, so that unvalidated document is written into an empty buffer, where no
        wrap is possible. Verified on 32-bit: a 1200 MiB and a 1400 MiB payload 0
        document each produce a clean NoMemoryError, not a crash.
      • Payload 1 documents are appended later, when the buffer may be large, but
        each is bounded by max_bson_size (16 MiB by default), so L stays
        small.
      • The inbound path checks length > max_message_size (default
        MAX_MESSAGE_SIZE = 50_331_648) before reading, and builds a fresh
        BSON::ByteBuffer per message, so nothing accumulates.

      The residual path requires a malicious or compromised server: the driver reads
      maxBsonObjectSize and maxMessageSizeBytes from the hello response
      without clamping (server/description.rb:407,419). A server advertising
      maxBsonObjectSize of roughly 700 MiB would lift the payload 1 per-document
      cap and restore the preconditions. This requires a 32-bit deployment, a server
      the attacker controls or can MITM, and an application pushing about 2.2 GiB
      through a single bulk write.

      Proposed fix

      1. In rb_bson_expand_buffer, check required_size for overflow and
        verify that required_size * 2 does not wrap before calling ALLOC_N.
        Raise NoMemoryError instead of allocating an undersized block.
      2. Rewrite ENSURE_BSON_WRITE to compare as
        length > buffer_ptr->size - buffer_ptr->write_position so that no
        addition can wrap.
      3. Add a length guard to rb_bson_byte_buffer_put_bytes
        (ext/bson/write.c:120-137), which currently takes RSTRING_LEN with no
        validation, unlike the string paths capped by pvt_check_string_length.

      The pure-Ruby fallback is unaffected because Ruby integers are arbitrary
      precision. The JRuby extension in src/ should be reviewed separately, since
      Java int arithmetic has a comparable bound.

      Testing notes

      Add a regression spec alongside the existing
      spec/bson/string_length_overflow_spec.rb from RUBY-3894, gated behind
      STRESS=1 and skipped unless size_t is 4 bytes, since each example needs
      roughly 2.2 GiB of address space and only fails on 32-bit builds.

      Relationship to RUBY-3894

      Adjacent but distinct. RUBY-3894 fixed signed narrowing in the string write
      paths in ext/bson/write.c (RSTRING_LEN stored in int32_t). This is
      unsigned multiplication wrap in the buffer-growth arithmetic in
      ext/bson/bytebuf.c, which that patch did not touch. bytebuf.c and
      bson-native.h contain no overflow guards at all: SIZE_MAX appears
      nowhere in the extension.

            Assignee:
            Unassigned
            Reporter:
            Jamis Buck
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: