Summary
ext/cbor/ is a copy of msgpack's C buffer code from before msgpack 1.8.4. It is missing the fix
msgpack shipped in 1.8.4 for a cross-buffer use-after-free in the shared memory pool. As a result a
page can be returned to the process-global pool while a live buffer still reads from it, and a second
buffer that reuses that page overwrites the first buffer's bytes. The defect is present at the latest
release, 0.5.10.3.
This is filed as latent: I could not reach it through CBOR.decode or a single CBOR::Unpacker.
It is reachable through the low-level CBOR::Buffer write API. A reproducer is included below.
Mechanism
_msgpack_buffer_add_new_chunk (buffer.c:278) copies the tail into a fresh chunk with
*nc = b->tail (lines 288 and 312) but does not move b->rmem_owner, which still points at
&b->tail.mem while the pool page has moved into nc->mem. The reuse branch in
_msgpack_buffer_chunk_malloc then carves the new tail out of that page:
/* buffer.c:378-390, "reuse unused rmem" */
c->mem = *b->rmem_owner; /* the page nc now owns */
*b->rmem_owner = NULL;
b->rmem_owner = &c->mem;
When nc is later shifted off the head of the chunk list, _msgpack_buffer_shift_chunk →
_msgpack_buffer_chunk_destroy returns that page to the global s_rmem pool while the tail is still
reading and writing inside it. msgpack 1.8.4 fixed this by adding _msgpack_buffer_transfer_rmem_owner
at both *nc = b->tail sites and by resetting rmem_end/rmem_last/rmem_owner in
_msgpack_buffer_shift_chunk. cbor has neither. msgpack changelog 1.8.4: "Fix MessagePack::Buffer
to stop using already released memory page. This could have caused data to leak across buffers."
Reproducer
Deterministic. No GC stress, no compaction. CBOR::Buffer only.
require "cbor"
def choreo
a = CBOR::Buffer.new(nil, write_reference_threshold: 256)
((4096 / 200) + 4).times { a << ("y" * 200) } # copied writes fill/spill a pool page
a << ("R" * 320) # > threshold: reference path rolls rmem_last back
a << ("y" * 120) # reuse branch carves the tail from that page
rem = a.size
a.read(rem - 120) # drain head chunks; the page is freed to the pool...
b = CBOR::Buffer.new(nil, write_reference_threshold: 256)
((4096 / 200) + 8).times { b << ("Z" * 200) } # ...and buffer B reallocates it
b.read
a.read # buffer A's tail: 'y' if intact, 'Z' if reused
end
corrupt = 50.times.count { choreo.to_s.include?("Z") }
puts "corrupt #{corrupt}/50"
corrupt 50/50 on cbor 0.5.10.3, ruby 3.4.7, aarch64-linux. Two controls confirm the mechanism:
removing the second buffer, or raising the threshold so the long write is copied rather than
referenced, both give 0/50. The read returns another buffer's bytes — a positive identification,
not a crash.
Scope
Reachable through CBOR::Buffer when the write pattern mixes copied and referenced writes on one
buffer and the page is later reclaimed by another buffer. I could not reach it through CBOR.decode
(it forces write_reference_threshold = 0, so the whole input is a single reference chunk and nothing
is copied into the pool) or through a single CBOR::Unpacker over an IO. Filed as latent for that
reason; the reproducer is safe to share.
Suggested fix
Port msgpack 1.8.4's _msgpack_buffer_transfer_rmem_owner to both *nc = b->tail sites and reset
the rmem fields in _msgpack_buffer_shift_chunk, or re-sync ext/cbor/{buffer,rmem}.{c,h} with a
current msgpack.
Environment: cbor 0.5.10.3, ruby 3.4.7, aarch64-linux (glibc). Also reproduces at 0.5.10.1.
Summary
ext/cbor/is a copy of msgpack's C buffer code from before msgpack 1.8.4. It is missing the fixmsgpack shipped in 1.8.4 for a cross-buffer use-after-free in the shared memory pool. As a result a
page can be returned to the process-global pool while a live buffer still reads from it, and a second
buffer that reuses that page overwrites the first buffer's bytes. The defect is present at the latest
release, 0.5.10.3.
This is filed as latent: I could not reach it through
CBOR.decodeor a singleCBOR::Unpacker.It is reachable through the low-level
CBOR::Bufferwrite API. A reproducer is included below.Mechanism
_msgpack_buffer_add_new_chunk(buffer.c:278) copies the tail into a fresh chunk with*nc = b->tail(lines 288 and 312) but does not moveb->rmem_owner, which still points at&b->tail.memwhile the pool page has moved intonc->mem. The reuse branch in_msgpack_buffer_chunk_mallocthen carves the new tail out of that page:When
ncis later shifted off the head of the chunk list,_msgpack_buffer_shift_chunk→_msgpack_buffer_chunk_destroyreturns that page to the globals_rmempool while the tail is stillreading and writing inside it. msgpack 1.8.4 fixed this by adding
_msgpack_buffer_transfer_rmem_ownerat both
*nc = b->tailsites and by resettingrmem_end/rmem_last/rmem_ownerin_msgpack_buffer_shift_chunk. cbor has neither. msgpack changelog 1.8.4: "FixMessagePack::Bufferto stop using already released memory page. This could have caused data to leak across buffers."
Reproducer
Deterministic. No GC stress, no compaction.
CBOR::Bufferonly.corrupt 50/50on cbor 0.5.10.3, ruby 3.4.7, aarch64-linux. Two controls confirm the mechanism:removing the second buffer, or raising the threshold so the long write is copied rather than
referenced, both give
0/50. The read returns another buffer's bytes — a positive identification,not a crash.
Scope
Reachable through
CBOR::Bufferwhen the write pattern mixes copied and referenced writes on onebuffer and the page is later reclaimed by another buffer. I could not reach it through
CBOR.decode(it forces
write_reference_threshold = 0, so the whole input is a single reference chunk and nothingis copied into the pool) or through a single
CBOR::Unpackerover an IO. Filed as latent for thatreason; the reproducer is safe to share.
Suggested fix
Port msgpack 1.8.4's
_msgpack_buffer_transfer_rmem_ownerto both*nc = b->tailsites and resetthe rmem fields in
_msgpack_buffer_shift_chunk, or re-syncext/cbor/{buffer,rmem}.{c,h}with acurrent msgpack.
Environment: cbor 0.5.10.3, ruby 3.4.7, aarch64-linux (glibc). Also reproduces at 0.5.10.1.