{"id":"CVE-2026-54905","aliases":["GHSA-wv3x-4vxv-whpp"],"url":"https://o3.security/vulnerability/CVE-2026-54905","summary":"concurrent-ruby:  `ReentrantReadWriteLock` read-count overflow grants a write lock without exclusivity","details":"### Summary\n`Concurrent::ReentrantReadWriteLock` can incorrectly grant a write lock after one thread acquires the read lock 32,768 times.\n\nThe lock stores a thread's local read and write hold counts in one integer. The low 15 bits are used for the read hold count, and bit 15 is used as `WRITE_LOCK_HELD`. After 32,768 reentrant read acquisitions, the local read count crosses into the write-lock bit. `try_write_lock` then treats the thread as already holding a write lock and returns `true` without setting the global `RUNNING_WRITER` bit.\n\nThis breaks the core mutual-exclusion guarantee: the caller is told it has a write lock, but other threads can still hold or acquire read locks at the same time.\n\n### Version\nSoftware: concurrent-ruby\nVersion: 1.3.6\nCommit: 7a1b78941c081106c20a9ca0144ac73a48d254ab\n\n### Details\n\nThe implementation uses a shared counter to track global readers/writers and a per-thread local counter to support reentrancy:\n\n```ruby\nREADER_BITS    = 15\nWRITER_BITS    = 14\n\nWAITING_WRITER = 1 << READER_BITS\nRUNNING_WRITER = 1 << (READER_BITS + WRITER_BITS)\nMAX_READERS    = WAITING_WRITER - 1\nMAX_WRITERS    = RUNNING_WRITER - MAX_READERS - 1\n\nWRITE_LOCK_HELD = 1 << READER_BITS\nREAD_LOCK_MASK  = WRITE_LOCK_HELD - 1\nWRITE_LOCK_MASK = MAX_WRITERS\n```\n\nWhen a thread already holds a lock, `acquire_read_lock` increments `@HeldCount`:\n\n```ruby\nif (held = @HeldCount.value) > 0\n  if held & READ_LOCK_MASK == 0\n    @Counter.update { |c| c + 1 }\n  end\n  @HeldCount.value = held + 1\n  return true\nend\n```\n\nAfter 32,768 read acquisitions, the per-thread held count becomes `32768`, which is equal to `WRITE_LOCK_HELD`. Then `try_write_lock` returns success through its \"already have a write lock\" branch:\n\n```ruby\ndef try_write_lock\n  if (held = @HeldCount.value) >= WRITE_LOCK_HELD\n    @HeldCount.value = held + WRITE_LOCK_HELD\n    return true\n  else\n    # normal global writer acquisition path\n  end\nend\n```\n\nThis branch does not set the global `RUNNING_WRITER` bit. Other threads therefore do not observe an active writer and can continue holding or acquiring read locks while the caller believes it owns the write lock.\n\n### PoC\n\n```ruby\n#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire 'concurrent/atomic/reentrant_read_write_lock'\nrequire 'concurrent/version'\nrequire 'thread'\n\ndef wait_for_queue(queue, timeout_seconds)\n  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds\n  loop do\n    return queue.pop(true)\n  rescue ThreadError\n    return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline\n\n    sleep 0.001\n  end\nend\n\nputs \"ruby=#{RUBY_DESCRIPTION}\"\nputs \"concurrent_ruby_version=#{Concurrent::VERSION}\"\nputs \"poc=ReentrantReadWriteLock read-depth overflow grants write lock without exclusivity\"\n\nlock = Concurrent::ReentrantReadWriteLock.new\nother_reader_ready = Queue.new\nother_reader_stop = Queue.new\n\nother_reader = Thread.new do\n  lock.acquire_read_lock\n  other_reader_ready << :held\n  other_reader_stop.pop\nend\n\nwait_for_queue(other_reader_ready, 1)\nputs \"other_thread_holds_read_lock=true\"\n\ndepth = Concurrent::ReentrantReadWriteLock::WRITE_LOCK_HELD\ndepth.times { lock.acquire_read_lock }\n\nheld_count = lock.instance_eval { @HeldCount.value }\ncounter_before = lock.instance_eval { @Counter.value }\n\nputs \"main_thread_read_acquisitions=#{depth}\"\nputs \"main_thread_held_count=#{held_count}\"\nputs \"counter_before_try_write=#{counter_before}\"\nputs \"running_writer_bit_before=#{(counter_before & Concurrent::ReentrantReadWriteLock::RUNNING_WRITER) != 0}\"\n\nwrite_granted = lock.try_write_lock\ncounter_after = lock.instance_eval { @Counter.value }\n\nputs \"try_write_lock_returned=#{write_granted}\"\nputs \"counter_after_try_write=#{counter_after}\"\nputs \"running_writer_bit_after=#{(counter_after & Concurrent::ReentrantReadWriteLock::RUNNING_WRITER) != 0}\"\n\nthird_reader_ready = Queue.new\nthird_reader = Thread.new do\n  lock.acquire_read_lock\n  third_reader_ready << :acquired\nend\n\nthird_reader_acquired = wait_for_queue(third_reader_ready, 0.25) == :acquired\nputs \"new_reader_acquired_while_write_claimed=#{third_reader_acquired}\"\n\nif write_granted && third_reader_acquired && (counter_after & Concurrent::ReentrantReadWriteLock::RUNNING_WRITER).zero?\n  puts 'result=REPRODUCED write lock granted without setting global writer state'\nelse\n  puts 'result=NOT_REPRODUCED'\nend\n\nthird_reader.kill\nother_reader_stop << :stop\nother_reader.kill\n```\n\n### Log evidence\n```text\nruby=ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25]\nconcurrent_ruby_version=1.3.6\npoc=ReentrantReadWriteLock read-depth overflow grants write lock without exclusivity\nother_thread_holds_read_lock=true\nmain_thread_read_acquisitions=32768\nmain_thread_held_count=32768\ncounter_before_try_write=2\nrunning_writer_bit_before=false\ntry_write_lock_returned=true\ncounter_after_try_write=2\nrunning_writer_bit_after=false\nnew_reader_acquired_while_write_claimed=true\nresult=REPRODUCED write lock granted without setting global writer state\n```\n\n### Impact\nThis breaks the write-lock exclusivity guarantee. After the overflow, a thread can be told it has acquired the write lock while other threads can still hold or acquire read locks, allowing races and inconsistent reads of protected mutable state.\n\n### Credit\nPranjali Thakur - depthfirst ([depthfirst.com](<http://depthfirst.com>))","published":"2026-06-24T15:42:44.257Z","modified":"2026-10-28T02:30:54.770176538Z","cvss":null,"epss":{"score":0.00153,"percentile":0.04817,"asOf":"2026-09-17"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"RubyGems","name":"concurrent-ruby","fixedVersion":"1.3.7"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/54xxx/CVE-2026-54905.json"},{"type":"ADVISORY","url":"https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-wv3x-4vxv-whpp"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54905"},{"type":"PACKAGE","url":"https://github.com/ruby-concurrency/concurrent-ruby"},{"type":"WEB","url":"https://github.com/rubysec/ruby-advisory-db/blob/master/gems/concurrent-ruby/CVE-2026-54905.yml"},{"type":"WEB","url":"https://www.cve.org/CVERecord/SearchResults?query=CVE-2026-54905"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-28T02:30:54.770176538Z"}}