A technical deep-dive into an arbitrary file read to RCE chain in Ruby on Rails applications.
Ruby on Rails runs a large slice of the modern web: GitHub, Shopify, Basecamp, and a very long tail of self-hosted products and open-source projects. When we published the initial advisory for KindaRails2Shell (CVE-2026-66066), we withheld the technical details. Some PoCs are already landing, and Rails just published a repository with technical details and forensic tooling, so we are now also publishing our analysis: the root cause, the file-format trick that makes it work, the ActiveStorage behaviors that route attacker bytes into it, and the read primitive that turns a thumbnail into an oracle for any file, and then into remote code execution.
This is also the story of how we found it. Inspired by wp2shell, we were seeking a Rails2Shell and we started from an old idea from previous research: libvips, used by Ruby on Rails by default, loads a lot of weird image formats, including Matlab files. Here’s an overview of the chain:
a single malformed upload escalates from arbitrary file read to remote code execution as root: five steps, no authentication requiredRuby on Rails processes far more than regular images
If a Rails app lets a user upload an avatar and shows a thumbnail back, it’s almost certainly running libvips. Since Rails 7, the default ActiveStorage variant processor is vips, and the official Rails Docker images do apt install libvips on a Debian base by default. Alternatively, Rails may be configured to use ImageMagick instead, which is not affected by this specific vector.
The thing is how many formats libvips can open, and it's not just regular images (PNG/JPEG/WEBP/GIF). Depending on how it was built, libvips will also load PDFs (via poppler), SVG (via librsvg), FITS astronomy images, NIfTI medical scans, OpenSlide pathology slides, Radiance HDR, and MATLAB .mat files.
Libvips picks its loader by sniffing the bytes, and not by trusting a filename or a declared MIME type. If Vips::Image.new_from_file processes some bytes, it will run each loader's detector until one says "hey, that's mine." However, libvips flags a bunch of formats as untrusted, which is a good security control - one that ActiveStorage did not use by default.
So we pointed Claude Opus 4.8 at these loaders to dig deep on arbitrary file read (AFR) and arbitrary file write (AFW) primitives (btw for more information on some neat techniques on AFWs, check out our upcoming DEFCON Bug Bounty Village talk: Write Once, Shell Everywhere: Turning Arbitrary File Writes into RCE).
The root cause: HDF5 external datasets
This is the behavior that makes everything else work. A modern MATLAB file, MAT v7.3, is not a specific format at all. It is an HDF5 file, and HDF5 has a feature called external storage. When you create an HDF5 dataset, you can tell that the dataset's raw bytes don't live inside the file, they live in another file, at an arbitrary path, at some offset.
This is a known, legitimate HDF5 feature (H5Pset_external) meant for datasets too big to inline. The dataset's creation property list carries a list of (filename, offset, size) entries, and when you read the dataset, HDF5 transparently opens those files and reads from them. Libmatio, the Matlab library used by Libvips, reads MAT v7.3 variable data with a H5Dread call. In src/mat73.c:
The only storage validation libmatio does is a check for chunked datasets (to detect truncation). It never calls H5Pget_external_count(), so it never checks if a dataset comes from an arbitrary file like /etc/passwd. H5Dread opens the external file, and copies its bytes into the variable's data buffer.
That's the primitive: any application that opens an attacker-supplied .mat, and then reads a variable, performs an intended arbitrary file read.
It's possible to craft one in a few lines of Python with h5py:
This class of bug, HDF5 external storage as an intended arbitrary file read primitive, has surfaced recently in ML model loaders (Keras and TensorFlow both had variants). We found no prior report of it in libmatio’s existing CVEs are all memory-corruption and DoS. We found no prior report of this behaviour at the time of our research, but we’ve now learned that Abdelmounaim Moulahcene (bl0rph) published Leaky Avatar on 18 July 2026, independently reaching matload with an HDF5 external dataset and reading /proc/1/environ out of a resized avatar. We weren’t aware of it while doing this work. We are currently investigating if Claude accessed this.
But a file read in a C library almost nobody calls directly isn't that interesting. The question is: can we trigger it from a web request?
The MATLAB 5.0 / 7.3 confusion
Here's the first issue, a detail that got us mindblown. Libvips decides "this is a MAT file, send it to libmatio" using a content sniffer, vips__mat_ismat. We tested every version string, and it turns out that sniffer only accepts files whose header begins with the exact literal ASCII string MATLAB 5.0. MATLAB 7.3, MATLAB 7.0, MATLAB 6.0 are all rejected.But our arbitrary file read primitive needs the file to be HDF5 (MAT v7.3), because external datasets are an HDF5 feature. MAT v5 is an entirely different, non-HDF5 binary format with no such capability. So we have a disagreement:
- libvips will only route the file to libmatio if the header text says MATLAB 5.0.
- libmatio will only give us external datasets if the file is actually HDF5 (v7.3).
Here’s a tweet I posted previously that illustrates this scenario:
@0xacb tweet on Two components read the same input and reach different conclusionsSo, two libraries check on different fields of the same header. A MAT file header is 128 bytes. libvips's sniffer looks at the text description at offset 0. libmatio, on the other hand, dispatches on the 2-byte version word at offset 124: 0x0100 means "MAT v5, use the classic parser". 0x0200 means "use the HDF5 backend."
One file, two readers, one disagreement. libvips sees a MAT header and passes it through. libmatio sees an HDF5 version word and reads the external file.So we can build a file that lies in exactly one place:
Libvips sniffs MATLAB 5.0, selects matload, hands the bytes to libmatio, and libmatio reads them as HDF5 and follows the external reference straight to /etc/passwd.
The MATLAB 5.0 string is mandatory for this vector. We tried loading the same HDF5 body with a MATLAB 7.3 header, with the string moved, with extra whitespace, every variant fails libvips's sniffer and never reaches libmatio. This specific vector doesn’t work without those first ten bytes.
ActiveStorage content-type confusion
We can craft a file that reads /etc/passwd when libvips opens it and now we need Rails to open it. ActiveStorage decides whether a blob can be turned into image variants using Blob#variable?, which checks the blob's content_type against an allowlist (config.active_storage.variable_content_types, which includes image/png, image/jpeg, etc.):
Note what that checks: the declared content_type, which is a string. And where does that string come from on the direct-upload path? Straight from the client:
create_before_direct_upload! writes the attacker-supplied content_type into the database as is. There is no re-identification of the actual bytes. So an attacker can:
- POST /rails/active_storage/direct_uploads with content_type: "image/png" and the MD5 checksum of their .mat payload
- PUT the .mat bytes to the returned storage URL
The blob is now, as far as Rails is concerned, an image/png - variable? returns true, but its bytes are our crafted MAT file. When the app generates a variant, ActiveStorage downloads the blob to a tempfile and calls libvips on it.
ActiveStorage trusts the declared label and treats it as an image.So there are two systems looking at the same blob: ActiveStorage trusts the declared type, libvips trusts the bytes. The attacker controls both sides of that disagreement.
Variation keys as a file read oracle
At this point the app will, somewhere, run libvips on our blob and produce a thumbnail. We can read /etc/passwd into the image. But we need to get those exact bytes back. The common place is the ActiveStorage representations production route, which turns an uploaded blob + a transform into a processed image:
The variation_key is a signed token. You might assume you'd need to already have a valid one for your own blob, but here's the important property: a variation key signs only the transform, not the blob. Decoded, it's just {"resize_to_limit":[1024,768]} (or similar) plus purpose:"variation", wrapped in an HMAC over secret_key_base.
That means a variation key is blob-independent and replayable and is intended to be public. Any signed key the application has ever emitted, in a page's HTML, in an og:image meta tag, in an API response, sitting in a years-old snapshot on the Internet Archive, can be replayed against a different signed blob id. So the attacker harvests one legitimate representation URL from a target response (or from other sources, like the Wayback Machine, logs, etc), keeps the variation_key, and uses it with the signed id of the blob they just uploaded. That was one of the leads we already had from past research.
The app thumbnails our .mat, and the returned PNG's pixels are the bytes of the file we pointed at. But there's one catch. A resizing transform (resize_to_limit) runs an interpolation that blends neighbouring pixels, corrupting a straight byte read. The workaround is kinda funny: read one byte per request using a 1×1 image (external(target, offset=k, size=1)). A 1×1 image has no neighbours to blend, so the single pixel comes back byte-exact. Parallelize across offsets, and you can stream an arbitrary file out through thumbnails. Past-EOF reads come back as zero-fill, so you can auto-detect file length.
A thumbnail generator turned file-read oracle. Feed it any file, read the output pixel, one byte at a timeWe then realized that we can leverage more techniques, such as unsharpening filters, that allow us to exfiltrate more bytes at once via a single image. We now have an unauthenticated or low-privilege arbitrary file read of any file the image worker can access.
You don't always need a harvested variation key
The signed-URL route is the way to hit a target that never renders your uploaded blob back to you, but it isn't the only trigger.
- Attach-then-render. Many apps run the variant synchronously on upload/attach (process: :immediately on the variant declaration, or an after_create that calls attachment.representation(:thumb).processed).
- App-controlled transform endpoints. Any custom controller that does blob.variant(fixed_transform).processed and returns the pixels reaches libvips without a variation key. The transform is hardcoded server-side and the attacker can supply the blob.
- Analyzers and background jobs. ActiveStorage::Analyzer::ImageAnalyzer::Vips calls libvips at analyze time to extract dimensions. Any codepath that eventually fires an analyzer on an untrusted blob (upload hooks, previewers, ActionText attachments) is another trigger that allows extracting bytes via metadata.
The variation key harvest is what makes the primitive fully unauthenticated on a stock app with no custom code.
From file read to shell
A file read on a Rails app is, on its own, close to game over, because almost every secret lives on disk or in the environment or in the process memory. The cleanest path to arbitrary code execution goes through secret_key_base:
1. Read config/master.key and config/credentials.yml.enc via the AFR. Decrypt the credentials with AES-GCM and pull out secret_key_base. If the app is deployed with SECRET_KEY_BASE or RAILS_MASTER_KEY in the environment instead, read /proc/self/environ. If it’s not in the environment, we can also read /proc/self/maps and then /proc/self/mem.
2. Forge a malicious variation. With secret_key_base, we can sign our own variation keys. The verifier key is PBKDF2-HMAC-SHA256 (secret_key_base, "ActiveStorage", 1000, 64); we sign a transform of our choosing. And the transform we choose is where the second bug comes in.
This is CVE-2025-24293 (ActiveStorage variant method injection), but with an incomplete-fix twist. ActiveStorage's transformers are supposed to restrict which methods a transform can call. The allowlist (ActiveStorage.supported_image_processing_methods) is enforced in the ImageMagick transformer, but the vips transformer inherits the base validate_transformation, which only blocks combine_options. So on the default :vips stack, an arbitrary transform key becomes:
Set name to instance_eval and args to a string of Ruby, and you have arbitrary code execution. We can forge something like:
Send it to the default representations route, then read the output file back through the same AFR. Please note that other gadgets most likely exist. Our PoC was able to perform arbitrary file read, to secret disclosure, to remote code execution:
kindarails2shell-pocPreconditions
To recap, the chain fires when:
- ActiveStorage uses variant_processor = :vips - the Rails 7+ production default.
- libvips is built with libmatio and matio is recent enough to parse the crafted file. Debian/Ubuntu libvips42 links libmatio13 (1.5.28+); the official Rails Docker image and most stock installs qualify.
- libvips does not block untrusted loaders. ActiveStorage did not set VIPS_BLOCK_UNTRUSTED or Vips.block_untrusted on its own. A recent enough image_processing gem does.
- A trigger for libvips exists. A harvestable variation key on the default representations route, or any of the app-side patterns from the section above.
This is a smaller set than "all Rails apps," but a very real one. It's the default configuration of multiple shipped products, several of which we confirmed end-to-end. We named it KindaRails2Shell because of these preconditions, it isn't an unauthenticated one-shot against every Rails install, but it is very much a real AFR to RCE against a common Rails usage pattern that lands on real targets.
The Fix
Rails / ActiveStorage. The most effective, self-contained fix was to block untrusted loaders. libvips already flags matload as untrusted and exposes the switch, ActiveStorage should simply flip it whenever it uses the vips processor:
> Rails patch: https://github.com/rails/rails/commit/1c01bb58…
This kills reachability to matload (and svgload, fitsload, niiload, radload, openslideload), the AFR primitive can no longer fire through ActiveStorage. It also has real side effects: transformation of BMP, ICO, and PSD now raises Vips::Error, and image analysis of SVG, JPEG XL, JPEG 2000, and Netpbm stops recording width/height.
Final remarks
We had done prior manual work researching image pipelines and kept coming back to libvips's list of exotic loaders, the untrusted ones and the scientific formats. We injected these leads into Claude Opus 4.8 (1M context) and steered it, giving ideas, for more than 24 hours, until we combined all the pieces together. Then, we got completely mind-blown when we got informed that another researcher, @ryotak, found a very similar chain with the new Claude Opus 5 a few days later, without any special guidance.
Claude helped on a significant amount of the work. It worked out the HDF5 external-dataset primitive, wrote the h5py payload generator, built the byte-exact read, and tested everything against a local rails production Docker image, reporting back which cases hit RCE and why the others didn't.
We're going to see more bugs like this surfacing in the near future. AI already changed the game, and it's good that we're finding and patching these ahead of malicious actors.
If you're a defender and want to know whether you were vulnerable or exploited: the Rails team published a toolkit at rails/rails-forensics-CVE-2026-66066.
Timeline
2026-07-20: Claude Opus 4.8 launched
2026-07-21: Initial working PoC
2026-07-22: Reported to Ruby on Rails
2026-07-23: Acknowledged by Rails
2026-07-29: Fixed in ActiveStorage 8.1.3.1 / 8.0.5.1 / 7.2.3.2
2026-07-29: CVE-2026-66066 assigned
2026-07-30: Rails publishes technical details and forensic tooling
How can Ethiack help?
Ethiack provides continuous testing across your attack surface using agentic AI pentesting. We stay ahead of attackers, validate new CVEs quickly, and confirm exploitability with proof-of-exploit, not scanner noise. See your exposure. Test continuously. Act on what matters. Research by 0xacb, s3np41k1r1t0, castilho and the Ethiack Research Team. Thanks to RyotaK from GMO Flatt Security for their independent discovery and close collaboration on this disclosure. Also credit to bl0rph for finding the primitive a couple of days before us.and to the Rails maintainers, especially Mike Dalessio (flavorjones), and the Libvips maintainers, for their help and quick response in getting a fix shipped.
