{"id":"GHSA-wv3x-4vxv-whpp","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 \u003c\u003c READER_BITS\nRUNNING_WRITER = 1 \u003c\u003c (READER_BITS + WRITER_BITS)\nMAX_READERS    = WAITING_WRITER - 1\nMAX_WRITERS    = RUNNING_WRITER - MAX_READERS - 1\n\nWRITE_LOCK_HELD = 1 \u003c\u003c 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) \u003e 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) \u003e= 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) \u003e= 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 \u003c\u003c :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 \u003c\u003c :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 \u003c\u003c :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](\u003chttp://depthfirst.com\u003e))","aliases":["CVE-2026-54905"],"modified":"2026-07-20T13:45:25.193701994Z","published":"2026-06-19T20:47:38Z","database_specific":{"github_reviewed_at":"2026-06-19T20:47:38Z","nvd_published_at":"2026-06-24T17:17:29Z","cwe_ids":["CWE-128"],"severity":"LOW","github_reviewed":true},"references":[{"type":"WEB","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"}],"affected":[{"package":{"name":"concurrent-ruby","ecosystem":"RubyGems","purl":"pkg:gem/concurrent-ruby"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.3.7"}]}],"versions":["0.0.1","0.1.0","0.1.1","0.1.1.pre.1","0.1.1.pre.2","0.1.1.pre.3","0.1.1.pre.4","0.1.1.pre.5","0.2.0","0.2.1","0.2.2","0.3.0","0.3.0.pre.1","0.3.0.pre.2","0.3.0.pre.3","0.3.1","0.3.1.pre.1","0.3.1.pre.2","0.3.2","0.4.0","0.4.1","0.5.0","0.5.0.pre.1","0.6.0","0.6.0.pre.1","0.6.0.pre.2","0.6.1","0.7.0","0.7.0.rc0","0.7.0.rc1","0.7.0.rc2","0.7.1","0.7.2","0.8.0","0.8.0.pre1","0.8.0.pre2","0.9.0","0.9.0.pre2","0.9.0.pre3","0.9.1","0.9.2","1.0.0","1.0.0.pre1","1.0.0.pre2","1.0.0.pre3","1.0.0.pre4","1.0.0.pre5","1.0.1","1.0.2","1.0.3","1.0.3.pre3","1.0.4","1.0.5","1.1.0.pre1","1.1.0.pre2","1.1.1","1.1.10","1.1.2","1.1.3","1.1.4","1.1.5","1.1.6","1.1.6.pre1","1.1.7","1.1.8","1.1.9","1.2.0","1.2.1","1.2.2","1.2.3","1.3.1","1.3.1.pre","1.3.2","1.3.3","1.3.4","1.3.5","1.3.6"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-wv3x-4vxv-whpp/GHSA-wv3x-4vxv-whpp.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N"},{"type":"CVSS_V4","score":"CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N"}]}