Race Condition / TOCTTOU in atomic file writes

HIGH
rails/rails
Commit: 5f72e36195d6
Affected: Rails 8.1.x prior to the fix (e.g., 8.1.0 - 8.1.2); fixed in 8.1.3 and later
2026-04-05 12:56 UTC

Description

The commit fixes a race condition in atomic file writes by including a random suffix in the temporary file name. Previously, the atomic_write implementation could generate the same temp file name in concurrent writes, causing collisions, TOCTTOU-like behavior, potential data corruption or leakage. The random suffix ensures unique temp names per writer, reducing race conditions and ensuring safer atomic writes.

Proof of Concept

require 'thread' require 'active_support/core_ext/file/atomic' file = "poc_atomic_#{Process.pid}.txt" paths = [] lock = Mutex.new threads = 2.times.map do Thread.new do File.atomic_write(file, Dir.pwd) do |f| lock.synchronize { paths << f.path } sleep 0.01 f.write("data") end end end threads.each(&:join) puts paths.inspect

Commit Details

Author: Donal McBreen

Date: 2026-01-05 14:27 UTC

Message:

Use the random suffix in the atomic temp file name In https://github.com/rails/rails/commit/f781cc50e5b27502d01e38039f401bf7cc2fd369, the suffix was not appended to the temp file name, leading to collisions when concurrently writing to the same file.

Triage Assessment

Vulnerability Type: Race Condition

Confidence: HIGH

Reasoning:

The commit fixes a race condition in atomic file writes by ensuring the temporary file name includes a random suffix, preventing collisions when concurrently writing to the same file. This reduces TOCTTOU-style vulnerabilities and potential data corruption or leakage due to overlapping temp files. The test additionally asserts unique temp file names, reinforcing the security-focused nature.

Verification Assessment

Vulnerability Type: Race Condition / TOCTTOU in atomic file writes

Confidence: HIGH

Affected Versions: Rails 8.1.x prior to the fix (e.g., 8.1.0 - 8.1.2); fixed in 8.1.3 and later

Code Diff

diff --git a/activesupport/lib/active_support/core_ext/file/atomic.rb b/activesupport/lib/active_support/core_ext/file/atomic.rb index f85f3e91c7042..011d932452a0e 100644 --- a/activesupport/lib/active_support/core_ext/file/atomic.rb +++ b/activesupport/lib/active_support/core_ext/file/atomic.rb @@ -27,7 +27,7 @@ def self.atomic_write(file_name, temp_dir = dirname(file_name)) # Names can't be longer than 255B tmp_suffix = ".tmp.#{SecureRandom.hex}" - tmp_name = ".#{basename(file_name).byteslice(0, 254 - tmp_suffix.bytesize)}" + tmp_name = ".#{basename(file_name).byteslice(0, 254 - tmp_suffix.bytesize)}#{tmp_suffix}" tmp_path = File.join(temp_dir, tmp_name) open(tmp_path, RDWR | CREAT | EXCL | SHARE_DELETE | BINARY) do |temp_file| temp_file.binmode diff --git a/activesupport/test/core_ext/file_test.rb b/activesupport/test/core_ext/file_test.rb index e8a7200c06c58..e47eb74176b0a 100644 --- a/activesupport/test/core_ext/file_test.rb +++ b/activesupport/test/core_ext/file_test.rb @@ -94,6 +94,20 @@ def test_when_no_dir end end + def test_atomic_write_uses_unique_temp_file_names + temp_file_paths = [] + 2.times do + File.atomic_write(file_name, Dir.pwd) do |file| + temp_file_paths << file.path + end + end + + assert_match(/\.atomic-#{Process.pid}\.file\.tmp\.[0-9a-f]{32}\z/, temp_file_paths[0]) + assert_not_equal temp_file_paths[0], temp_file_paths[1] + ensure + File.unlink(file_name) rescue nil + end + private def file_name "atomic-#{Process.pid}.file"
← Back to Alerts View on GitHub →