{"id":14884,"date":"2026-10-04T17:30:00","date_gmt":"2026-10-04T12:00:00","guid":{"rendered":"https:\/\/www.allerin.com\/blog\/?p=14884"},"modified":"2026-09-25T11:21:03","modified_gmt":"2026-09-25T05:51:03","slug":"rails-marshal-audit-checklist","status":"publish","type":"post","link":"https:\/\/www.allerin.com\/blog\/rails-marshal-audit-checklist\/","title":{"rendered":"Rails Marshal audit checklist for old readers"},"content":{"rendered":"<p>A Rails application can write JSON while continuing to read Marshal. A useful Marshal audit checks which old formats each reader still accepts, who can supply those bytes, and what happens when that reader is removed.<\/p>\n<p>The <a href=\"https:\/\/www.elttam.com\/blog\/ruby-4-0-universal-rce-deserialization-gadget-chain\" target=\"_blank\" rel=\"noopener\">14 August 2026 elttam research<\/a> demonstrated the danger on Ruby 3.3 through 4.0.6. Ruby&#8217;s <a href=\"https:\/\/docs.ruby-lang.org\/en\/3.4\/Marshal.html#module-Marshal-label-Security+considerations\" target=\"_blank\" rel=\"noopener\">Marshal documentation<\/a> already prohibits loading untrusted data. There is no Ruby patch level that makes that operation safe. Component advisories still require their own updates; they do not replace this audit.<\/p>\n<nav style=\"margin: 24px 0; padding: 18px; background: #edf4f0; border-radius: 6px;\" aria-label=\"Article sections\">\n<p style=\"margin: 0 0 12px;\"><strong>Find the relevant check<\/strong><\/p>\n<ul style=\"display: flex; flex-wrap: wrap; gap: 10px 24px; list-style: none; margin: 0; padding: 0;\">\n<li style=\"margin: 0;\"><a href=\"#check-the-defaults-the-application-actually-adopted\">Check the defaults the application actually adopted<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#start-the-marshal-audit-by-tracing-inputs\">Start the Marshal audit by tracing inputs<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#retire-cookie-and-message-fallbacks-deliberately\">Retire cookie and message fallbacks deliberately<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#a-new-cache-format-can-still-read-marshal\">A new cache format can still read Marshal<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#migrate-columns-without-reopening-unsafe-yaml\">Migrate columns without reopening unsafe YAML<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#prove-each-transition-is-finished\">Prove each transition is finished<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#what-to-do-this-week\">What to do this week<\/a><\/li>\n<li style=\"margin: 0;\"><a href=\"#sources-and-runnable-examples\">Sources and runnable examples<\/a><\/li>\n<\/ul>\n<\/nav>\n<h2 id=\"check-the-defaults-the-application-actually-adopted\" style=\"scroll-margin-top: 128px;\">Check the defaults the application actually adopted<\/h2>\n<p>The version in <code style=\"overflow-wrap: anywhere; white-space: normal;\">Gemfile.lock<\/code> does not tell you which defaults are active. Read <code style=\"overflow-wrap: anywhere; white-space: normal;\">config.load_defaults<\/code>, copied initializers, environment overrides and explicit constructor arguments. The table separates those choices, using tagged source checked on 12 September 2026. It describes the named patches, not every earlier patch in each series.<\/p>\n<div style=\"overflow-x: auto;\" tabindex=\"0\" role=\"region\" aria-label=\"Serializer configuration by Rails series\">\n<table style=\"width: 100%; min-width: 620px; table-layout: auto; border-collapse: collapse; font-size: 16px; line-height: 1.55;\" aria-label=\"Serializer configuration by Rails series\">\n<thead>\n<tr>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">Matching Rails defaults<\/th>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">Cookies<\/th>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">App messages<\/th>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">Cache<\/th>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">serialize<\/code> default<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><a href=\"https:\/\/github.com\/rails\/rails\/blob\/v6.1.7.10\/railties\/lib\/rails\/application\/configuration.rb\" target=\"_blank\" rel=\"noopener\">6.1.7.10<\/a><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Unset; generated initializer JSON<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Marshal<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">6.1 Marshal<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">YAML, safe loading<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><a href=\"https:\/\/github.com\/rails\/rails\/blob\/v7.0.10\/railties\/lib\/rails\/application\/configuration.rb\" target=\"_blank\" rel=\"noopener\">7.0.10<\/a><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">JSON<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Marshal<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">7.0 Marshal<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">YAML, safe loading<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><a href=\"https:\/\/github.com\/rails\/rails\/blob\/v7.1.6\/railties\/lib\/rails\/application\/configuration.rb\" target=\"_blank\" rel=\"noopener\">7.1.6<\/a><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">JSON<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">json_allow_marshal<\/code><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">7.1 format; Marshal values<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">No implicit coder<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><a href=\"https:\/\/github.com\/rails\/rails\/blob\/v7.2.3.2\/railties\/lib\/rails\/application\/configuration.rb\" target=\"_blank\" rel=\"noopener\">7.2.3.2<\/a><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">JSON<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">json_allow_marshal<\/code><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">7.1 format; Marshal values<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">No implicit coder<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><a href=\"https:\/\/github.com\/rails\/rails\/blob\/v8.0.5.1\/railties\/lib\/rails\/application\/configuration.rb\" target=\"_blank\" rel=\"noopener\">8.0.5.1<\/a><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">JSON<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">json_allow_marshal<\/code><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">7.1 format; Marshal values<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">No implicit coder<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><a href=\"https:\/\/github.com\/rails\/rails\/blob\/v8.1.3.1\/railties\/lib\/rails\/application\/configuration.rb\" target=\"_blank\" rel=\"noopener\">8.1.3.1<\/a><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">JSON<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">json_allow_marshal<\/code><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">7.1 format; Marshal values<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">No implicit coder<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>The linked configuration tags identify each row. The companion source matrix links the corresponding cookie, message, cache and column readers. Server-side session gems have independent settings.<\/p>\n<p>In Rails 6.1, the generated <a href=\"https:\/\/github.com\/rails\/rails\/blob\/v6.1.7.10\/railties\/lib\/rails\/generators\/rails\/app\/templates\/config\/initializers\/cookies_serializer.rb.tt\" target=\"_blank\" rel=\"noopener\">cookie initializer<\/a> explicitly selected JSON even though <code style=\"overflow-wrap: anywhere; white-space: normal;\">load_defaults 6.1<\/code> did not. Rails 7.0 brought that choice into <code style=\"overflow-wrap: anywhere; white-space: normal;\">load_defaults<\/code>. Similarly, adopting 7.1 defaults clears <code style=\"overflow-wrap: anywhere; white-space: normal;\">default_column_serializer<\/code>; retaining older defaults can retain YAML. These differences matter in an application that has accumulated several generations of configuration.<\/p>\n<p>The changes have distinct histories. The <a href=\"https:\/\/rubyonrails.org\/2013\/1\/8\/Rails-3-2-11-3-1-10-3-0-19-and-2-3-15-have-been-released\" target=\"_blank\" rel=\"noopener\">2013 parameter-parsing fixes<\/a> restricted request parsing. <a href=\"https:\/\/github.com\/rails\/rails\/blob\/v4.1.6\/guides\/source\/upgrading_ruby_on_rails.md#cookies-serializer\" target=\"_blank\" rel=\"noopener\">Rails 4.1 in 2014<\/a> provided JSON cookies and hybrid migration. The <a href=\"https:\/\/discuss.rubyonrails.org\/t\/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record\/81017\" target=\"_blank\" rel=\"noopener\">2022 serialized-column advisory<\/a> changed YAML loading. <a href=\"https:\/\/guides.rubyonrails.org\/7_1_release_notes.html#add-activesupportmessagepack\" target=\"_blank\" rel=\"noopener\">Rails 7.1 in 2023<\/a> expanded serializer choices. An upgrade must finish the relevant data migrations, not merely install those releases.<\/p>\n<h2 id=\"start-the-marshal-audit-by-tracing-inputs\" style=\"scroll-margin-top: 128px;\">Start the Marshal audit by tracing inputs<\/h2>\n<p>Run the same search over application code and the installed bundle. This script prints candidates with file and line numbers. It cannot determine data flow or certify a match as safe.<\/p>\n<p>Save as <code style=\"overflow-wrap: anywhere; white-space: normal;\">scan_deserializers.rb<\/code> and run <code style=\"overflow-wrap: anywhere; white-space: normal;\">bundle exec ruby \/path\/to\/scan_deserializers.rb &gt; candidates.jsonl<\/code> from the application root.<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-ruby\">#!\/usr\/bin\/env ruby\r\n# Run from an application's root using bundle exec ruby \/path\/to\/this\/script.rb.\r\n# Candidate discovery only. Review data origin, authentication and caller reachability.\r\nrequire \"bundler\/setup\"\r\nrequire \"json\"\r\nrequire \"pathname\"\r\nrequire \"digest\"\r\npatterns = {\r\n  \"marshal\" =&gt; \/\\bMarshal\\s*\\.\\s*(?:load|restore)\\b\/,\r\n  \"yaml_psych\" =&gt; \/\\b(?:YAML|Psych)\\s*\\.\\s*(?:load|load_file|unsafe_load|unsafe_load_file)\\b\/,\r\n  \"json_load\" =&gt; \/\\bJSON\\s*\\.\\s*(?:load|restore|unsafe_load)\\b\/,\r\n  \"oj_load\" =&gt; \/\\bOj\\s*\\.\\s*(?:load|object_load|default_options)\\b\/,\r\n  \"object_hook\" =&gt; \/\\bdef\\s+(?:self\\.)?(?:marshal_load|_load)\\b\/,\r\n  \"base64\" =&gt; \/\\b(?:Base64\\s*\\.\\s*(?:decode64|strict_decode64|urlsafe_decode64)|unpack1?\\s*\\(?\\s*[\"']m)\/,\r\n  \"serializer_config\" =&gt; \/\\b(?:cookies_serializer|message_serializer|cache_format_version|use_yaml_unsafe_load|yaml_column_permitted_classes|session_store)\\b\/,\r\n  \"model_coder\" =&gt; \/\\b(?:serialize|store)\\s+(?:[:\"']|\\()\/,\r\n  \"coder_option\" =&gt; \/\\b(?:coder|serializer)\\s*:\/\r\n}\r\nroots = %w[app lib config].map { |path| [\"application\", path, path] }\r\nBundler.load.specs.each { |gem| roots &lt;&lt; [\"gem\", gem.full_name, gem.full_gem_path] }\r\nroots.each do |scope, name, root|\r\n  Dir.glob(File.join(root, \"**\", \"*.{rb,rake,yml,yaml}\")).sort.each do |path|\r\n    next unless File.file?(path)\r\n    bytes = File.binread(path)\r\n    text = bytes.encode(\"UTF-8\", invalid: :replace, undef: :replace)\r\n    text.each_line.with_index(1) do |line, number|\r\n      patterns.each do |kind, pattern|\r\n        next unless pattern.match?(line)\r\n        relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s\r\n        id = Digest::SHA256.hexdigest([scope, name, relative, number, kind, line].join(\"\\0\"))\r\n        puts JSON.generate({id: id, scope: scope, package: name, path: relative, line: number,\r\n          kind: kind, file_sha256: Digest::SHA256.hexdigest(bytes), status: \"unreviewed\",\r\n          location_hint: relative.match?(%r{(?:\\A|\/)(?:test|spec|fixtures?)\/}) ? \"test-location\" : \"runtime-or-unknown\"})\r\n      end\r\n    end\r\n  end\r\nend\r\n<\/code><\/pre>\n<p>For each result, record the input&#8217;s origin, authentication before parsing, permitted writers, selected coder and retirement plan. Classify it as externally influenced, internal with documented write controls, test-only, or unresolved. Review wrappers and dynamically selected coders as well; absence from this search is not proof of absence.<\/p>\n<p>An internal deep copy that dumps and loads the same known object is different from loading an import. Bootsnap&#8217;s local compile cache depends on control of its files and image build. Neither is a reason to delete every matching line. Keep raw cookies, application secrets and stored customer values out of the audit report.<\/p>\n<p>Cookie settings do not migrate server-side sessions. <a href=\"https:\/\/github.com\/rails\/activerecord-session_store\/blob\/v2.3.0\/README.md\" target=\"_blank\" rel=\"noopener\">activerecord-session_store 2.3.0<\/a> and <a href=\"https:\/\/github.com\/roidrage\/redis-session-store\/blob\/v0.11.6\/lib\/redis-session-store.rb\" target=\"_blank\" rel=\"noopener\">redis-session-store 0.11.6<\/a> default to Marshal and offer separate JSON\/hybrid choices. The redis-actionpack\/redis-rack path instead delegates to <a href=\"https:\/\/github.com\/redis-store\/redis-store\/blob\/v1.12.0\/lib\/redis\/store\/serialization.rb\" target=\"_blank\" rel=\"noopener\">redis-store&#8217;s serializer<\/a>. Inspect the installed combination and session lifetime.<\/p>\n<p>For mounted Rack applications, <a href=\"https:\/\/github.com\/rack\/rack-session\/blob\/v2.1.2\/lib\/rack\/session\/cookie.rb\" target=\"_blank\" rel=\"noopener\">rack-session 2.1.2<\/a> needs particular care. Its encrypted payload defaults to Marshal unless <code style=\"overflow-wrap: anywhere; white-space: normal;\">serialize_json<\/code> is selected; a custom <code style=\"overflow-wrap: anywhere; white-space: normal;\">coder<\/code> bypasses its built-in encryption and HMAC. Preserve authentication when changing formats.<\/p>\n<p>Version context changes other search results. <a href=\"https:\/\/github.com\/ruby\/psych\/blob\/v4.0.0\/lib\/psych.rb\" target=\"_blank\" rel=\"noopener\">Psych 4.0<\/a>, bundled with Ruby 3.1 in 2021, made <code style=\"overflow-wrap: anywhere; white-space: normal;\">load<\/code> use safe loading. But <a href=\"https:\/\/github.com\/collectiveidea\/delayed_job\/blob\/v4.2.0\/lib\/delayed\/psych_ext.rb\" target=\"_blank\" rel=\"noopener\">delayed_job 4.2.0<\/a> has its own object-loading visitor. <a href=\"https:\/\/github.com\/ruby\/json\/blob\/v3.0.2\/CHANGES.md\" target=\"_blank\" rel=\"noopener\">JSON 3.0<\/a> removed class additions in September 2026; older <code style=\"overflow-wrap: anywhere; white-space: normal;\">JSON.load<\/code> calls need separate review. Check <a href=\"https:\/\/github.com\/ohler55\/oj\/blob\/v3.17.6\/pages\/Modes.md\" target=\"_blank\" rel=\"noopener\">Oj&#8217;s selected mode<\/a> too. A method name alone is not a finding.<\/p>\n<h2 id=\"retire-cookie-and-message-fallbacks-deliberately\" style=\"scroll-margin-top: 128px;\">Retire cookie and message fallbacks deliberately<\/h2>\n<p>CookieStore follows the cookie serializer. Signed cookies are verified and encrypted cookies authenticated before their contents are parsed. Their presence is not evidence that unauthenticated input reaches Marshal; compromised signing keys and uncontrolled storage writers change that assessment.<\/p>\n<p>For cookies, <code style=\"overflow-wrap: anywhere; white-space: normal;\">:hybrid<\/code> reads old Marshal values and writes JSON. It is a migration state. Set an expiry or invalidation plan, then move to <code style=\"overflow-wrap: anywhere; white-space: normal;\">:json<\/code> and test old-cookie requests. Permanent cookies and tokens need an explicit decision, not an indefinite wait.<\/p>\n<p>On 12 September, the synthetic Rails 7.0.10 and 8.1.3.1 applications both accepted old cookies in hybrid mode. Direct JSON mode rejected them, but the 7.0 fixture raised <code style=\"overflow-wrap: anywhere; white-space: normal;\">JSON::ParserError<\/code>; the 8.1 fixture returned missing values. The samples exercise signed, encrypted and session cookies. They authenticate or decrypt before inspecting plaintext, and reject tampered controls. A base64 decode alone cannot establish an encrypted cookie&#8217;s serializer.<\/p>\n<p>From either downloaded fixture, select its recorded Ruby and install the locked bundle. Run the setup below once in that shell; later per-app commands use the same test environment. These switches configure the samples only.<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-sh\">export RAILS_ENV=test\r\nmkdir -p tmp db\r\nCOOKIE_SERIALIZER=marshal bin\/rails runner ..\/shared\/cookie_probe.rb write\r\nCOOKIE_SERIALIZER=hybrid bin\/rails runner ..\/shared\/cookie_probe.rb read\r\nCOOKIE_SERIALIZER=json bin\/rails runner ..\/shared\/cookie_probe.rb read\r\n<\/code><\/pre>\n<p>Rails 7.1+ application messages default to <code style=\"overflow-wrap: anywhere; white-space: normal;\">:json_allow_marshal<\/code>. Evaluate strict <code style=\"overflow-wrap: anywhere; white-space: normal;\">:json<\/code> or <code style=\"overflow-wrap: anywhere; white-space: normal;\">:message_pack<\/code>, and inspect explicit serializers and rotations separately. The <a href=\"https:\/\/github.com\/rails\/rails\/blob\/v8.1.3.1\/activesupport\/lib\/active_support\/messages\/rotation_coordinator.rb\" target=\"_blank\" rel=\"noopener\">rotation coordinator<\/a> supports compatible reader\/writer deployment stages. Remove the old rotation after its migration window; adding a strict primary reader does not remove fallbacks.<\/p>\n<p>Below 7.1 there is no equivalent app-wide setting for the old application factory. Individual verifier\/encryptor constructors accept custom serializers, but changing one does not change every framework consumer. Inventory signed IDs, Global IDs, Active Storage links, Action Text references and authentication tokens by the component that creates them. Do not assume an authentication gem uses Rails&#8217; message factory.<\/p>\n<h2 id=\"a-new-cache-format-can-still-read-marshal\" style=\"scroll-margin-top: 128px;\">A new cache format can still read Marshal<\/h2>\n<p>The <a href=\"https:\/\/github.com\/rails\/rails\/blob\/v8.1.3.1\/activesupport\/lib\/active_support\/cache.rb\" target=\"_blank\" rel=\"noopener\">7.1 cache format<\/a> separates entry metadata from the value. It does not make the default value JSON. The built-in cache <code style=\"overflow-wrap: anywhere; white-space: normal;\">:message_pack<\/code> option retains older-format readers. The normal <a href=\"https:\/\/github.com\/rails\/rails\/blob\/v8.1.3.1\/activesupport\/lib\/active_support\/cache\/coder.rb\" target=\"_blank\" rel=\"noopener\">cache coder<\/a> also has a separate Marshal reader for version metadata.<\/p>\n<p>The Rails 8.1 fixture confirms both distinctions using harmless locally written entries. Its alternative replaces the whole entry coder, accepts only a declared set of JSON values and treats the old format as a miss. It checks expiry and version mismatches too. This is an application-specific example, not a general replacement for Rails&#8217; object cache.<\/p>\n<p>The first command writes and reads a local FileStore entry; the second inspects the strict replacement and rejects the old entry. A raw prefix alone does not prove which nested readers are reachable.<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-sh\">bin\/rails runner ..\/shared\/cache_probe.rb write\r\nbin\/rails runner ..\/shared\/cache_probe.rb strict\r\n<\/code><\/pre>\n<p>Use a new namespace or isolated store for incompatible writers, account for cold-cache load, and test rollback. Do not flush a shared Redis instance containing sessions, queues or locks. Authenticate and isolate Redis\/Memcached; restrict file-cache writers. <a href=\"https:\/\/github.com\/rails\/solid_cache\/blob\/v1.0.10\/lib\/solid_cache\/store.rb\" target=\"_blank\" rel=\"noopener\">Solid Cache 1.0.10<\/a> inherits the Rails coder. <a href=\"https:\/\/github.com\/rails\/rails\/blob\/v8.1.3.1\/activesupport\/lib\/active_support\/cache\/mem_cache_store.rb\" target=\"_blank\" rel=\"noopener\">Rails 8.1 MemCacheStore<\/a> additionally sets Dalli&#8217;s serializer to Marshal, so inspect both layers. These dependency findings are source reviews, not runtime tests. Rails 6.1 requires its own store-specific assessment; the whole-entry example targets the two tested versions.<\/p>\n<h2 id=\"migrate-columns-without-reopening-unsafe-yaml\" style=\"scroll-margin-top: 128px;\">Migrate columns without reopening unsafe YAML<\/h2>\n<p>The <a href=\"https:\/\/discuss.rubyonrails.org\/t\/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record\/81017\" target=\"_blank\" rel=\"noopener\">2022 fixes<\/a> shipped in Rails 7.0.3.1, 6.1.6.1, 6.0.5.1 and 5.2.8.1. In the patched tags above, <code style=\"overflow-wrap: anywhere; white-space: normal;\">use_yaml_unsafe_load<\/code> defaults to false and the global permitted list contains <code style=\"overflow-wrap: anywhere; white-space: normal;\">Symbol<\/code>. Audit per-column overrides too. Narrow permitted classes to the data the application needs; do not enable unsafe loading to silence a migration error.<\/p>\n<p><code style=\"overflow-wrap: anywhere; white-space: normal;\">serialize<\/code> and <code style=\"overflow-wrap: anywhere; white-space: normal;\">store<\/code> need a data decision. A JSON coder changes symbol keys and may lose Ruby-specific types. Native JSON\/JSONB columns are another option, with a database migration of their own. For custom message coders, passing the JSON module can call version-dependent <code style=\"overflow-wrap: anywhere; white-space: normal;\">JSON.load<\/code>. Rails&#8217; <code style=\"overflow-wrap: anywhere; white-space: normal;\">serialize<\/code> API maps JSON to its own coder; inspect that API separately. The sample explicitly calls <code style=\"overflow-wrap: anywhere; white-space: normal;\">JSON.parse<\/code> and <code style=\"overflow-wrap: anywhere; white-space: normal;\">JSON.generate<\/code>.<\/p>\n<p>The fixture stages compatibility first. All readers accept the two approved representations while writers retain YAML. After old processes have retired, writers switch to JSON and a compare-and-swap backfill rewrites only unchanged rows. A conflict stops the task rather than overwriting the new value; earlier batches may already be committed. Finally, JSON-only readers reject remaining YAML. Keep rollback readers capable of handling new writes.<\/p>\n<p>The full coders and migration task are in the download. After the fixture&#8217;s seed and compatibility checks, the executed backfill command is:<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-sh\">COLUMN_PHASE=conversion bin\/rails audit:backfill\r\n<\/code><\/pre>\n<p>The task&#8217;s concurrency guard is:<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-ruby\">count = RawPreference.where(\r\n  id: row.id, settings: original, lock_version: row.lock_version\r\n).update_all(settings: replacement, lock_version: row.lock_version + 1)\r\nraise \"concurrent update for row #{row.id}; rerun after checking writers\" unless count == 1\r\n<\/code><\/pre>\n<p>Both versions moved four rows from two YAML, one JSON and one NULL to zero YAML, three JSON and one NULL. Rerunning changed none. Separate tests preserve a competing update and verify values and types, including rejection of unsupported dates and symbol keys. These counts describe only the supplied fixture.<\/p>\n<h2 id=\"prove-each-transition-is-finished\" style=\"scroll-margin-top: 128px;\">Prove each transition is finished<\/h2>\n<div style=\"overflow-x: auto;\" tabindex=\"0\" role=\"region\" aria-label=\"Migration choices and the data they leave behind\">\n<table style=\"width: 100%; min-width: 620px; table-layout: auto; border-collapse: collapse; font-size: 16px; line-height: 1.55;\" aria-label=\"Migration choices and the data they leave behind\">\n<thead>\n<tr>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">Surface<\/th>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">Destination to evaluate<\/th>\n<th style=\"padding: 14px; border: 1px solid #d3dfdb; background: #edf4f0; text-align: left; vertical-align: top;\" scope=\"col\">State to retire or preserve<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Cookies and CookieStore<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">cookies_serializer = :json<\/code><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Hybrid reader; expired or deliberately invalidated sessions<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">App messages, 7.1+<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\"><code style=\"overflow-wrap: anywhere; white-space: normal;\">message_serializer = :json<\/code> or <code style=\"overflow-wrap: anywhere; white-space: normal;\">:message_pack<\/code><\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Marshal rotations; old tokens and permanent links<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Cache<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Reviewed whole-entry coder and isolated namespace<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Old entries; cold-cache capacity; any sessions sharing storage<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Columns<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Restricted safe YAML or strict JSON\/native JSONB<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Old rows and types; dual reader; compatible rollback<\/td>\n<\/tr>\n<tr>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Server-side sessions<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Exact gem&#8217;s documented coder<\/td>\n<td style=\"padding: 14px; border: 1px solid #d3dfdb; text-align: left; vertical-align: top; overflow-wrap: anywhere;\">Stored sessions; old writers; reauthentication decision<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>Run the complete rehearsal from the extracted package root with Ruby 3.3.12 for <code style=\"overflow-wrap: anywhere; white-space: normal;\">70<\/code> and Ruby 3.4.10 for <code style=\"overflow-wrap: anywhere; white-space: normal;\">81<\/code>:<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-sh\">bash run_fixture.sh 70\r\nbash run_fixture.sh 81\r\n<\/code><\/pre>\n<p>The package includes these configuration and policy checks, run from each app:<\/p>\n<pre style=\"overflow-x: auto; max-width: 100%;\"><code class=\"language-sh\">bin\/rails runner ..\/shared\/config_audit.rb\r\nCACHE_MODE=strict MESSAGE_SERIALIZER=json bin\/rails runner ..\/shared\/config_audit.rb\r\nCACHE_MODE=strict MESSAGE_SERIALIZER=json bin\/rails runner ..\/shared\/assert_policy.rb\r\n<\/code><\/pre>\n<p>The Rails 8.1 fixture passes its stated policy. Rails 7.0 deliberately fails for the unresolved framework-message scope. Neither result establishes the absence of other readers. The source scan also leaves most dependency candidates unreviewed; it supplies a ledger format for an application&#8217;s own review.<\/p>\n<p>The download contains the full configuration printer, negative controls and migration task. Tests use synthetic applications, public fixture-only keys and benign data. Source research and AI review support this article; these results are not an Allerin production case study.<\/p>\n<p>A clean Brakeman report does not inspect stored bytes, credentials, every dependency or deployment rotations. The <a href=\"https:\/\/brakemanscanner.org\/docs\/warning_types\/unsafe_deserialization\/\" target=\"_blank\" rel=\"noopener\">deserialization checks<\/a> are one part of the evidence. The fixture runs <code style=\"overflow-wrap: anywhere; white-space: normal;\">Deserialize<\/code>, <code style=\"overflow-wrap: anywhere; white-space: normal;\">CookieSerialization<\/code> and <code style=\"overflow-wrap: anywhere; white-space: normal;\">ModelSerialize<\/code>. Each reported zero scan errors and zero warnings before and after, so those reports did not distinguish the runtime serializer modes. A separate full scan flagged Rails 7.0 end of support.<\/p>\n<h2 id=\"what-to-do-this-week\" style=\"scroll-margin-top: 128px;\">What to do this week<\/h2>\n<ol>\n<li>Record effective defaults and every unclassified application or dependency reader.<\/li>\n<li>Choose strict destinations and document lost sessions, invalidated tokens, changed types and cache misses.<\/li>\n<li>Test compatible deployment stages, old-format rejection and rollback before removing a reader.<\/li>\n<li>Retire transitional readers, then keep the relevant negative controls in CI.<\/li>\n<\/ol>\n<p>Check the <a href=\"https:\/\/www.allerin.com\/blog\/rails-support-calendar-explained\/\">Rails support calendar<\/a> separately from this audit. For an application review and supported upgrade path, see <a href=\"https:\/\/www.allerin.com\/services\/rails-upgrades\">Rails upgrades<\/a>.<\/p>\n<h2 id=\"sources-and-runnable-examples\" style=\"scroll-margin-top: 128px;\">Sources and runnable examples<\/h2>\n<p><a href=\"https:\/\/www.allerin.com\/downloads\/rails\/marshal-audit-checks.zip\">Download the runnable checks and source matrix<\/a>. The README, exact lockfiles, scanner, configuration printer, migration task and negative controls are included. Tests ran on Rails 7.0.10\/Ruby 3.3.12 and Rails 8.1.3.1\/Ruby 3.4.10 on arm64 macOS on 12 September 2026. Rails 7.0 is an unsupported comparison baseline.<\/p>\n<p>The linked framework tags and gem sources support the defaults above. Read the <a href=\"https:\/\/docs.ruby-lang.org\/en\/4.0\/Marshal.html\" target=\"_blank\" rel=\"noopener\">Ruby Marshal security guidance<\/a>, <a href=\"https:\/\/www.ruby-lang.org\/en\/security\/\" target=\"_blank\" rel=\"noopener\">Ruby security notices<\/a> and <a href=\"https:\/\/guides.rubyonrails.org\/upgrading_ruby_on_rails.html\" target=\"_blank\" rel=\"noopener\">Rails upgrade guide<\/a> alongside the migration you are planning. The separate <a href=\"https:\/\/www.ruby-lang.org\/en\/news\/2026\/04\/21\/erb-cve-2026-41316\/\" target=\"_blank\" rel=\"noopener\">April 2026 ERB advisory<\/a> lists component fixes; it does not make untrusted Marshal input safe.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Find old deserializers in Rails cookies, messages, caches and database columns. Tested examples distinguish a new writer from removal of the old reader.<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[2037],"tags":[2070,2069,2068,2067,2071],"class_list":["post-14884","post","type-post","status-publish","format-standard","hentry","category-ruby-on-rails","tag-rails-cache","tag-rails-cookies","tag-rails-security","tag-ruby-marshal","tag-serialization"],"_links":{"self":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14884","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/comments?post=14884"}],"version-history":[{"count":2,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14884\/revisions"}],"predecessor-version":[{"id":14897,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14884\/revisions\/14897"}],"wp:attachment":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/media?parent=14884"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/categories?post=14884"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/tags?post=14884"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}