Two Parsers, One File, Zero Agreement: The Attack Surface Behind CVE-2026-66066
Rails' Active Storage let an attacker-labeled image decide its own format twice — and the second answer was HDF5

Two parsers, one file, zero agreement
Here's the question worth asking whenever a framework bolts on "free" image processing: if the web layer checks a file's label, and a native image library checks its bytes, who actually decides what the file is? Rails' Active Storage answered that question the hard way in CVE-2026-66066, and the answer is a genuinely new-shaped attack surface — not just a bad content-type check.
Scores as of 2026-08-07live record →
The surface nobody drew a box around
Most "file upload security" reviews stop at the web layer: check content-type, sniff magic bytes, maybe run an AV scan. But the moment that upload gets handed to a native image library for thumbnailing, a second parser stack opens up — one your web framework never audits, and one the image library's own maintainers didn't fully trust either. If the HTTP request is layer 0 and full remote code execution is layer 100, the arbitrary file read in CVE-2026-66066 sits around layer 40: it never touches a line of your application code, because it lives entirely inside the pipeline everyone assumes is "just resizing a picture."
Upload to arbitrary file read
- Direct-upload controller
- content_type stored, bytes unchecked
- libvips sniff: first 10 bytes
- libmatio parses real version (bytes 124-125)
- MAT 7.3 → HDF5 external dataset
- H5Dread() pulls arbitrary file bytes
- Bytes rendered back as PNG pixel data
- Attacker downloads the "variant"
Layer 1 — the label lies for free
The first trust failure is unremarkable: Active Storage's direct-upload controller accepts the content_type straight off the multipart request and writes it onto the blob record without opening the file. Downstream, Blob#variable? only checks that stored string against an allow-list — so labeling a malicious payload image/png is enough to get it into the variant-generation pipeline. Nothing exotic here, it's the same content-type trust gap that's shown up in a dozen unrelated upload bugs. It's just the first domino.
Layer 2 — the sniff lies too, just differently
The second lie is the interesting one, because it isn't a gap — it's a disagreement between two programs that each believe they're the authority on the file's format. When libvips decides whether a file is a legacy MATLAB .mat container, per the GitHub advisory and Rapid7's analysis it checks exactly ten bytes for the string MATLAB 5.0. But loading a MAT file doesn't stop at libvips: it hands off to libmatio, which determines the real format from an entirely different location — the version field at bytes 124–125. Craft a file where the first ten bytes say "harmless MATLAB 5.0" and bytes 124–125 say "MAT 7.3," and you've built something that passes libvips' sniff test while being parsed as a format libvips never screened at all.
| Layer | What it checks | What it concludes |
|---|---|---|
| Blob record | Client-supplied content_type header | "image/png" — trusted, no bytes read |
| libvips loader dispatch | First 10 bytes of the file | "MATLAB 5.0" — a safe, legacy format |
| libmatio parser | Version field at bytes 124-125 | MAT 7.3 — an HDF5 container instead |
Layer 3 — HDF5 turns "parse a MAT file" into "read any file"
MAT 7.3 isn't really a MATLAB-specific format — it's HDF5 wearing a MATLAB header. HDF5 datasets can point at external storage: a separate file, at an attacker-chosen path and byte offset, pulled in as if it were part of the dataset. Rapid7's analysis found that libmatio's read path calls HDF5's H5Dread() without validating those external-storage entries. So the "MATLAB variable" Active Storage extracts and renders back into a derived PNG isn't matrix data at all — it's the raw bytes of whatever file the attacker pointed at. Read /proc/self/environ this way and the response image is your environment variables, styled as pixels.
From file read to shell: the second, separate bug
GitHub's advisory treats the arbitrary file read as the vulnerability and stops there — full technical detail was deliberately withheld until enough of the Rails install base had a chance to patch. Rapid7's independent analysis picked up from that point and validated a second, distinct bug that turns the read into a shell.
// Illustrative payload structure from Rapid7's public technical analysis — not independently reproduced here.
{"send": ["spawn", "/bin/sh", "-c", "id"]}
{"send": ["eval", "File.write(...)"]}The path Rapid7 documented requires an app configured with config.active_support.message_serializer = :json, common on Rails 7.1+. Under that config, ImageProcessing's Vips transformation builder dispatches transformation names straight to builder.public_send(name, *argument), unfiltered. Rapid7 confirmed a transformation name of send reaches that call directly — meaning any JSON-compatible method name and argument list an attacker can smuggle into a signed variation payload gets invoked on the Vips builder object. No Marshal gadget chain, no deserialization exploit — just a JSON array with the wrong method name in it, once secret_key_base has been read and used to sign a new payload.
Why Vips is named and ImageMagick isn't
- Transformation names pass through validate_transformation before dispatch
- No MAT/HDF5 loader chain in the affected code path
- Not named as affected in GitHub's, Rapid7's, or Akamai's advisories
- Transformation name goes straight to builder.public_send(name, *argument)
- Inherits every "unfuzzed" libvips loader, including the MAT/HDF5 chain
- The only processor named as vulnerable across every source reviewed
The patch, and why it needs libvips to cooperate
| Branch | Vulnerable range | Fixed version | Extra requirement |
|---|---|---|---|
| 7.2.x | < 7.2.3.2 | 7.2.3.2 | libvips ≥ 8.13, or boot raises an exception |
| 8.0.x | ≥ 8.0.0.beta1, < 8.0.5.1 | 8.0.5.1 | libvips ≥ 8.13, or boot raises an exception |
| 8.1.x | ≥ 8.1.0.beta1, < 8.1.3.1 | 8.1.3.1 | libvips ≥ 8.13, or boot raises an exception |
| 6.0–6.1 | 6.0.0–6.1.7.10, non-default config only | Upgrade to a patched line | Only exploitable if Active Storage config deviates from Rails defaults |
- Upgrade activestorage/Rails to 7.2.3.2, 8.0.5.1, or 8.1.3.1.
- Confirm libvips itself is ≥ 8.13 — older builds can't disable untrusted operations, and the patched gem refuses to boot rather than run silently exposed.
- If libvips can't be upgraded immediately, set VIPS_BLOCK_UNTRUSTED=1 or call Vips.block_untrusted(true) (ruby-vips ≥ 2.2.1) as a stopgap — the advisory is explicit that this is a mitigation, not a fix.
- Rotate secret_key_base, config/master.key, encrypted credentials, and every database/cloud-storage token the Rails process could read — upgrading the gem does not undo a read that already happened.
- If untrusted users can upload images and you can't upgrade yet, consider disabling automatic variant generation until you can.
Disclosure, accelerated
- Original full-disclosure dateRails maintainers had scheduled complete technical details for August 28, giving the install base a month to patch quietly.
- Plan scrapped — PoCs surfacePublic proof-of-concept exploits appeared quickly, so maintainers moved up disclosure and shipped full attack details plus a forensic investigation tool the same day, per Bleeping Computer and Akamai.
- GHSA-xr9x-r78c-5hrm published, patches taggedRails 7.2.3.2, 8.0.5.1 and 8.1.3.1 released alongside the advisory.
- CVE-2026-66066 lands in NVDPublished with GitHub's CNA-assigned CVSS v4.0 score of 9.5; NVD had not issued its own score as of this writing.
- Rapid7 publishes the RCE chain and a Metasploit moduleIndependent research (dubbed KindaRails2Shell) validated a path from the same bug to command execution.
- NVD record last updatedCISA-ADP's SSVC assessment lists exploitation status as "poc" and technical impact as "total."
Attackers using AI tooling should be able to reconstruct the attack chain based on the patch diffs.
The reusable lesson: sniffing is a trust boundary, not a formality
Give this pattern a name, because you'll see it again: a dual-parser trust gap — any pipeline where one piece of code decides a file's type using a cheap heuristic, and a second, more powerful piece of code parses the file for real using a different, more complete rule. Whichever check is cheaper is the one an attacker satisfies. Whichever parser is more complete is the one that actually runs on their bytes. In CVE-2026-66066 that gap sat between a ten-byte prefix check and a proper HDF5 version field, but the shape doesn't care what the two parsers are.
Once a project wires up automatic thumbnailing, PDF preview, audio transcoding, or any "just render it back to the user" feature for untrusted uploads, that feature stops being a UI nicety bolted onto the app. It becomes a second parser stack, often written in C, running attacker-controlled bytes through code paths your web framework's security review never touched. Rails happened to expose this one through libvips' MAT/HDF5 chain; the same shape of bug is waiting inside every framework that treats "we support image uploads" as a feature checkbox instead of a second attack surface with its own patch cadence — archive extractors, font renderers, and container-layer parsers all make the identical bet on a cheap type check.
Risk data as of 2026-08-07live record →