CVE Tools
Back to blog

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: 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. Her
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. Her

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.

9.5CVSS v4.0 (GitHub CNA)NVD had not issued its own score as of this writing
CWE-1188Insecure Default Initialization of Resource
3Patched branches7.2.3.2 · 8.0.5.1 · 8.1.3.1
0.018cve.tools EPSS score75.9th percentile — still catching up to the Critical label

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

  1. Direct-upload controller
  2. content_type stored, bytes unchecked
  3. libvips sniff: first 10 bytes
  4. libmatio parses real version (bytes 124-125)
  5. MAT 7.3 → HDF5 external dataset
  6. H5Dread() pulls arbitrary file bytes
  7. Bytes rendered back as PNG pixel data
  8. 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.

LayerWhat it checksWhat it concludes
Blob recordClient-supplied content_type header"image/png" — trusted, no bytes read
libvips loader dispatchFirst 10 bytes of the file"MATLAB 5.0" — a safe, legacy format
libmatio parserVersion field at bytes 124-125MAT 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

MiniMagick / ImageMagick path
  • 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
Vips path (default since Rails 7.0)
  • 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
Neither advisory explains why MiniMagick validates and Vips doesn't — only that it does.

The patch, and why it needs libvips to cooperate

BranchVulnerable rangeFixed versionExtra requirement
7.2.x< 7.2.3.27.2.3.2libvips ≥ 8.13, or boot raises an exception
8.0.x≥ 8.0.0.beta1, < 8.0.5.18.0.5.1libvips ≥ 8.13, or boot raises an exception
8.1.x≥ 8.1.0.beta1, < 8.1.3.18.1.3.1libvips ≥ 8.13, or boot raises an exception
6.0–6.16.0.0–6.1.7.10, non-default config onlyUpgrade to a patched lineOnly exploitable if Active Storage config deviates from Rails defaults
  1. Upgrade activestorage/Rails to 7.2.3.2, 8.0.5.1, or 8.1.3.1.
  2. 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.
  3. 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.
  4. 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.
  5. If untrusted users can upload images and you can't upgrade yet, consider disabling automatic variant generation until you can.

Disclosure, accelerated

  1. Original full-disclosure date
    Rails maintainers had scheduled complete technical details for August 28, giving the install base a month to patch quietly.
  2. Plan scrapped — PoCs surface
    Public 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.
  3. GHSA-xr9x-r78c-5hrm published, patches tagged
    Rails 7.2.3.2, 8.0.5.1 and 8.1.3.1 released alongside the advisory.
  4. CVE-2026-66066 lands in NVD
    Published with GitHub's CNA-assigned CVSS v4.0 score of 9.5; NVD had not issued its own score as of this writing.
  5. Rapid7 publishes the RCE chain and a Metasploit module
    Independent research (dubbed KindaRails2Shell) validated a path from the same bug to command execution.
  6. NVD record last updated
    CISA-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.
— Ethiack, explaining why waiting until August 28 stopped being the safer option

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 →