See how rubyconcurrency compares to other vendors in security performance
Summary Concurrent::AtomicReference#update can enter a permanent busy retry loop when the current value is Float::NAN.
The issue is caused by the interaction between: - AtomicReference#update, which retries until compareandset(oldvalue, newvalue) succeeds. - Numeric compareandset, which checks old == oldvalue before attempting the underlying atomic swap. - Ruby NaN semantics, where Float::NAN == Float::NAN is always false.
As a result, once an AtomicReference contains Float::NAN, calling #update repeatedly evaluates the caller's block and never returns. In services that store externally derived numeric values in an AtomicReference, this can cause CPU exhaustion or permanent request/job hangs.
Version Software: concurrent-ruby Version: 1.3.6 Commit: 7a1b78941c081106c20a9ca0144ac73a48d254ab Details
AtomicReference#update retries until compareandset returns true:
ruby def update true until compareandset(oldvalue = get, newvalue = yield(oldvalue)) newvalue end
For numeric expected values, compareandset uses numeric equality before attempting the underlying atomic compare-and-set:
ruby def compareandset(oldvalue, newvalue) if oldvalue.kindof? Numeric while true old = get
return false unless old.kindof? Numeric return false unless old == oldvalue
result = compareandset(old, newvalue) return result if result end else compareandset(oldvalue, newvalue) end end
When the stored value is Float::NAN, oldvalue = get returns NaN. The later comparison old == oldvalue is false because NaN is not equal to itself. compareandset therefore returns false every time. AtomicReference#update treats that as a failed concurrent update and retries forever.
This is reachable through the public Concurrent::AtomicReference API and does not require native extensions or undefined behavior.
PoC
ruby #!/usr/bin/env ruby frozenstringliteral: true
require 'concurrent/atomic/atomicreference' require 'concurrent/version'
puts "ruby=#{RUBYDESCRIPTION}" puts "concurrentrubyversion=#{Concurrent::VERSION}" puts "poc=AtomicReference#update livelock when current value is Float::NAN"
ref = Concurrent::AtomicReference.new(Float::NAN) attempts = 0 finished = false
worker = Thread.new do ref.update do |oldvalue| attempts += 1 0.0 end finished = true end
sleep 0.25
puts "nanupdateattemptsafter250ms=#{attempts}" puts "nanupdatefinished=#{finished}" puts "nanupdateworkeralive=#{worker.alive?}"
if worker.alive? && !finished && attempts > 1000 puts 'result=REPRODUCED busy retry loop; update did not complete' else puts 'result=NOTREPRODUCED' end
worker.kill worker.join
control = Concurrent::AtomicReference.new(1.0) controlattempts = 0 controlresult = control.update do |oldvalue| controlattempts += 1 oldvalue + 1.0 end
puts "controlupdateresult=#{controlresult.inspect}" puts "controlupdateattempts=#{controlattempts}" puts "controlupdatefinalvalue=#{control.value.inspect}" Log evidence text ruby=ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25] concurrentrubyversion=1.3.6 poc=AtomicReference#update livelock when current value is Float::NAN nanupdateattemptsafter250ms=1926016 nanupdatefinished=false nanupdateworkeralive=true result=REPRODUCED busy retry loop; update did not complete controlupdateresult=2.0 controlupdateattempts=1 controlupdatefinalvalue=2.0
Impact This is an application-level denial of service issue. If an application stores externally derived numeric data in a Concurrent::AtomicReference, an attacker or faulty upstream data source may be able to cause the stored value to become Float::NAN. Any later call to AtomicReference#update on that reference will spin indefinitely, repeatedly executing the update block and consuming CPU.
Credit Pranjali Thakur - depthfirst (depthfirst.com)
Summary Concurrent::ReadWriteLock#releasewritelock 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#releasereadlock 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
releasewritelock checks only whether the global counter indicates that a writer is running. It does not track or verify ownership:
ruby def releasewritelock return true unless runningwriter? c = @Counter.update { |counter| counter - RUNNINGWRITER } @ReadLock.broadcast @WriteLock.signal if waitingwriters(c) > 0 true end
Because ownership is not checked, a different thread can clear the RUNNINGWRITER 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.
releasereadlock unconditionally decrements the shared counter:
ruby def releasereadlock while true c = @Counter.value if @Counter.compareandset(c, c-1) if waitingwriter?(c) && runningreaders(c) == 1 @WriteLock.signal end break end end true end
On a fresh lock, this changes the counter from 0 to -1. A later acquirereadlock 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/readwritelock' require 'concurrent/version' require 'thread'
puts "ruby=#{RUBYDESCRIPTION}" puts "concurrentrubyversion=#{Concurrent::VERSION}" puts "poc=ReadWriteLock release methods corrupt or bypass lock state"
lock = Concurrent::ReadWriteLock.new events = Queue.new writer1inside = false
writer1 = Thread.new do lock.acquirewritelock writer1inside = true events << :writer1acquired sleep 0.5 writer1inside = false lock.releasewritelock events << :writer1finished end
events.pop puts 'writer1acquired=true'
intruderresult = nil intruder = Thread.new do intruderresult = lock.releasewritelock end intruder.join
puts "wrongthreadreleasewritelockreturned=#{intruderresult}"
writer2enteredwhilewriter1inside = nil writer2 = Thread.new do lock.acquirewritelock writer2enteredwhilewriter1inside = writer1inside lock.releasewritelock end
writer2.join(0.25)
puts "writer2acquiredwhilewriter1inside=#{writer2enteredwhilewriter1inside}"
writer1.join
lock2 = Concurrent::ReadWriteLock.new strayreadreleaseresult = lock2.releasereadlock counterafterstrayreadrelease = lock2.instanceeval { @Counter.value } readafterstrayrelease = begin lock2.acquirereadlock 'acquired' rescue => error "#{error.class}: #{error.message}" end
puts "strayreleasereadlockreturned=#{strayreadreleaseresult}" puts "counterafterstrayreadrelease=#{counterafterstrayreadrelease}" puts "acquirereadafterstrayrelease=#{readafterstrayrelease}"
if intruderresult && writer2enteredwhilewriter1inside && counterafterstrayreadrelease == -1 puts 'result=REPRODUCED wrong-thread write release and stray read-release corruption' else puts 'result=NOTREPRODUCED' end Expected result:
- A second thread successfully calls releasewritelock while the first writer still holds the lock. - A second writer enters while the first writer is still inside the write critical section. - Calling releasereadlock 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] concurrentrubyversion=1.3.6 poc=ReadWriteLock release methods corrupt or bypass lock state writer1acquired=true wrongthreadreleasewritelockreturned=true writer2acquiredwhilewriter1inside=true strayreleasereadlockreturned=true counterafterstrayreadrelease=-1 acquirereadafterstrayrelease=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)
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 WRITELOCKHELD. After 32,768 reentrant read acquisitions, the local read count crosses into the write-lock bit. trywritelock then treats the thread as already holding a write lock and returns true without setting the global RUNNINGWRITER 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 READERBITS = 15 WRITERBITS = 14
WAITINGWRITER = 1 << READERBITS RUNNINGWRITER = 1 << (READERBITS + WRITERBITS) MAXREADERS = WAITINGWRITER - 1 MAXWRITERS = RUNNINGWRITER - MAXREADERS - 1
WRITELOCKHELD = 1 << READERBITS READLOCKMASK = WRITELOCKHELD - 1 WRITELOCKMASK = MAXWRITERS
When a thread already holds a lock, acquirereadlock increments @HeldCount:
ruby if (held = @HeldCount.value) > 0 if held & READLOCKMASK == 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 WRITELOCKHELD. Then trywritelock returns success through its "already have a write lock" branch:
ruby def trywritelock if (held = @HeldCount.value) >= WRITELOCKHELD @HeldCount.value = held + WRITELOCKHELD return true else # normal global writer acquisition path end end
This branch does not set the global RUNNINGWRITER 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 frozenstringliteral: true
require 'concurrent/atomic/reentrantreadwritelock' require 'concurrent/version' require 'thread'
def waitforqueue(queue, timeoutseconds) deadline = Process.clockgettime(Process::CLOCKMONOTONIC) + timeoutseconds loop do return queue.pop(true) rescue ThreadError return nil if Process.clockgettime(Process::CLOCKMONOTONIC) >= deadline
sleep 0.001 end end
puts "ruby=#{RUBYDESCRIPTION}" puts "concurrentrubyversion=#{Concurrent::VERSION}" puts "poc=ReentrantReadWriteLock read-depth overflow grants write lock without exclusivity"
lock = Concurrent::ReentrantReadWriteLock.new otherreaderready = Queue.new otherreaderstop = Queue.new
otherreader = Thread.new do lock.acquirereadlock otherreaderready << :held otherreaderstop.pop end
waitforqueue(otherreaderready, 1) puts "otherthreadholdsreadlock=true"
depth = Concurrent::ReentrantReadWriteLock::WRITELOCKHELD depth.times { lock.acquirereadlock }
heldcount = lock.instanceeval { @HeldCount.value } counterbefore = lock.instanceeval { @Counter.value }
puts "mainthreadreadacquisitions=#{depth}" puts "mainthreadheldcount=#{heldcount}" puts "counterbeforetrywrite=#{counterbefore}" puts "runningwriterbitbefore=#{(counterbefore & Concurrent::ReentrantReadWriteLock::RUNNINGWRITER) != 0}"
writegranted = lock.trywritelock counterafter = lock.instanceeval { @Counter.value }
puts "trywritelockreturned=#{writegranted}" puts "counteraftertrywrite=#{counterafter}" puts "runningwriterbitafter=#{(counterafter & Concurrent::ReentrantReadWriteLock::RUNNINGWRITER) != 0}"
thirdreaderready = Queue.new thirdreader = Thread.new do lock.acquirereadlock thirdreaderready << :acquired end
thirdreaderacquired = waitforqueue(thirdreaderready, 0.25) == :acquired puts "newreaderacquiredwhilewriteclaimed=#{thirdreaderacquired}"
if writegranted && thirdreaderacquired && (counterafter & Concurrent::ReentrantReadWriteLock::RUNNINGWRITER).zero? puts 'result=REPRODUCED write lock granted without setting global writer state' else puts 'result=NOTREPRODUCED' end
thirdreader.kill otherreaderstop << :stop otherreader.kill
Log evidence text ruby=ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25] concurrentrubyversion=1.3.6 poc=ReentrantReadWriteLock read-depth overflow grants write lock without exclusivity otherthreadholdsreadlock=true mainthreadreadacquisitions=32768 mainthreadheldcount=32768 counterbeforetrywrite=2 runningwriterbitbefore=false trywritelockreturned=true counteraftertrywrite=2 runningwriterbitafter=false newreaderacquiredwhilewriteclaimed=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)