Skip to content

Use-after-free: PostReadCc dereferences a recycled NonBlockingLock when a read-intent is released before its post-read (under CleanDataForTest kickout) #508

Description

@liunyl

Summary

A pre-existing use-after-free in the cc-entry lock lifetime. Under a multi-node cluster with the data store + aggressive eviction (kickout_data_for_test=true, checkpointer_interval=1) plus concurrent scans/writes, a PostReadCc can dereference a NonBlockingLock that has already been recycled, aborting the node.

Crash signature

eloqkv: .../abseil-cpp/absl/container/internal/raw_hash_set.h:325:
probe_seq<16>::probe_seq(size_t, size_t): Assertion `((mask + 1) & mask) == 0 && "not a mask"' failed.   (SIGABRT)

Crashing thread (core + gdb):

#3  absl::container_internal::probe_seq<16>::probe_seq        (corrupt capacity)
#6  raw_hash_set<FlatHashMapPolicy<uint64,uint32>>::find_non_soo
#8  txservice::NonBlockingLock::ReleaseReadIntent             non_blocking_lock.cpp:503
#9  txservice::NonBlockingLock::ClearTx                       non_blocking_lock.cpp:562
#10 txservice::CcMap::ReleaseCceLock                          cc_map.cpp:412  (lk_type=NoLock, recycle_lock=true)
#11 TemplateCcMap<EloqKey,RedisEloqObject>::Execute           template_cc_map.h:1314
#12 PostReadCc::Execute                                       cc_request.h:262
#13 CcShard::ProcessRequests -> TxProcessor::RunOneRound      (is_ext_proc=true)

In the core, the NonBlockingLock's read_intentions_ (absl::flat_hash_map<TxNumber,uint32_t>) has garbage capacity_ / size_ / slot pointer — the lock object was recycled/clobbered while still referenced.

Root cause

PostReadCc::Execute (template_cc_map.h:1243) obtains the entry from the cached cce_addr (cce_addr.ExtractCce(), a KeyGapLockAndExtraData* captured at read time), then calls cc_entry.GetKeyLock()->ReleaseReadIntent(). If the cce's read-intent is released before that PostReadCc runs, the entry becomes IsFree() (cc_lock_and_extra_ == nullptr && IsPersistent()), the CleanDataForTest kickout (LocalCcShards::KickoutDataForTestKickoutCcEntryCc with CleanType::CleanDataForTest) recycles it, and the later PostReadCc dereferences the recycled lock → UAF.

What is already correct (so the gap is the early intent release)

  • Read-set insertion already guards lock_type != LockType::NoLock at every site (point read tx_execution.cpp:2206; scan sites tx_execution.cpp:3007 / 3148 / 3369 / 5886). ReadCommitted reads map to NoLock (LockTypeUtil::DeduceLockType) and are not tracked; scans AcquireReadIntent per tuple.
  • The kickout gate already skips locked entries: CleanDataForTestCcPageCleanGuardWithoutKickoutCc::CanBeCleaned = IsFree() && !GetBeingCkpt().

So the entry was IsFree() at kickout time, i.e. its read-intent had been released before the post-read. Suspected early-release sites (a read-intent refcount interaction): scan DrainScanner (PostReadType::Release, tx_execution.cpp ~3651), the transient last-cce pin DecrReadIntent (template_cc_map.h:3884 / ~3891), or a DedupRead edge (tx_execution.cpp:6370).

Dead code noticed nearby

The "record-and-don't-free a still-locked cce" defense is not wired up: CcPageCleanGuard::MarkClean(idx, delay_free) ignores its delay_free argument, and LruEntry::ClearLocks(ccs, ng, invalidate_owner_term) never references invalidate_owner_term (no caller passes true) — vestigial since #168 ("Restructure for eloqconverge"). For the genuine stale-ex-leader post-read, the only active guard is the term check (PostReadCc::CheckLeaderTerm + ReadWriteSet::ClearDataReadSet read-lock-term validation), which fires only on a term change (failover) and does not cover the same-term kickout case above.

Relation to PR #491 (bthread-mutex CC-request fix)

Not a regression from #491. Verified by A/B (built fix @1448c93 vs pre-fix @f62f0fa): both pass scan.tcl alone 15× and both crash identically under concurrent-writer stress with the same NonBlockingLock assertion. The CI failure on the eloqkv PR (eloqkv-pr-ent/compile-test build #285, config 7 = 3-node cluster + eloqstore + kickout_data_for_test, scan.tcl first flushdbI/O error reading reply) is an intermittent manifestation of this pre-existing race, not a new bug introduced by #491.

Reproduction (flaky / timing-sensitive)

  • Debug build, WITH_DATA_STORE=ELOQDSS_ELOQSTORE, EXT_TX_PROC_ENABLED=ON, WITH_LOG_SERVICE=ON, OPEN_LOG_SERVICE=OFF, FORK_HM_PROCESS=ON.
  • 3-node local cluster, shared eloqstore via minio, --core_number=2 --checkpointer_interval=1 --kickout_data_for_test=true --max_processing_time_microseconds=1000.
  • Concurrent writers (SET churn) + scan.tcl loop → a data node SIGABRTs intermittently (usually within 1–2 scan iterations under light load; heavier load tends to hang instead).
  • A deterministic repro / regression test is best done with CODE_FAULT_INJECTOR (force a kickout/recycle of a read-set cce right before its PostReadCc).

Proposed fix direction

Ensure a cce referenced by an in-flight PostReadCc (i.e. in a tx's data/scan read-set) retains its read-intent until the post-read releases it, so the kickout's IsFree() gate skips it. Audit the read-intent refcount accounting across the scan drain / transient last-cce pin / dedup paths. Optionally remove (or properly wire up) the dead delay_free / invalidate_owner_term machinery.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions