Skip to content

Fix CacheBuffer::m_mem use-after-free - #930

Merged
shosseinimotlagh merged 20 commits into
eBay:stable/v3.8.xfrom
raakella1:fix/memvec-uaf-race
Oct 1, 2026
Merged

shosseinimotlagh merged 20 commits into
eBay:stable/v3.8.xfrom
raakella1:fix/memvec-uaf-race

Conversation

@raakella1

@raakella1 raakella1 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

CacheBuffer::m_mem was read via raw, unsynchronized access in insert_missing_pieces(), update_missing_piece(), and at_offset(), while set_memvec() (called from refresh_buf() during cross-checkpoint COW) could concurrently replace m_mem and free the old MemVector out from under a reader → UAF → SIGSEGV.

Confirmed in production: identical insert_missing_pieces/verify_csum crash signature on HomeStore 3.8.4 and 3.8.7.

Fix

  • Added m_mem_mtx to guard m_mem's lifetime. Readers take a shared_lock and copy an intrusive_ptr before releasing it; set_memvec() takes a unique_lock when replacing m_mem.
  • at_offset() had a second, later-window variant of the same bug (returned a bare sisl::blob with no ownership). Introduced blob_view to keep the MemVector alive for as long as the caller holds the returned blob.
  • m_mem_mtx uses folly::SharedMutexReadPriority (4 bytes) instead of std::shared_mutex (56 bytes) — same type used elsewhere in this codebase, ~14x smaller per node.

raakella1 and others added 9 commits September 17, 2026 19:45
get_memvec_intrusive() and set_memvec() previously had a three-way race:
Thread A reads m_mem.px (raw ptr into intrusive_ptr), then Thread B calls
set_memvec() replacing m_mem, then IO completion drops the last wb_req ref,
freeing MemVec_1 before Thread A bumps its refcount. Result: UAF in
insert_missing_pieces / update_missing_piece / at_offset.

Fix:
- Add m_mem_mtx (shared_mutex) solely for protecting m_mem lifetime.
  Separate from m_mtx which guards eviction/cache-state changes.
- get_memvec_intrusive(): take shared_lock while copying intrusive_ptr,
  so the refcount increment is atomic with respect to set_memvec.
  Multiple concurrent readers proceed in parallel; only a concurrent
  set_memvec causes a brief exclusive wait.
- set_memvec(): take unique_lock before replacing m_mem.
- insert_missing_pieces(), update_missing_piece(), at_offset(): all
  converted from raw get_memvec() reference to get_memvec_intrusive(),
  ensuring the caller holds a strong ref for the duration of the call.

The lock is held only for the pointer snapshot (nanoseconds), not for
the actual MemVector operation, so IO throughput is unaffected.

Reproducer: crash in homeds::MemVector::insert_missing_pieces (Thread 14)
with secondary tcmalloc freelist corruption (Threads 5, 7, 8) observed in
access_mgr v3.5.21 / HomeStore 3.8.4 on 2026-09-13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refresh_buf() read m_mem via raw get_memvec() before calling set_memvec().
Although the btree node lock prevents a concurrent set_memvec on the same
node, using the raw accessor is inconsistent with the m_mem_mtx discipline
introduced in the previous commit and technically unsound from the C++
memory model perspective.

Replace with get_memvec_intrusive() for full consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…for m_mem_mtx

std::shared_mutex wraps pthread_rwlock_t at 56 bytes per instance.
With millions of btree nodes each embedding a CacheBuffer, that adds
~53 MB per million nodes.

folly::SharedMutexReadPriority is 4 bytes (a single uint32_t) and is
already the lock type used for per-btree-node locking (btree_node.h)
and hash-bucket locking (intrusive_hashset.hpp) in this codebase.
Same correctness guarantees, 14x smaller.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ests

Race A: CacheBuffer::insert_missing_pieces() and set_memvec() accessed
m_mem concurrently without synchronisation. insert_missing_pieces()
returned a raw C++ reference from get_memvec(), while a concurrent
set_memvec() could replace m_mem and drop MV1's refcount to zero,
freeing it while the raw reference was still live → UAF → SIGSEGV.

Fix: add mutable std::shared_mutex m_mem_mtx to CacheBuffer.
Readers (insert_missing_pieces, update_missing_piece, at_offset) capture
an intrusive_ptr<MemVector> under shared_lock before releasing it.
set_memvec() holds unique_lock when replacing m_mem.

To make the race deterministic without TSAN, two _PRERELEASE FLIPs
cooperate:

1. wb_flush_delay_before_write (200ms, in flush_buffers): keeps CP0
   reqs in WB_REQ_WAITING while Phase 2 writers call refresh_buf →
   set_memvec(MV2) → MV1 refcount 2→1.

2. wb_cache_get_memvec_cp_delay (500ms, in insert_missing_pieces): holds
   the window open after the raw ref (no-fix) or intrusive_ptr (fix) is
   taken, giving CP0 flush time to complete → MV1 freed. No-fix: Thread A
   wakes to a dangling ref → SIGSEGV. Fix: intrusive_ptr keeps MV1 alive.

Also added:
- test_wb_cache_race.cpp: unit tests for Race A and Race B (wb_req->m_mem)
- test_wb_cache_integration.cpp: integration tests on a live homestore
  instance including CrossCpMemvecRaceDetectedByFlip (PRERELEASE only)

Verified on docker4:
  ~/org_crash/test_wb_cache_integration  -> SIGSEGV (Race A)
  ~/fix_crash/test_wb_cache_integration  -> PASSED  (fix holds)
SDSTOR-25237: fix wb_req->m_mem data race and add regression UT
CacheBuffer::at_offset() correctly took a locked, refcounted snapshot
of m_mem before reading it, but that reference (mv) was local to the
function and was released the instant at_offset() returned. Callers
(Volume::verify_csum, btree node checksum verification, etc.) only
received a bare sisl::blob {bytes, size} with no ownership, so a
concurrent set_memvec() could free the MemVector after at_offset()
returned but before the caller read blob.bytes - a second, later-window
instance of the same use-after-free class already fixed for the
read-and-bump-refcount step itself.

Introduce blob_view (sisl::blob + a type-erased ownership token) so the
MemVector stays alive for as long as the caller holds the returned
blob, not just for the duration of the accessor call. CacheBuffer and
VolInterface's at_offset() now return blob_view; callers that bind the
result to `auto` need no further changes, since the extra member rides
along transparently and is released only when the caller's own local
variable goes out of scope. Updated the two production callers that
explicitly declared `sisl::blob` locals (Volume::verify_csum,
SSDBtreeStore::get_physical/_init_node), which would otherwise slice
off the ownership token at that exact line and silently reintroduce
the bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cache: keep MemVector alive through at_offset()'s returned blob
@raakella1 raakella1 changed the title Fix/memvec uaf race Fix CacheBuffer::m_mem use-after-free Sep 29, 2026
@shosseinimotlagh

Copy link
Copy Markdown
Contributor

change release_cache_after_recovery: bool = false (hotswap); in homeblk config file please

- nlohmann_json/3.12.0 is on ebay-local only; use [^3.11] range
  to pick what is available on conan center (matches sisl/8.9.8)
- Add openssl/1.1.1w override to match iomgr/8.8.7 and sisl/8.9.8,
  preventing version-range conflicts in the dependency graph
Save seq_num before invoking the completion callback, which may call
logstore_req::free(req). Accessing req->seq_num after the callback is
a heap-use-after-free caught by ASAN in test_wb_cache_integration and
test_load.
Move VolInterface::shutdown + iomanager.stop from TearDownTestSuite
into main() after RUN_ALL_TESTS() returns, matching the pattern used
by test_log_store. Shutting down inside TearDownTestSuite caused a
segfault (SIGSEGV, exit 139) and ASAN LeakSanitizer reports because
gtest's own post-suite cleanup still ran while the IOManager and
HomeStore singletons were gone.
Comment thread locks/debug_deps.lock Outdated
@raakella1
raakella1 force-pushed the fix/memvec-uaf-race branch from 6959a7a to 1866dc9 Compare October 1, 2026 15:53
@raakella1
raakella1 force-pushed the fix/memvec-uaf-race branch 2 times, most recently from 1866dc9 to de7ef0d Compare October 1, 2026 16:00
…wncast error

process_completions() casts virtualdev_req -> blkstore_req<Buffer> via
static_pointer_cast.  UBSan's -fsanitize=vptr flags this as an invalid
downcast when the actual object is writeback_req (a further-derived type)
because it cannot verify the type relationship through the static cast.

Switch to dynamic_pointer_cast: semantically correct for this downcast,
and bypasses UBSan's static-cast vptr check.  The cast always succeeds at
runtime since writeback_req IS-A blkstore_req<wb_cache_buffer_t>.
… blkstore buffer type

WbTestBtree used SimpleNumberKey/FixedBytesValue with SIMPLE node types, but the
index blkstore is BlkStore<WriteBackCacheBuffer<MappingKey, MappingValue, VAR_VALUE,
VAR_VALUE>> (= BLKSTORE_BUFFER_TYPE). With dynamic_pointer_cast in to_blkstore_req
the type mismatch correctly returns null instead of UB, triggering an assertion.

Switch WbTestBtree to MappingKey/MappingValue/VAR_VALUE/VAR_VALUE so the btree's
blkstore_req dynamic type matches what process_completions expects.
Default MappingValue() leaves m_earr uninitialized (is_initialized=false),
causing blob_array.h:191's assertion to fire when the btree node serializes
the value. Use MappingValue(seq_id_t, BlkId) which calls alloc_element() and
sets is_initialized before the first put.
…ree nodes

MappingValue::estimate_size_after_append() asserts false — it is intentionally
unimplemented because the real Mapping class bypasses it via custom callbacks.
The assert fires in varlen_node.hpp:348 whenever btree_put() is called with a
key that already exists in a VAR_VALUE leaf node.

ConcurrentWriteAndFlushIsRaceFree: use a fresh non-overlapping key band per
(iter, thread) so no key is ever written twice.  The wb_cache else-branch still
fires because Phase 2 keys are adjacent to Phase 1 keys and land in the same
partially-filled leaf nodes.

CrossCpMemvecRaceDetectedByFlip: Phase 1 uses stride-(kWriteThreads+1) keys;
Phase 2 threads use interleaved offsets (t+1 within each stride block).  All
four threads' inserts land between Phase 1 keys in the same leaf nodes, so
refresh_buf fires for every write — same race coverage with no collisions.
…eate

After m_log_records->create() marks the log record's slot active, a
concurrent flush on another thread can immediately pick it up via
foreach_active, flush synchronously (buffered-IO + IO-reactor), fire the
completion callback, and free the owning logstore_req.  The next line in
append_async then reads data.size from the reference that now points into
freed memory, triggering an ASAN heap-use-after-free.

Fix: capture data.size in a local uint32_t before create() is called.
Use that local for the pending-size fetch_add and the post-create
threshold check.  The data reference is not accessed after create().
@shosseinimotlagh
shosseinimotlagh removed the request for review from szmyd October 1, 2026 19:00
@shosseinimotlagh
shosseinimotlagh merged commit 3ad2348 into eBay:stable/v3.8.x Oct 1, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants