PT-2026-51091 · Rubygems · Concurrent-Ruby

Published

2026-06-19

·

Updated

2026-06-19

·

CVE-2026-54905

CVSS v4.0

2.0

Low

VectorAV: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 WRITERS
When 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
end
After 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
end
This 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.kill

Log 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 state

Impact

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

CVE-2026-54905
GHSA-WV3X-4VXV-WHPP

Affected Products

Concurrent-Ruby