Skip to main content
← Back to Currents

Ruby Marshal RCE: 5 Things We Check in a Legacy Rails App

September 14, 2026

Ruby on RailsSecurityLegacy Modernization
A character locks a padlock on a legacy machine's input hatch, a clear version plate visible on its body

If you run an older Ruby or Rails app, the universal Marshal RCE chain that elttam published on August 14, 2026 is worth an afternoon. The takeaway: any Marshal.load on data you didn't produce is remote code execution on Ruby 4.0.6 and unchanged back to Ruby 3.3, and upgrading Ruby won't close it. The chain needs no gems beyond the ones that ship with Ruby, no application code, and no prior state on disk.

A deserialization gadget chain is a sequence of classes that already exist in the process and, once an attacker's bytes are loaded, call each other in a way that ends in running a command. Ruby's own Marshal documentation already says you "should never unmarshal user supplied input or other untrusted data." Here's where that input hides in a legacy Rails app, in the order we check it.

1. Cookie and session serialization. Older Rails apps, especially ones upgraded across several major versions, can still carry the Marshal cookie serializer. Check config.action_dispatch.cookies_serializer. If it reads :marshal or :hybrid, a leaked signing key is a direct path into this chain. Moving it to :json is the first job. If the key ever leaked, handle it like any exposed secret and rotate it.

2. The cache store's coder. A Rails.cache configured with a Marshal coder deserializes whatever sits in the cache backend. User-influenced data in a cache entry makes the cache an injection point. So does a shared, loosely locked-down Redis or Memcached instance. Confirm what writes to the cache and in what format.

3. Background job and queue payloads. This is the one teams forget. Job arguments and message-queue bodies get serialized on the way in and deserialized on the way out. If that path ever touched Marshal and any input is external, the worker is the target, not the web tier. Trace where the job arguments come from.

4. Every direct Marshal.load and YAML.unsafe_load. Grep the app and its gems. Look at file uploads, database columns another system writes, imported data, and API request bodies. We treat Marshal.load on anything the app didn't produce itself as command execution. Move it to JSON with an explicit schema.

5. "We're not on Ruby 4, so we're fine." You're not. The chain works unchanged back to Ruby 3.3. Its triggers live in the C internals of Time deserialization and in how a Hash rehashes its keys. elttam argues that removing them would change how core data structures behave, which isn't a change the language can realistically make. Your Ruby version isn't the control here. Whether you deserialize untrusted input is.

None of this is exotic. In the older Ruby apps we remediate, every item on this list is ordinary plumbing that predates the current owners. That's why it survives audits: nobody wrote it recently, so nobody thinks to look. The fix is boring and durable. Stop handing untrusted bytes to Marshal.load, use a data-only format with a schema, and verify the serializer settings you inherited instead of assuming them.

We run this pass on legacy Ruby and Rails apps before an agent or a new feature touches the code. If you want a second set of eyes on yours, that's our Ruby and Rails practice.

Have a problem worth solving?

Tell us what you are trying to build or modernize, and we will tell you honestly how we would approach it.