PT-2026-51092 · Rubygems · Concurrent-Ruby

Published

2026-06-19

·

Updated

2026-06-19

·

CVE-2026-54906

CVSS v4.0

2.1

Low

VectorAV:L/AC:H/AT:N/PR:N/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N

Summary

Concurrent::ReadWriteLock#release write lock does not verify that the calling thread acquired the write lock. Any thread with access to the lock object can release an active write lock held by another thread. A second writer can then enter its critical section while the first writer is still running.
Concurrent::ReadWriteLock#release read lock also decrements the shared counter even when no read lock is held. Calling it on a fresh lock changes the counter from 0 to -1, after which normal read acquisition raises Concurrent::ResourceLimitError.
This is a synchronization correctness issue in the public Concurrent::ReadWriteLock API. It should not be framed as an authorization bypass; the lock is an in-process concurrency primitive, not an access-control boundary.

Version

Software: concurrent-ruby Version: 1.3.6 Commit: 7a1b78941c081106c20a9ca0144ac73a48d254ab

Details

release write lock checks only whether the global counter indicates that a writer is running. It does not track or verify ownership:
ruby
def release write lock
 return true unless running writer?
 c = @Counter.update { |counter| counter - RUNNING WRITER }
 @ReadLock.broadcast
 @WriteLock.signal if waiting writers(c) > 0
 true
end
Because ownership is not checked, a different thread can clear the RUNNING WRITER bit while the original writer is still inside its critical section. Another writer can then acquire the write lock and run concurrently with the first writer.
release read lock unconditionally decrements the shared counter:
ruby
def release read lock
 while true
  c = @Counter.value
  if @Counter.compare and set(c, c-1)
   if waiting writer?(c) && running readers(c) == 1
    @WriteLock.signal
   end
   break
  end
 end
 true
end
On a fresh lock, this changes the counter from 0 to -1. A later acquire read lock raises Concurrent::ResourceLimitError because the maximum-reader check masks the negative counter as saturated.

Reproduce

From the root of a concurrent-ruby checkout, run:
bash
ruby -Ilib/concurrent-ruby - <<'RUBY'
require 'concurrent/atomic/read write lock'
require 'concurrent/version'
require 'thread'

puts "ruby=#{RUBY DESCRIPTION}"
puts "concurrent ruby version=#{Concurrent::VERSION}"
puts "poc=ReadWriteLock release methods corrupt or bypass lock state"

lock = Concurrent::ReadWriteLock.new
events = Queue.new
writer1 inside = false

writer1 = Thread.new do
 lock.acquire write lock
 writer1 inside = true
 events << :writer1 acquired
 sleep 0.5
 writer1 inside = false
 lock.release write lock
 events << :writer1 finished
end

events.pop
puts 'writer1 acquired=true'

intruder result = nil
intruder = Thread.new do
 intruder result = lock.release write lock
end
intruder.join

puts "wrong thread release write lock returned=#{intruder result}"

writer2 entered while writer1 inside = nil
writer2 = Thread.new do
 lock.acquire write lock
 writer2 entered while writer1 inside = writer1 inside
 lock.release write lock
end

writer2.join(0.25)

puts "writer2 acquired while writer1 inside=#{writer2 entered while writer1 inside}"

writer1.join

lock2 = Concurrent::ReadWriteLock.new
stray read release result = lock2.release read lock
counter after stray read release = lock2.instance eval { @Counter.value }
read after stray release = begin
 lock2.acquire read lock
 'acquired'
rescue => error
 "#{error.class}: #{error.message}"
end

puts "stray release read lock returned=#{stray read release result}"
puts "counter after stray read release=#{counter after stray read release}"
puts "acquire read after stray release=#{read after stray release}"

if intruder result && writer2 entered while writer1 inside && counter after stray read release == -1
 puts 'result=REPRODUCED wrong-thread write release and stray read-release corruption'
else
 puts 'result=NOT REPRODUCED'
end
Expected result:
  • A second thread successfully calls release write lock while the first writer still holds the lock.
  • A second writer enters while the first writer is still inside the write critical section.
  • Calling release read lock on a fresh lock changes the counter to -1.
  • A subsequent read acquisition fails with Concurrent::ResourceLimitError.

Log evidence

Local reproduction output:
text
ruby=ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25]
concurrent ruby version=1.3.6
poc=ReadWriteLock release methods corrupt or bypass lock state
writer1 acquired=true
wrong thread release write lock returned=true
writer2 acquired while writer1 inside=true
stray release read lock returned=true
counter after stray read release=-1
acquire read after stray release=Concurrent::ResourceLimitError: Too many reader threads
result=REPRODUCED wrong-thread write release and stray read-release corruption

Impact

This can break the write-lock mutual exclusion guarantee and can also leave a lock unusable after a stray read release. The impact is local to applications that expose or misuse the manual acquire * / release * APIs. If the lock protects integrity-sensitive mutable state, wrong-thread write release can allow concurrent writers and data races. The stray read-release path can cause denial of service by corrupting the lock counter.

Credit

Pranjali Thakur - depthfirst (depthfirst.com)

Fix

Improper Locking

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-54906
GHSA-6WX8-W4F5-WWCR

Affected Products

Concurrent-Ruby