Tech14 min read

CVE-2026-66066 hits Rails apps that never generate a single thumbnail

IkesanContents

TL;DR

What happened Rails shipped 7.2.3.2, 8.0.5.1 and 8.1.3.1 on July 29, 2026 (July 30 JST) for CVE-2026-66066, CVSS 4.0 9.5. An unauthenticated attacker can read arbitrary files off the server, including the process environment holding secret_key_base and external service credentials.

Who is affected Any Rails app using libvips for Active Storage image processing (variant_processor = :vips, the default since load_defaults 7.0) that accepts image uploads from untrusted users.

The part people get wrong “Generating variants is not a separate requirement”, per the advisory. Metadata extraction on upload reaches the same loaders, so an app that never produced a thumbnail is still in scope.

What to do

  1. Upgrade activestorage to 7.2.3.2, 8.0.5.1 or 8.1.3.1
  2. Get libvips to 8.13+ and ruby-vips to 2.2.1+. Below either floor, Rails raises RuntimeError at boot
  3. Rotate secret_key_base, the master key and everything in credentials.yml.enc, S3/GCS/Azure keys, database credentials, third-party tokens

What breaks after the upgrade BMP, ICO and PSD variants raise Vips::Error. SVG, JPEG XL, JPEG 2000 and Netpbm stop recording width and height.

What to check Rails published a forensics repository on July 31 with a scanner. The rendered variant holds the stolen bytes as pixel values in your own object store, which is the strongest artifact left behind.


Ruby on Rails released 7.2.3.2, 8.0.5.1 and 8.1.3.1 in the early hours of July 30, 2026 JST, fixing CVE-2026-66066 in Active Storage.
The GitHub Advisory rates it Critical, CVSS 4.0 9.5, vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H.

It was reported by 0xacb, s3np41k1r1t0 and castilho of Ethiack, and RyotaK of GMO Flatt Security.
Ethiack calls it KindaRails2Shell, a nod to the WordPress Core wp2shell flaw from earlier in July, which I covered as pre-auth RCE in WordPress Core through the REST API batch route. wp2shell started from a dispatch misalignment inside Core; this one starts from what gets handed to an image processing library.

The attack chain was withheld when the advisory landed, but on July 31 Rails published a forensics resource and Ethiack published a technical writeup, so the path can now be traced. I verified the published conditions and the patch diff as far as they reproduce locally: libvips loader selection, the analysis difference between Rails 8.1.3 and 8.1.3.1, what breaks after the upgrade, and the boot failure condition. I did not reproduce the attack chain itself.

Test environment

ItemValue
MachineM4 Mac mini
OSmacOS 26.5.2
libvips8.18.4 (Homebrew)
ImageMagick7.1.2-29 (libvips delegate)
Ruby4.0.6
Rails8.1.3 (vulnerable) and 8.1.3.1 (patched)
ruby-vips2.3.0, plus 2.1.4 for the boot-check reproduction
Active Storagevariant_processor = :vips, left at the rails new default

Variant generation is not part of the affected conditions

The advisory lists only two conditions. The app uses libvips for Active Storage image processing, meaning config.active_storage.variant_processor = :vips. And it accepts image uploads from untrusted users.

The first is what load_defaults 7.0 sets, and no later default changed it. The app I generated with rails new still had :vips.

Right after those two conditions the advisory says “Generating variants is not a separate requirement”. Variant generation is not part of the conditions, so an app that has never produced a single thumbnail is still in scope.

The reason is visible in the published code. Active Storage has an allowlist of transformable formats in config.active_storage.variable_content_types, and variant generation goes through it. The analyzer that extracts metadata on upload does not: ActiveStorage::Analyzer::ImageAnalyzer decides with blob.image? alone.

def self.accept?(blob)
  blob.image?
end

If the declared Content-Type starts with image/, the analyzer accepts the blob and hands the bytes to libvips. There is no allowlist narrowing on this path. The patch commit message says the same thing: “both the Vips analyzer and the Vips transformer passed untrusted attachments to them”.

Affected gemFixed version
activestorage < 7.2.3.27.2.3.2
activestorage >= 8.0, < 8.0.5.18.0.5.1
activestorage >= 8.1, < 8.1.3.18.1.3.1

Rails 6.x is out of support but has the same problem where :vips was set explicitly. The 6.x default was :mini_magick, so default configurations are not affected.

The loaders libvips flags include formats unrelated to images

libvips has a loader and a saver per file format, and most of them are backed by third-party libraries. libvips flags the ones whose validation against hostile input is insufficient as “unfuzzed” or “untrusted”, meaning they are intended for trusted input only. Since 8.13 these flagged operations can be blocked wholesale at runtime, and Active Storage was not using that mechanism.

I listed the flagged operations. The flag appears as a literal string in vips -l output.

$ vips --version
vips-8.18.4
$ vips -l | grep -c untrusted
56

These were the flagged formats on the load side. Which ones are present varies by build configuration, so this breakdown is specific to the Homebrew build.

LoaderFormatsBacking library
magickloadBMP, ICO, PSD and other formats delegated to ImageMagickImageMagick 7
svgloadSVGlibrsvg
pdfloadPDFpoppler
jxlloadJPEG XLlibjxl
jp2kloadJPEG 2000OpenJPEG
dcrawloadCamera RAW formatsdcraw family
openexrloadOpenEXROpenEXR
ppmloadNetpbm (PBM, PGM, PPM, PFM, PNM)libvips built-in
radloadRadiance HDRlibvips built-in
openslideloadPathology slide formatsOpenSlide
matloadMATLAB .matmatio
fitsloadAstronomy FITScfitsio
analyzeloadAnalyze6 medical imaginglibvips built-in
csvloadCSVlibvips built-in
rawloadHeaderless raw datalibvips built-in
vipsloadlibvips native .vlibvips built-in

On the save side, magicksave, jxlsave and fitssave carried the flag.

Ordinary web images occupy the top few rows. The rest are production and scientific image formats such as camera RAW, OpenEXR, Radiance HDR and Netpbm, plus MATLAB matrix files, observatory FITS, medical imaging, pathology slides and CSV. Formats a web app never expects to receive share the same entry point.

Renaming 15 formats to .png

I checked format by format whether filtering on extension or Content-Type is enough. Formats libvips can write were produced with libvips, ICO and PSD with ImageMagick, and SVG, CSV and PDF as text. The bytes were left untouched and only the filename was changed to .png.

$ cp src.svg fake_svg.png
$ vips copy fake_svg.png out.v
$ vipsheader -a out.v | grep vips-loader
vips-loader: svgload

I repeated this for 15 formats and recorded which loader ran, plus the result with VIPS_BLOCK_UNTRUSTED set.

ContentLoader that ranFlaggedVIPS_BLOCK_UNTRUSTED=1
PNGpngloadnoloaded
JPEGjpegloadnoloaded
WebPwebploadnoloaded
TIFFtiffloadnoloaded
BMPmagickloadyesblocked
ICOmagickloadyesblocked
PSDmagickloadyesblocked
SVGsvgloadyesblocked
PDFpdfloadyesblocked
JPEG XLjxlloadyesblocked
JPEG 2000jp2kloadyesblocked
Netpbmppmloadyesblocked
Radiance HDRradloadyesblocked
FITSfitsloadyesblocked
CSVload failed outrightyesblocked

Every file was named .png, and the loader that ran still split along the actual contents. libvips decides on leading bytes: 3c 73 76 67 (<svg) for SVG, 25 50 44 46 (%PDF) for PDF, 42 4d (BM) for BMP. The filename is obviously attacker-chosen, and with Active Storage direct uploads the declared Content-Type is recorded exactly as the client sends it. Filtering on those two cannot reach the contents.

CSV was the exception. Under a .png name the load failed outright, and only the correct name src.csv got it to csvload. CSV has no leading bytes usable for detection, so some loaders are unreachable through content sniffing.

The four unflagged formats still loaded with VIPS_BLOCK_UNTRUSTED set. The fix stops only the flagged operations; image processing itself keeps working.

The attack chain starts at matload, the MATLAB loader

On July 31 Rails published a forensics repository and Ethiack published a technical writeup of the chain. The entry point was the one loader in the table above that is not an image format at all.

Per the Rails writeup: Active Storage records the content_type the client declared on a direct upload without inspecting the bytes. libvips then reads the leading bytes and routes anything starting with MATLAB 5.0 to the MATLAB loader. But libmatio, one layer down, decides the format from the version word at offset 124 instead of the head. If that says MAT v7.3, which is HDF5, libmatio treats the file as HDF5.

HDF5 has an External File List, which lets a dataset’s body live in another file addressed by path and offset. libmatio opens it without validation, so rendering the “image” turns into reading an arbitrary file off the server. The bytes come back as pixel values in the variant. Rails describes this in its own README as the same confusion happening twice, at two layers that cannot see each other’s fields.

Loader selection can be confirmed locally without anything harmful. I wrote a 128-byte header starting with MATLAB 5.0, named it .png, and had libvips read it. No external references and no data.

$ file probe_mat.png
probe_mat.png: Matlab v5 mat-file (little endian) version 0x100
$ vips copy probe_mat.png out.v
mat2vips: no matrix variables in "probe_mat.png"
$ VIPS_BLOCK_UNTRUSTED=1 vips copy probe_mat.png out.v
VipsForeignLoad: "probe_mat.png" is not a known file format

mat2vips going off to look for variables shows the MATLAB loader was reached under a .png name. With blocking on, the format itself became unknown and libmatio was never reached. The Vips.block_untrusted(true) that Rails 8.1.3.1 added disables the first stage of this chain.

Per Ethiack, the step from arbitrary file read to RCE involves reading secret_key_base and combining it with a separate Active Storage variant method injection CVE. I did not verify that far.

flowchart TD
    A["Attacker uploads a crafted file<br/>declared as image/png"] --> B["ActiveStorage stores the blob"]
    B --> C["ImageAnalyzer.accept? checks<br/>blob.image? only"]
    C --> D["libvips reads leading bytes<br/>and picks a loader"]
    D --> E["A flagged loader runs"]
    E --> F["Arbitrary file read"]
    F --> G["secret_key_base and credentials<br/>in the process environment"]
    G --> H["RCE and lateral movement"]

The “the allowlist is enough” call was made in May 2026

The patch commit message records that image_processing 2.0 blocks the flagged operations when it loads, and that Active Storage re-enabled them. That commit is 1fc6efcd, dated May 20, 2026.

Allow unfuzzed libvips loaders

This keeps backwards compatibility, since Active Storage already has an
allowlist of "variable" formats.

The reasoning is that backwards compatibility can win because there is an allowlist of transformable formats. But that allowlist is not applied on the analyzer path, which is the blob.image?-only check above.

The contents of the allowlist have the same problem. The defaults in activestorage/lib/active_storage/engine.rb include image/bmp, image/vnd.adobe.photoshop and image/vnd.microsoft.icon. As the table above shows, all three map to magickload. Three of the formats on the allowlist were being handed straight to a flagged loader.

That 1fc6efcd is a change on the 8.2 development branch (edge Rails); the released 7.2, 8.0 and 8.1 lines do not use image_processing 2.0. On those branches nothing ever blocked the flagged operations in the first place.

The fix calls block_untrusted at boot

All three lines got the same patch, written by Mike Dalessio. A new file activestorage/lib/active_storage/vips.rb blocks the flagged operations at boot whenever ruby-vips is available.

unless Vips.respond_to?(:block_untrusted)
  raise <<~ERROR.squish
    libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
    cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or
    later. Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile.
  ERROR
end

Vips.block_untrusted(true)

Calling the same thing directly from ruby-vips reproduces both sides of the fix.

require "vips"
Vips::Image.new_from_file("fake_svg.png").width   #=> 123 (svgload runs)
Vips.block_untrusted(true)
Vips::Image.new_from_file("fake_svg.png")         #=> Vips::Error
Vips::Image.new_from_file("base.png").width       #=> 40 (pngload still works)

Where Vips.block_untrusted is not implemented, meaning libvips below 8.13 or ruby-vips below 2.2.1, that raise stops boot. It is there to keep the app from running on with an unclosable hole.

Rails 8.1.3 vs 8.1.3.1 on the same attachment

Everything above is libvips on its own, so I checked whether the same thing happens in a real Rails app. I added Active Storage to a rails new app and attached a file whose contents are SVG, whose name is fake_svg.png, and whose declared Content-Type is image/png. No variant was ever requested; I called blob.analyze and nothing else.

blob = ActiveStorage::Blob.create_and_upload!(
  io: File.open("fake_svg.png"), filename: "fake_svg.png", content_type: "image/png")
blob.analyze
blob.reload.metadata

Swapping only the rails line in the Gemfile and running both:

Attachment contentsblob.image?ImageAnalyzer.accept?metadata on 8.1.3metadata on 8.1.3.1
SVGtruetruewidth 123, height 45nothing recorded
BMPtruetruewidth 40, height 30nothing recorded
PNGtruetruewidth 40, height 30width 40, height 30

On 8.1.3, a file whose contents are SVG recorded 123x45. The PNG source is 40x30, so that value belongs to the SVG itself, which means svgload actually ran. In an app that never requested a variant, an uploaded file was processed straight through by a flagged libvips loader.

blob.image? and ImageAnalyzer.accept? returned true for all three attachments on both versions. What the published code says about the analyzer not narrowing by format holds in a running app too.

On 8.1.3.1 the same attachments recorded no dimensions and only PNG got through. I have not traced the attack chain, but this difference confirms the entry point is closed.

Some environments stop booting after the upgrade

The libvips 8.13 floor is newer than what the major distributions ship. Environments using the packaged libvips as-is include combinations that do not meet the requirement.

EnvironmentlibvipsAfter the upgrade
Debian 11 bullseye8.10.5RuntimeError at boot
Ubuntu 20.04 focal8.9.1RuntimeError at boot
Ubuntu 22.04 jammy8.12.1RuntimeError at boot
Debian 12 bookworm8.14.1meets the requirement
Ubuntu 24.04 noble8.15.1meets the requirement
Debian 13 trixie8.16.1meets the requirement

The local libvips is 8.18.4 and clears the floor, so I dropped ruby-vips to 2.1.4 instead and hit the same check. Not having Vips.block_untrusted defined is treated the same as libvips below 8.13.

$ bin/rails runner 'puts "booted"'
.../activestorage-8.1.3.1/lib/active_storage/vips.rb:36:in '<top (required)>':
libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or later.
Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile. (RuntimeError)

rails runner stopped at boot. It is an exception rather than a warning, so on this configuration the app stops coming up the moment you upgrade. Ubuntu 22.04 LTS stays in standard support until April 2027. There is no public data on how many apps run this combination, and the Rails Security Team has said it has no reasonable estimate. Upgrading Rails does not raise the libvips version, so a libvips backport, a switch to a custom build, or a container base image update comes along separately.

Below 8.13 there is no workaround at all. The advisory says that when libvips is below 8.13, the only option is removing the libvips dependency from the app. If ruby-vips is there for image analysis rather than image processing, dropping ruby-vips from the Gemfile is enough.

Apps on :mini_magick are affected too. How attachments get processed does not change, but as long as ruby-vips is installed the block applies process-wide, and the libvips and ruby-vips version floors still apply. Removing ruby-vips from the Gemfile avoids both.

BMP, ICO and PSD variants break after the upgrade

The fix includes a breaking change. Per the CHANGELOG, variant generation for BMP, ICO and PSD raises Vips::Error. SVG, JPEG XL, JPEG 2000 and Netpbm no longer record width and height when analyzed. Requesting FITS, JXL or any ImageMagick-delegated output format also raises Vips::Error. Attaching, storing and downloading are unchanged.

Attaching a BMP on the 8.1.3.1 app and requesting a variant did exactly that.

b.variable?                                  #=> true
b.variant(resize_to_limit: [20,20]).processed #=> Vips::Error
# the same operation on a PNG succeeds

variable? stayed true. BMP is still on the Active Storage allowlist, so the blob is treated as transformable and libvips stops it at execution time. Code branching on variable? runs without reflecting the change and then fails.

On the libvips side, a format whose loader has been stopped is treated as an unknown format.

$ vips copy colors.bmp out1.png
$ VIPS_BLOCK_UNTRUSTED=1 vips copy colors.bmp out2.png
VipsForeignLoad: "colors.bmp" is not a known file format

The error text is identical to what a build missing that format produces. A post-upgrade report of “BMP stopped loading” and a report caused by an incomplete libvips build cannot be told apart from this message alone.

For apps that generate variants synchronously during a request, this Vips::Error surfaces as an error response rather than a failed job. The CHANGELOG covers dropping the relevant Content-Types from the allowlist in that case.

Rails.application.config.active_storage.variable_content_types -=
  %w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]

With that setting, Active Storage stops treating those three formats as transformable and never attempts the conversion.

Stopgaps when you cannot upgrade right away

Where libvips is 8.13 or newer, the flagged operations can be blocked without upgrading Rails. libvips reads the VIPS_BLOCK_UNTRUSTED environment variable when it initializes, so setting that variable closes the hole. With ruby-vips 2.2.1 or newer you can call Vips.block_untrusted(true) from an initializer instead. Either way the BMP, ICO and PSD breaking change applies at the same time.

If a specific loader is genuinely required, it can be re-enabled individually in an initializer. Blocking everything and then allowing SVG left SVG readable while BMP stayed blocked.

Vips.block_untrusted(true)
Vips.block("VipsForeignLoadSvg", false)
Vips::Image.new_from_file("fake_svg.png").width  #=> 123
Vips::Image.new_from_file("fake_bmp.png")        #=> Vips::Error

The granularity works, but a re-enabled loader has to be kept away from anywhere untrusted input reaches it. The libvips ImageMagick loader changes name with the major version it was built against, which is why the Rails tests pass both VipsForeignLoadMagick and VipsForeignLoadMagick7. Naming only one of them leaves the unblock ineffective on builds using the other.

Whether a WAF (a defensive layer that inspects HTTP requests and blocks attack patterns) can detect or block this depends heavily on application configuration, including the Active Storage service and the upload path. GMO Flatt Security calls its effectiveness extremely limited and asks that it not be treated as a substitute for upgrading.

Rotating secrets is separate from upgrading

The advisory says to treat every secret readable by the application process as potentially exposed and to rotate it. Upgrading closes the path; already-exposed values get replaced separately.

That covers secret_key_base, the master key in config/master.key or RAILS_MASTER_KEY along with everything it decrypts in credentials.yml.enc, storage service keys for S3, GCS or Azure, database credentials, and tokens and keys for any third-party service the app calls.

Changing secret_key_base expires active sessions and forces users to log in again. Encrypted cookies, signed cookies, signed Global IDs and Active Storage URLs are affected as well. That side effect makes it tempting to keep the old value as a rotation fallback, but the advisory is explicit that rotation is only an intermediate step where necessary and that an exposed secret must not stay in the fallback.

Material for checking whether you were exploited went public with the July 31 forensics repository. The advisory itself carries no IoCs (indicators of compromise, the traces you search for), but the repository has both the method and a scanner that actually runs.

The same repository explains why traces survive. The bytes that were read come back as pixel values in the variant, so the generated variant sits in your own object store. With the exfiltrated content parked in your own bucket, Rails calls this the strongest of the artifacts left behind.

Detection identifies the crafted MAT file from two header fields in the first 128 bytes of an object. It classifies from a ranged read rather than downloading the whole file, which is what makes it runnable against a production bucket. The repository has two procedures, one producing the window during which you were vulnerable and one answering whether it was used, and the second depends on the first one’s output. A payload generator is deliberately not included.

Rotating secret_key_base ahead of that answer is a defensible call, because the exposure if it was read is broad and the check itself takes time.

References: