criticalCVE-2026-66066CVSS 9.5Rails · Active Storage

KindaRails2Shell: upgrade Rails, then rotate every secret it could have read

A crafted 'image' upload makes Rails' image processor read any file on the server and hand it back as pixels, secrets included. Upgrade Active Storage, block untrusted libvips operations, and rotate everything the app process could read.

Marko ZivanovicAug 01, 20269 min read

01 The Big PictureFor leadership · no jargon

Okay, so this one's properly nasty, and it's got a daft name to boot: KindaRails2Shell. If your Rails app lets users upload images, an attacker could send you a file that claims to be an image but secretly contains instructions saying "actually, go read this other file on the server, and turn its contents into my picture". The server with no questions does so, and the attacker downloads their "image", which is really your server's secrets, painted as pixels.

Think of it like a photo printing shop where customers hand in pictures to be resized. One customer hands in a photo whose caption reads "please print page one of the office safe instead". The clerk doesn't check; the caption looks photo-ish enough. Out comes a nice glossy print of everything in the safe, and the customer walks off with it.

The file attackers ask for first is the one holding the app's master secret, the key Rails uses to sign its own internal messages. With that key, the attacker can forge a message Rails trusts, and forgery escalates the whole thing from "read any file" to "run any command". Pre-authentication, over the internet, CVSS 9.5. Yikes.

The genuinely good news, the fix shipped the same day as the advisory (29 July 2026), neither the Rails Security Team nor Rapid7 are aware of any exploitation in the wild, and it's not in CISA's Known Exploited Vulnerabilities catalogue as of writing. The less good news working exploit code has been public since 31 July, including a full lab PoC on GitHub, so the clock is very much ticking. The job is a gem upgrade, a library version check, and then the bit everyone forgets: rotating the secrets the bug exists to steal.

02 Technical BreakdownFor engineers

The root cause is two stacked type confusions between Rails' Active Storage and libvips, the image library behind the default :vips variant processor (the maintainers' own writeup in the-attack.md is the reference here, and it's excellent).

First confusion is Active Storage's built-in direct-upload endpoint (POST /rails/active_storage/direct_uploads, mounted by default) persists the *client-declared* content_type without inspecting the bytes, and Blob#variable? (representable.rb:112) then trusts that database column. libvips, meanwhile, picks its loader by sniffing magic bytes. So the app thinks the blob is a image/bmp, while libvips is free to disagree.

Second confusion is libvips' matload sniffer (libvips/foreign/matlab.c) accepts any file whose first 10 bytes read MATLAB 5.0, but libmatio underneath dispatches on the version field at bytes 124–125, where 0x0200 means MAT 7.3, which is actually HDF5. So the crafted upload is a structurally genuine HDF5 container wearing a MATLAB 5.0 moustache. No legitimate writer emits that combination, which is handy for detection later.

And here's where it gets fun (for the attacker). HDF5 has a feature called the External File List: a dataset whose storage is a (path, offset, length) triple pointing at another file on disk. Point it at, say, /proc/1/environ, and the bytes of that file become the pixel values of the "image" the variant processor renders. The attacker requests the variant, downloads the PNG, and reads your environment variables off the pixels. SECRET_KEY_BASE falls out. No key needed for any of this, by the way; the file-read stage needs only a genuine variation_key scraped from any page that renders a representation.

With SECRET_KEY_BASE recovered, the attacker forges signed variation messages and escalates to RCE. Three variants are public: Ethiack's chain abuses CVE-2025-24293 (instance_eval reaching Vips::Image.public_send), Rapid7 verified RCE by forging an ImageProcessing 1.x variation with no Marshal involved, and the Zer0SumGam3 PoC goes the classic Marshal gadget route (DeprecatedInstanceVariableProxy wrapping MiniMagick::Tool, firing a curl callback out to an OAST listener). Its gadget classes come from gems a stock rails new already pulls in, which is a neat touch, if "neat" is the word.

The regression itself is image_processing 2.0 disabled libvips' untrusted operations at load, but an edge commit re-enabled them, and both the Vips analyser and the transformer then happily passed untrusted attachments to unsafe loaders. The fix (rails/rails@349e7a5) flips the untrusted-operation block back on, permanently. Credit where due: found independently by 0xacb, s3np41k1r1t0 and castilho at Ethiack, and RyotaK at GMO Flatt Security, reported 22 July, patched and disclosed by the 29th. Brisk.

03 Affected

Per the advisory (GHSA-xr9x-r78c-5hrm), everything below these fixed releases: 7.2.3.2, 8.0.5.1, 8.1.3.1. That is activestorage < 7.2.3.2, >= 8.0.0.beta1, < 8.0.5.1, and >= 8.1.0.beta1, < 8.1.3.1.

Exposure needs all of the following is the :vips variant processor (the default since config.load_defaults 7.0, so most modern apps), image uploads accepted from untrusted users, and a libvips build that includes the unsafe loaders. That last one covers stock Debian, Ubuntu and the Rails-generated Docker images, which is a lot of production. Note you don't need to explicitly generate variants anywhere; the attacker's direct upload plus a scraped representation URL is enough.

Two traps to step around. Rails 7.1 and earlier are end-of-life and got no backport: 6.0–6.1 are exploitable when Vips was manually configured (Rapid7 verified it), and if you're on those branches the only fix is an upgrade to 7.2.3.2 or later. And the NVD entry currently opens with "Action Pack is a framework for handling and responding to web requests", a copy-paste artefact; the vulnerable component is Active Storage. Don't let a scanner's component name talk you out of patching.

04 Detection

Before we change anything, has anyone already had a go? Rails published an official forensics kit, rails/rails-forensics-CVE-2026-66066, extracted from a real investigation at 37signals, and it correctly re-identified the researchers' own test uploads. The artefacts, in increasing strength:

  • An unattached blob holding the crafted MAT file: the attack was staged.
  • A row in active_storage_variant_records for that blob: the read actually ran.
  • The rendered variant image in your own object store, whose pixel values are the exfiltrated bytes. Yes, really; you can recover what was taken from your own bucket.

The file signature to hunt for: bytes 0–9 claim MATLAB 5.0 *and* bytes 124–127 carry the MAT 7.3 dispatch tag. No legitimate writer emits both. Don't anchor on the HDF5 signature at offset 512, though: the user block can be any power of two ≥ 512, and a fixed-offset detector will false-negative on exactly the files you're looking for.

The repo ships a self-contained, read-only scanner you can copy into a container or run on the host:

bin/rails runner kr2s_scan_active_storage_blobs.rb --since 2026-07-20 --output /tmp/kr2s

Sweep the blob store for crafted MAT files; crafted is the finding, and a non-zero failed count means a coverage gap, not a clean bill. Set the window to when vulnerable code served production traffic.

For log and SIEM hunting, join requests on the crafted blob's checksum and byte_size: look for POST /rails/active_storage/direct_uploads followed by GET /rails/active_storage/representations/redirect/... with spliced signed IDs. If you're running the Zer0SumGam3 PoC in a lab, its callback is an outbound curl from the Rails process with a rails_ghsa_xr9x=<nonce> query parameter; a real attacker would change both, so treat those as lab IOCs only.

One nasty caveat: scheduled cleanup jobs purge unattached blobs (ActiveStorage::Blob.unattached...purge_later, check config/recurring.yml, config/schedule.rb, cron), object-store lifecycle rules do the same invisibly, and an attacker can attach-then-purge to erase the database evidence. An empty scan is evidence, not proof. If the window matters, sweep sooner rather than later.

05 Remediation

01 Upgrade Active Storage (and check libvips)

The fix is in Rails 7.2.3.2, 8.0.5.1 and 8.1.3.1. Pin and update:

gem "rails", "~> 8.1.3.1"
bundle update rails --conservative

--conservative keeps the bump minimal; share the lockfile diff in review like any other deploy.

Now the part that bites people: the patch works by blocking libvips' untrusted operations, which only libvips 8.13 or later supports. On anything older, patched Active Storage *refuses to boot* (with ruby-vips ≥ 2.2.1 installed), rather than quietly run unsecurable. Fail-closed, which is correct, but check before deploy night:

dpkg -l | grep libvips

Want 8.13+. Debian/Ubuntu images that lag will need the library upgraded alongside the gem.

02 Rotate everything the app process could read

This is the step that isn't optional. The bug's whole purpose is reading files as the Rails process, so assume anything it could read is exposed: secret_key_base, the master key (config/master.key / RAILS_MASTER_KEY) and everything in config/credentials.yml.enc, your storage service credentials (S3/GCS/Azure), database credentials, and third-party tokens.

EDITOR="nano" bin/rails credentials:edit

Rotate, redeploy, done. Changing secret_key_base expires sessions, signed cookies, signed global IDs and existing Active Storage URLs fleet-wide, so plan the mass re-login rather than discovering it.

03 Stopgap if you can't upgrade today

On libvips ≥ 8.13 you can switch the block on yourself, via the environment:

VIPS_BLOCK_UNTRUSTED=1 bundle exec rails server

libvips reads VIPS_BLOCK_UNTRUSTED at init; with ruby-vips ≥ 2.2.1, Vips.block_untrusted(true) in an initializer does the same. A stopgap, not a substitute: patch anyway.

On libvips < 8.13 the only workaround is removing ruby-vips from the Gemfile entirely (fine if it was only there for analysis, or the app doesn't use Active Storage). And a word on WAFs: the reporters themselves rate WAF filtering as "extremely limited" here. It might buy time in specific setups; it is not a fix.

06 Verification

Patched? Let's prove it rather than assume it.

The nicest signal is the fail-closed boot guard: if the app boots cleanly on the patched release with ruby-vips present, the untrusted-operation block is active. If libvips or ruby-vips is too old, it tells you so loudly at boot instead of letting you find out from someone else.

For a definitive check, the public PoC doubles as the verifier. The Zer0SumGam3 repo ships a full Docker lab (./run_lab.sh 8.1.3 vs ./run_lab.sh 8.1.3.1) and documents the expected differential: on a vulnerable target the run ends in ARBITRARY_ENV_READ_RESULT=CONFIRMED, while on 8.1.3.1 the crafted representation request returns environment_representation_http=500, no pixels come back, and no callback fires. Run it against your staging environment (the driver refuses non-loopback targets by default; leave that rail alone unless it's your own box and you're authorised).

Then sweep for what happened before the patch: the kr2s_scan_active_storage_blobs.rb scanner from the Detection section, over the full window the vulnerable version served traffic.

07 Rollback

Fair warning: the patch is a documented breaking change. Variant transformation of BMP, ICO and PSD now raises Vips::Error, and analysis of those plus SVG, JPEG XL, JPEG 2000 and Netpbm no longer records width/height. If that breaks a real workflow, the supported escape hatches are trimming content types from config.active_storage.variable_content_types, or re-enabling one specific loader with Vips.block("VipsForeignLoadMagick", false), which the commit itself flags as dangerous. Attach, store and download are unchanged, so for most apps the blast radius is small.

Rolling Rails back below the fix just reopens the hole, and on libvips < 8.13 there's no silent middle ground. The one thing you must never roll back is the secret rotation: the patch closes the file read, but it does not un-leak a key that already walked out the door. The advisory is blunt about it: rotation is not a fallback, and you should not retain an exposed secret "just in case".

08 References

If I've got anything wrong here, or you've hit an edge case I haven't covered, do say: corrections and questions are always welcome :)

// Patched, or stuck?

Ship the fix. Prove it’s closed.

If this runbook doesn’t fit your stack, send the CVE — an operator writes a verified path, usually within a business day. Or contribute your own and get co-authored credit.

// Dispatch

New guides, straight to your inbox.