Skip to content

Cross-buffer use-after-free in the vendored msgpack buffer pool (missing rmem_owner transfer) #35

Description

@jeremy

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions