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
- 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. - Rewrite ENSURE_BSON_WRITE to compare as
length > buffer_ptr->size - buffer_ptr->write_position so that no
addition can wrap. - 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.