PT-2026-51091 · Rubygems · Concurrent-Ruby
Published
2026-06-19
·
Updated
2026-06-19
·
CVE-2026-54905
CVSS v4.0
2.0
Low
| Vector | AV:L/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N |
Summary
Concurrent::ReentrantReadWriteLock can incorrectly grant a write lock after one thread acquires the read lock 32,768 times.The 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.This 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.
Version
Software: concurrent-ruby
Version: 1.3.6
Commit: 7a1b78941c081106c20a9ca0144ac73a48d254ab
Details
The implementation uses a shared counter to track global readers/writers and a per-thread local counter to support reentrancy:
ruby
READER BITS = 15
WRITER BITS = 14
WAITING WRITER = 1 << READER BITS
RUNNING WRITER = 1 << (READER BITS + WRITER BITS)
MAX READERS = WAITING WRITER - 1
MAX WRITERS = RUNNING WRITER - MAX READERS - 1
WRITE LOCK HELD = 1 << READER BITS
READ LOCK MASK = WRITE LOCK HELD - 1
WRITE LOCK MASK = MAX WRITERSWhen a thread already holds a lock,
acquire read lock increments @HeldCount:ruby
if (held = @HeldCount.value) > 0
if held & READ LOCK MASK == 0
@Counter.update { |c| c + 1 }
end
@HeldCount.value = held + 1
return true
endAfter 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:ruby
def try write lock
if (held = @HeldCount.value) >= WRITE LOCK HELD
@HeldCount.value = held + WRITE LOCK HELD
return true
else
# normal global writer acquisition path
end
endThis 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.PoC
ruby
#!/usr/bin/env ruby
# frozen string literal: true
require 'concurrent/atomic/reentrant read write lock'
require 'concurrent/version'
require 'thread'
def wait for queue(queue, timeout seconds)
deadline = Process.clock gettime(Process::CLOCK MONOTONIC) + timeout seconds
loop do
return queue.pop(true)
rescue ThreadError
return nil if Process.clock gettime(Process::CLOCK MONOTONIC) >= deadline
sleep 0.001
end
end
puts "ruby=#{RUBY DESCRIPTION}"
puts "concurrent ruby version=#{Concurrent::VERSION}"
puts "poc=ReentrantReadWriteLock read-depth overflow grants write lock without exclusivity"
lock = Concurrent::ReentrantReadWriteLock.new
other reader ready = Queue.new
other reader stop = Queue.new
other reader = Thread.new do
lock.acquire read lock
other reader ready << :held
other reader stop.pop
end
wait for queue(other reader ready, 1)
puts "other thread holds read lock=true"
depth = Concurrent::ReentrantReadWriteLock::WRITE LOCK HELD
depth.times { lock.acquire read lock }
held count = lock.instance eval { @HeldCount.value }
counter before = lock.instance eval { @Counter.value }
puts "main thread read acquisitions=#{depth}"
puts "main thread held count=#{held count}"
puts "counter before try write=#{counter before}"
puts "running writer bit before=#{(counter before & Concurrent::ReentrantReadWriteLock::RUNNING WRITER) != 0}"
write granted = lock.try write lock
counter after = lock.instance eval { @Counter.value }
puts "try write lock returned=#{write granted}"
puts "counter after try write=#{counter after}"
puts "running writer bit after=#{(counter after & Concurrent::ReentrantReadWriteLock::RUNNING WRITER) != 0}"
third reader ready = Queue.new
third reader = Thread.new do
lock.acquire read lock
third reader ready << :acquired
end
third reader acquired = wait for queue(third reader ready, 0.25) == :acquired
puts "new reader acquired while write claimed=#{third reader acquired}"
if write granted && third reader acquired && (counter after & Concurrent::ReentrantReadWriteLock::RUNNING WRITER).zero?
puts 'result=REPRODUCED write lock granted without setting global writer state'
else
puts 'result=NOT REPRODUCED'
end
third reader.kill
other reader stop << :stop
other reader.killLog evidence
text
ruby=ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25]
concurrent ruby version=1.3.6
poc=ReentrantReadWriteLock read-depth overflow grants write lock without exclusivity
other thread holds read lock=true
main thread read acquisitions=32768
main thread held count=32768
counter before try write=2
running writer bit before=false
try write lock returned=true
counter after try write=2
running writer bit after=false
new reader acquired while write claimed=true
result=REPRODUCED write lock granted without setting global writer stateImpact
This 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.
Credit
Pranjali Thakur - depthfirst (depthfirst.com)
Fix
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Concurrent-Ruby