Deserialization (cache deserialization) vulnerability; potential DoS via corrupted cache payloads

HIGH
rails/rails
Commit: d51ad367e2c2
Affected: Rails 8.1.x prior to 8.1.3 (i.e., 8.1.0–8.1.2)
2026-04-05 13:03 UTC

Description

The commit improves handling of deserialization errors in the Rails cache layer to prevent unhandled exceptions when confronted with corrupted or truncated cache payloads. Previously, deserialization (Marshal-based) of cache entries could raise exceptions or crash the process if the payload could not be deserialized (e.g., due to memcached delivering a truncated payload). The patch ensures such errors are reported (via ActiveSupport.error_reporter) and treated as a cache miss rather than propagating an exception. It also introduces safeguards to raise a controlled Cache::DeserializationError for certain lazy-loading paths and to wrap deserialization during compressed payload loading with explicit error handling. Tests were added to verify that corrupted payloads result in error reporting and cache miss behavior, and that lazy-loaded values raise DeserializationError when payloads can’t be deserialized. This reduces risk from corrupted or crafted cache payloads and mitigates potential DoS or crash scenarios originating from deserialization of untrusted cache data.

Proof of Concept

# Proof-of-concept (Rails 8.1.x with patch) # Prereqs: use a cache coder that uses Marshal + Zlib (as in test) require 'active_support/cache' require 'zlib' coder = ActiveSupport::Cache::Coder.new(Marshal, Zlib) entry = ActiveSupport::Cache::Entry.new(["test"], version: "12", expires_in: 34) payload = coder.dump_compressed(entry, 1) # 1) Corrupt payload (simulate truncated bytes) corrupted = payload.byteslice(1..-1) begin coder.load(corrupted) puts "Corrupted payload loaded (unexpected)" rescue => e puts "Corrupted payload caused error (expected): #{e.class}: #{e.message}" end # 2) Lazy-load path: truncated payload leads to DeserializationError on value access lazy_entry = coder.load(payload.byteslice(0..-2)) begin lazy_entry.value puts "Deserialization unexpectedly succeeded" rescue ActiveSupport::Cache::DeserializationError => e puts "DeserializationError raised (expected): #{e.class}: #{e.message}" end # 3) Using an intact payload shows no error begin intact = coder.dump(entry) coder.load(intact) puts "Intact payload loaded successfully" rescue => e puts "Unexpected error with intact payload: #{e.class}: #{e.message}" end

Commit Details

Author: Jean Boussier

Date: 2026-02-04 09:50 UTC

Message:

Better handle cache deserialization errors While rare, it can happen that some cache backend return truncated responses (memcached...). But generally speaking, the cache should treat any sort of error during deserialization as a cache miss.

Triage Assessment

Vulnerability Type: Deserialization / Cache deserialization

Confidence: HIGH

Reasoning:

The patch adds safeguards around cache deserialization to treat errors as cache misses and reports deserialization errors instead of allowing unhandled exceptions or unsafe processing. This reduces risk from corrupted or crafted cache payloads (e.g., from memcached), addressing potential deserialization vulnerabilities and denial-of-service scenarios.

Verification Assessment

Vulnerability Type: Deserialization (cache deserialization) vulnerability; potential DoS via corrupted cache payloads

Confidence: HIGH

Affected Versions: Rails 8.1.x prior to 8.1.3 (i.e., 8.1.0–8.1.2)

Code Diff

diff --git a/activesupport/lib/active_support/cache/coder.rb b/activesupport/lib/active_support/cache/coder.rb index b21d07dc6d5e2..5c018cc537a7c 100644 --- a/activesupport/lib/active_support/cache/coder.rb +++ b/activesupport/lib/active_support/cache/coder.rb @@ -39,14 +39,19 @@ def dump_compressed(entry, threshold) version = dump_version(entry.version) if entry.version version_length = version&.bytesize || -1 - packed = SIGNATURE.b - packed << [type, expires_at, version_length].pack(PACKED_TEMPLATE) - packed << version if version - packed << payload + header = [type, expires_at, version_length].pack(PACKED_TEMPLATE) + + "#{SIGNATURE}#{header}#{version}#{payload}".freeze end def load(dumped) - return @serializer.load(dumped) if !signature?(dumped) + unless signature?(dumped) + return begin + @serializer.load(dumped) + rescue => error + ActiveSupport.error_reporter.report(error, source: "active_support.cache") + end + end type = dumped.unpack1(PACKED_TYPE_TEMPLATE) expires_at = dumped.unpack1(PACKED_EXPIRES_AT_TEMPLATE) @@ -64,6 +69,7 @@ def load(dumped) private SIGNATURE = "\x00\x11".b.freeze + EMPTY_BINARY_STRING = "".b.freeze OBJECT_DUMP_TYPE = 0x01 @@ -105,7 +111,12 @@ def initialize(serializer, compressor, payload, **options) def value if !@resolved - @value = @serializer.load(@compressor ? @compressor.inflate(@value) : @value) + @value = begin + @serializer.load(@compressor ? @compressor.inflate(@value) : @value) + rescue => error + ActiveSupport.error_reporter.report(error, source: "active_support.cache") + raise DeserializationError, error.message + end @resolved = true end @value diff --git a/activesupport/lib/active_support/cache/serializer_with_fallback.rb b/activesupport/lib/active_support/cache/serializer_with_fallback.rb index 99358490a8452..cc589851fcfac 100644 --- a/activesupport/lib/active_support/cache/serializer_with_fallback.rb +++ b/activesupport/lib/active_support/cache/serializer_with_fallback.rb @@ -87,7 +87,13 @@ def dump_compressed(entry, threshold) def _load(marked) dumped = marked.byteslice(1..-1) - dumped = Zlib::Inflate.inflate(dumped) if marked.start_with?(MARK_COMPRESSED) + if marked.start_with?(MARK_COMPRESSED) + dumped = begin + Zlib::Inflate.inflate(dumped) + rescue Zlib::Error => error + raise Cache::DeserializationError, "#{error.class}: #{error.message}" + end + end Cache::Entry.unpack(marshal_load(dumped)) end diff --git a/activesupport/test/cache/cache_coder_test.rb b/activesupport/test/cache/cache_coder_test.rb index 1e37f5961a102..90d439648a56a 100644 --- a/activesupport/test/cache/cache_coder_test.rb +++ b/activesupport/test/cache/cache_coder_test.rb @@ -20,6 +20,33 @@ class CacheCoderTest < ActiveSupport::TestCase end end + test "handle corrupted payloads gracefully" do + coder = ActiveSupport::Cache::Coder.new(Marshal, Zlib) + entry = ActiveSupport::Cache::Entry.new(["test"], version: "12", expires_in: 34) + + payload = coder.dump_compressed(entry, 1) + + assert_error_reported do + assert_nil coder.load(payload.byteslice(1..-1)) + end + + lazy_entry = coder.load(payload.byteslice(0..-2)) + assert_raises ActiveSupport::Cache::DeserializationError do + lazy_entry.value + end + + payload = coder.dump(entry) + + assert_error_reported do + assert_nil coder.load(payload.byteslice(1..-1)) + end + + lazy_entry = coder.load(payload.byteslice(0..-2)) + assert_raises ActiveSupport::Cache::DeserializationError do + lazy_entry.value + end + end + test "compresses values that are larger than the threshold" do COMPRESSIBLE_ENTRIES.each do |entry| dumped = @coder.dump(entry) @@ -117,7 +144,7 @@ def dump_compressed(*) end def load(dumped) - Marshal.load(dumped.delete_prefix!("SERIALIZED:")) + Marshal.load(dumped.delete_prefix("SERIALIZED:")) end end @@ -129,7 +156,7 @@ def deflate(string) end def inflate(deflated) - Zlib.inflate(deflated.delete_prefix!("COMPRESSED:")) + Zlib.inflate(deflated.delete_prefix("COMPRESSED:")) end end diff --git a/activesupport/test/cache/serializer_with_fallback_test.rb b/activesupport/test/cache/serializer_with_fallback_test.rb index 57568f9135c59..6893d65d23017 100644 --- a/activesupport/test/cache/serializer_with_fallback_test.rb +++ b/activesupport/test/cache/serializer_with_fallback_test.rb @@ -47,6 +47,12 @@ class CacheSerializerWithFallbackTest < ActiveSupport::TestCase assert_operator compressed.bytesize, :<, uncompressed.bytesize assert_entry @entry, serializer(format).load(compressed) assert_entry @entry, serializer(format).load(uncompressed) + + unless format == :passthrough + assert_raises ActiveSupport::Cache::DeserializationError do + serializer(format).load(compressed.byteslice(0..-4)) + end + end end end diff --git a/tools/strict_warnings.rb b/tools/strict_warnings.rb index 4eae85903f9a7..b20daea67f625 100644 --- a/tools/strict_warnings.rb +++ b/tools/strict_warnings.rb @@ -20,7 +20,10 @@ class WarningError < StandardError; end # TODO: remove if https://github.com/mikel/mail/pull/1557 or similar fix %r{/lib/mail/parsers/.*statement not reached}, %r{/lib/mail/parsers/.*assigned but unused variable - disp_type_s}, - %r{/lib/mail/parsers/.*assigned but unused variable - testEof} + %r{/lib/mail/parsers/.*assigned but unused variable - testEof}, + + # Emitted by zlib + /attempt to close unfinished zstream/, ) def warn(message, ...)
← Back to Alerts View on GitHub →