KindaRails2Shell (CVE-2026-66066): the patch doesn't un-leak your secret_key_base
CVE-2026-66066 leaks Rails secrets through an image upload. Patching closes the loader — it does not rotate the keys, and the forensic evidence is on a deletion timer.
On 29 July 2026 the Rails core team shipped 7.2.3.2, 8.0.5.1 and 8.1.3.1 to fix CVE-2026-66066, a CVSS 4.0 score of 9.5 that Ethiack named KindaRails2Shell. The patch is small and it works: it tells libvips to refuse the file loaders that were never meant to see hostile input. What the patch cannot do is tell you whether, at some point in the last several Rails releases, an unauthenticated stranger uploaded a JPEG that was not a JPEG and walked away with your secret_key_base. That question has a different answer, a different deadline, and an evidence trail that your own background jobs are actively deleting.
What CVE-2026-66066 actually does
Active Storage lets a browser upload directly to object storage and then tell Rails what it just uploaded. Rails records that claim in the blob's content_type column. When something later asks for a variant — a thumbnail, a resized avatar — Rails consults that column to decide the file is an image worth processing, and hands the bytes to libvips.
libvips does not trust the column. It sniffs magic bytes and picks a loader based on what the file actually is. Two components, two different opinions about the same file, and nobody reconciling them. That gap is the whole bug.
libvips ships dozens of format loaders backed by third-party libraries, and it marks some of them unfuzzed — a maintainer's explicit statement that this code path has not been hardened against hostile input and should not be pointed at untrusted content. libvips has offered a switch to block those loaders since version 8.13. Active Storage never flipped it. The advisory's own words: "libvips reads and writes file formats through operations, many backed by third-party libraries. It marks some as unfuzzed (unsafe for untrusted content)." Active Storage did not disable the unfuzzed operations.
Rapid7's analysis documents the chain the researchers used. The uploaded file declares an image content type on the direct-upload endpoint. To libvips it looks like a MATLAB level 5 file, so the matload operation takes it. To libmatio underneath, it is a MAT 7.3 file — which is an HDF5 container. HDF5 supports an External File List: a dataset whose contents live in another file on disk, at a path the file itself specifies. libmatio follows the path. The bytes come back as pixel values in the generated thumbnail.
That is the entire read primitive. The attacker uploads a picture, asks for the thumbnail, and reads a file off your server out of the pixels.
The escalation nobody should have to explain twice
An arbitrary file read on a Rails box is not a disclosure incident. It is a key-theft incident, because of what is readable by the Rails process:
secret_key_baseand the master key inconfig/master.key- everything inside
config/credentials.yml.enc, once the master key is out - database credentials
- S3 / GCS / Azure storage keys
- third-party API tokens in the process environment
With secret_key_base an attacker signs whatever Rails will verify. Rapid7 notes that the recovered signing material is enough to forge ImageProcessing 1.x variant parameters — without needing a Marshal deserialization gadget — which converts the read into code execution. Even setting the RCE aside, a stolen secret_key_base means forged session cookies and forged signed GlobalIDs: an attacker who returns tomorrow arrives as a valid, correctly-signed user, and every authentication log agrees with them.
Readers who followed the SharePoint machine-key theft in July have seen this shape already. The product changes; the failure mode does not. Once the signing key leaves, patching the door the key came through is necessary and insufficient.
This is the third time Active Storage has trusted the client
Zero Hunt's engine keeps a semantic index over historical CVE corpora, and when we pointed it at the KindaRails2Shell mechanism the nearest neighbours were not other image-parsing bugs. They were Active Storage's own direct-upload history:
- CVE-2026-33173 (March 2026, CVSS 5.3) —
DirectUploadsControlleraccepted arbitrary client metadata, letting a client set internal flags such asidentifiedandanalyzedand so skip MIME detection entirely. - CVE-2020-8162 — the S3 direct-upload adapter let the client modify the
Content-Lengthof its own upload, defeating size limits.
Read those together and CVE-2026-66066 stops looking like a libvips accident. Direct upload is a design in which the client narrates its own file to the server, and each of these bugs is the same sentence with a different noun. The 2026 advisory classifies the root cause as CWE-1188, Initialization of a Resource with an Insecure Default — which is the honest label. Nobody chose libvips. It became the default variant processor for any app on load_defaults 7.0 or later, and the applications now exposed are overwhelmingly ones whose developers never made a decision about image processing at all.
"We don't do image processing, we just let users set a profile picture."
That sentence describes an affected application.
"Am I affected?" is not a version question
The advisory's affected ranges are precise, and they are only the first of three conditions:
| Condition | How to check |
|---|---|
activestorage below 7.2.3.2, or 8.0.x below 8.0.5.1, or 8.1.x below 8.1.3.1 |
bundle list | grep activestorage |
Variant processor is :vips (the default since load_defaults 7.0) |
grep -rn variant_processor config/ — no result means you are on the default |
| An untrusted user can get a file into Active Storage and cause a variant to be generated | bin/rails routes | grep direct_upload, then trace which uploads reach a variant call |
The third row is the one that no dependency scanner answers. Two applications on the identical Gemfile.lock can differ completely: one mounts the direct-upload endpoint behind authentication and never generates variants from user files, the other resizes avatars for anonymous signups. Same SBOM, same CVE, opposite risk. Rails 6.x, meanwhile, is not affected on defaults but is affected if someone explicitly configured Vips — a case an automated version check gets backwards in both directions.
The Rails security team was straightforward about the limits of their own visibility, telling The Hacker News they have "no telemetry or reasonable estimate" for how many live deployments combine Active Storage, Vips and untrusted uploads. Ethiack's public estimate is 500,000+ potentially exposed sites. Nobody, including the framework's maintainers, can tell you whether yours is one of them. You have to test the path.
The evidence is on a deletion timer
This is the part of KindaRails2Shell that most write-ups skip, and it is the part with a clock on it.
The attack leaves a physical artifact: the crafted file is a real object sitting in your Active Storage bucket. Rails published a forensics repository, rails/rails-forensics-CVE-2026-66066, built around exactly that. Its detector (lib/crafted_mat_file.rb) needs two header fields from the first 128 bytes of an object, which means bin/kr2s_scan_active_storage_blobs.rb can classify a candidate from a ranged read instead of downloading every blob you have ever stored. Two documented procedures sit on top of it — kr2s-was-i-vulnerable establishes the exposure window, kr2s-was-i-exploited then sweeps that window for crafted files and reconstructs which paths were read.
That works only while the artifact still exists. Rapid7 flags it plainly: scheduled cleanup of unattached blobs may remove the evidence. Most mature Rails applications purge unattached blobs on a cron — it is the recommended hygiene — and an exploit blob is by construction unattached, because the attacker uploaded it and never associated it with a record. The tooling that proves you were hit is racing the job you configured to keep your bucket tidy.
So the first action in this incident is not bundle update. It is: stop the purge job, then patch. Reversing that order can permanently destroy the only evidence that distinguishes "we patched a vulnerability" from "we had a breach and are legally required to say so." Note also the repository's own caveat — it covers Active Storage with tracked variants on the :vips processor, and "a clean result from this tooling is strong evidence, not proof." Any other code path that hands user files to libvips is outside its scope and yours to check.
Remediation
1. Am I affected?
Before anything else, suspend Active Storage blob cleanup — ActiveStorage::PurgeJob and any custom unattached-blob sweeper. Do this first.
# Framework version
bundle list | grep -E 'activestorage|^ \* rails '
# Variant processor: no output means the default (:vips) on load_defaults 7.0+
grep -rn "variant_processor" config/
# libvips and binding versions
vips --version # need >= 8.13
bundle list | grep ruby-vips # need >= 2.2.1
# Is the direct-upload endpoint reachable, and by whom?
bin/rails routes | grep direct_upload
You are affected when all three hold: an unpatched activestorage, the :vips processor, and a path by which an untrusted user's file reaches variant generation. Rails 6.0.0–6.1.7.10 are affected only where Vips was configured explicitly.
2. Patch — exact fixed versions
| Branch | Fixed version |
|---|---|
| Rails 7.0.0 – 7.2.3.1 | 7.2.3.2 |
| Rails 8.0.0 – 8.0.5 | 8.0.5.1 |
| Rails 8.1.0 – 8.1.3 | 8.1.3.1 |
| libvips | ≥ 8.13 |
| ruby-vips | ≥ 2.2.1 |
bundle update rails activestorage
The libvips floor is not optional: the patch works by calling the block-untrusted API, which older libvips does not have. Expect one behavioural change — applications that generate variants of BMP, ICO, PSD or similar formats will find variant generation broken, and must remove those types from variable_content_types. That is the fix functioning, not a regression.
3. Can't patch right now? Compensating controls
- Set
VIPS_BLOCK_UNTRUSTED=1in the application environment (requires libvips ≥ 8.13), or callVips.block_untrusted(true)from an initializer with ruby-vips ≥ 2.2.1. This is the same switch the patch flips. - Switch
config.active_storage.variant_processor = :mini_magick. Per Ethiack, only the Vips processor is affected through this vector — treat this as a bridge, not a destination; ImageMagick has its own delegate history. - Stop trusting the client's
content_type: validate real magic bytes server-side on ingest and reject files whose bytes disagree with their declaration. This kills the class, not just the CVE. - Restrict egress from whichever worker performs variant processing. It should not be able to reach arbitrary destinations, which raises the cost of turning a read into an exfiltration channel.
4. Hunt for compromise
Run the official tooling against your storage backend, in this order:
git clone https://github.com/rails/rails-forensics-CVE-2026-66066
# 1) establish the exposure window from your deploy history
# -> skills/kr2s-was-i-vulnerable
# 2) sweep that window for crafted blobs
ruby bin/kr2s_scan_active_storage_blobs.rb # self-contained; copy into the container
Signals to correlate alongside it:
- Blob-level: any blob declaring an image
content_typewhose first bytes identify as MAT/HDF5. This is the direct indicator the scanner keys on. - Request-level: a
POSTto/rails/active_storage/direct_uploadsfollowed within seconds by a variant request for that same blob, from one source, repeatedly. Legitimate users upload and then browse; they do not enumerate. - Unattached blobs uploaded during the exposure window that were never associated with any record.
- Post-theft: this is where a read incident becomes visible on the wire. Valid, correctly-signed session cookies or signed GlobalIDs presented from an ASN that account has never used; requests carrying signed Active Storage or ImageProcessing parameters your application never generated; a variant worker opening an outbound connection.
MITRE ATT&CK mapping for the chain: T1190 Exploit Public-Facing Application → T1083 File and Directory Discovery / T1005 Data from Local System → T1552.001 Unsecured Credentials: Credentials In Files → T1550.004 Use Alternate Authentication Material: Web Session Cookie → T1078 Valid Accounts.
5. Eradicate and verify
If the scan finds a crafted blob — or if your exposure window is long and your blob retention is short enough that you cannot rule it out — treat every secret the Rails process could read as exposed:
secret_key_baseconfig/master.keyand the full contents ofconfig/credentials.yml.enc- database credentials
- Active Storage service credentials (S3, GCS, Azure)
- every third-party API token in the process environment
The Rails advisory is emphatic on one point that teams get wrong under pressure: "Rotation should only be used as an intermediate step if necessary. Do not retain an exposed secret as a fallback." Rails supports a rotation fallback so old cookies keep validating during a deploy — leaving the compromised key in that list means the forged cookies keep validating too.
Plan for the blast radius before you rotate, because changing secret_key_base invalidates active sessions, encrypted and signed cookies, signed GlobalIDs, and previously issued Active Storage URLs. Users re-authenticate; any URL you emailed out stops resolving. Do it anyway.
Then verify, in this order: patched version deployed and libvips ≥ 8.13 confirmed in the running container; block-untrusted active; the blob scan re-run after patching and returning clean; the old secret_key_base removed from every rotation fallback; and — the check that is actually load-bearing — a session cookie signed with the old key rejected in production.
Where Zero Hunt fits
Everything above produces a factual record, and that record is what a regulator, an insurer or an auditor will eventually ask for. The awkward question in this incident is not "did you patch." It is: what was your exposure window, what did you scan, when did you scan it, what did the scan return, and can you prove any of that six months from now? Under NIS2 the reporting clock starts from awareness of a significant incident, not from confirmation of one — and KindaRails2Shell is precisely the case where awareness arrives long before confirmation, on evidence that expires.
Zero Hunt's compliance layer maps every finding, scan and remediation action continuously across 32 frameworks — NIS2 including Title 13, GDPR, ISO 27001, SOC 2, DORA including the TLPT RTS — and signs each record with ECDSA at write time, so chain of custody exists by construction rather than being reassembled from Slack scrollback during a deadline. The Trust Center exports the bundle in one click. For a vulnerability whose forensic artifacts are on a deletion timer, the signed timeline of what you checked and when is not paperwork; it is the difference between a defensible position and an assertion.
The exposure question gets answered on the offensive side. Zero Hunt's 10-agent swarm does not compare your Gemfile.lock against a CVE list — a local LLM writes a per-target chain: the Recon agent maps which upload endpoints are reachable without credentials, the Web agent drives an actual crafted upload through them, and the Post-Exploit agent establishes whether a variant request returns file content or nothing at all. Every skill it uses is backtested in the AI Gym against Vulhub and the CVE-based black-box corpus before it is allowed near a production target, and every exploit runs in an ephemeral container on the appliance, on-premise, with no cloud callback. The answer that comes back is not "you are running a vulnerable version." It is "this endpoint, this parameter, this file was read" — or a clean result you can put your name on.
If the third condition in the table above is the one you cannot answer for your own estate, that is the conversation worth having.