# CVE-2026-66066: from a representation request to an arbitrary file read This documents the exact call sequence from an inbound Active Storage representation request to the point where `libmatio` reads an attacker-chosen file. All paths were traced against `rails/rails` at `fbd03fc7`, `image_processing` 2.0.1 and 1.14.0, `ruby-vips` 2.3.0, `libvips` 8.18.4, and the `libmatio` and `libvips` sources current at the time of the report. Four junctions in this chain are load-bearing for the attack. They are marked inline and summarized below. ## Preconditions Two things must already be true before the request described here is made. **The blob was created through `POST /rails/active_storage/direct_uploads`.** This is the only upload path that persists the client-declared `content_type` without examining the bytes. `ActiveStorage::Blob.create_before_direct_upload!` calls `create!` directly, so `identify_without_saving` never runs and the `identified` flag stays `false` permanently. A server-side attach would have gone through `unfurl`, sniffed the bytes with Marcel, and stored the true type. **The attacker holds a `variation_key` scraped from any page that renders an Active Storage representation.** The key is genuine and correctly signed rather than forged. ## The four junctions | # | Location | What happens | |---|---|---| | 1 | `representable.rb:112`, `Blob#variable?` | The client-declared `content_type` is trusted to decide whether the blob may be processed | | 2 | `variation.rb`, `Variation.decode` | A harvested `variation_key` verifies legitimately, because it is genuine | | 3 | `image_processing_transformer.rb`, `validate_transformation` | The only choke point over user-supplied transformation names; not overridden for Vips. Load-bearing for the escalations rather than for the file read | | 4 | `image_processing/vips.rb`, `Vips::Image.new_from_file` | `libvips` selects the decoder from the file's magic bytes | No Ruby code anywhere in this chain reads a byte of the file's content. ## The call sequence ### 1. Request routing ``` GET /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename ``` Two `before_action` concerns resolve the parameters independently of one another: 1. `ActiveStorage::SetBlob#set_blob` calls `Blob.find_signed!(params[:signed_blob_id])` 2. `ActiveStorage::Representations::BaseController#set_representation` calls `@blob.representation(params[:variation_key]).processed` Both rescue only `ActiveSupport::MessageVerifier::InvalidSignature` and render 404. Because the blob and the transformation are resolved from two separate signed values with no cross-reference, any valid `variation_key` composes with any `signed_id`. ### 2. The content-type gate `Blob#representation` in `activestorage/app/models/active_storage/blob/representable.rb`: ```ruby case when previewable? then preview transformations when variable? then variant transformations else raise ActiveStorage::UnrepresentableError end ``` `previewable?` is evaluated first, but no registered previewer accepts `image/png`, so control falls to `variable?`. Then `Blob#variant`: ```ruby def variant(transformations) if variable? variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(...)) else raise ActiveStorage::InvariableError end end def variable? ActiveStorage.variable_content_types.include?(content_type) # JUNCTION 1 end ``` **Junction 1** (`representable.rb:112`). This set-membership test against a database column is the entirety of the type check. No bytes are consulted here or anywhere else in Ruby. ### 3. Variation key verification `Variation.wrap(String)` dispatches to `Variation.decode`: ```ruby def decode(key) new ActiveStorage.verifier.verify(key, purpose: :variation) # JUNCTION 2 end ``` **Junction 2.** This signature check passes legitimately. The token is real. This is why the file read requires no secrets, and equally why recovering `secret_key_base` is what unlocks everything downstream. ### 4. Download and hand-off `Variant#processed` calls `process`, which does: ```ruby blob.open do |input| # downloads the blob to a local Tempfile variation.transform(input) do |output| ``` The uploaded bytes are written to local disk, and that path is later handed to `libvips`. ### 5. The Active Storage choke point `ImageProcessingTransformer#process`: ```ruby def process(file, format:) processor. # ImageProcessing::Vips source(file). loader(page: 0). convert(format). apply(operations). call end def operations transformations.each_with_object([]) do |(name, argument), list| validate_transformation(name, argument) # JUNCTION 3 list << [name, argument] if argument.present? end end ``` **Junction 3.** `validate_transformation` is the single point through which every user-supplied transformation key passes. The base class rejects only `combine_options`. `Transformers::ImageMagick` overrides it with the `supported_image_processing_methods` allowlist. `Transformers::Vips` does not override it at all. This is the gap that [rails/rails#56995](https://github.com/rails/rails/pull/56995) would address, and it is relevant only to one of the escalations, not to the file read. Worth noting to avoid a common misreading: `.loader(page: 0)` is `image_processing`'s `Chainable#loader`, which sets options to be passed to whichever loader is later chosen. It does not name a loader. Nothing in this chain names a loader. ### 6. The image_processing pipeline The chained calls accumulate options into a `Pipeline`, and `.call` eventually reaches `ImageProcessing::Vips::Processor`: ```ruby def self.load_image(path_or_image, loader: nil, autorot: true, **options) ... if loader image = ::Vips::Image.public_send(:"#{loader}load", path, **options) else options = Utils.select_valid_loader_options(path, options) image = ::Vips::Image.new_from_file(path, **options) # JUNCTION 4 end ``` **Junction 4.** The `loader:` keyword is `nil`, so control reaches `new_from_file` and `libvips` selects the decoder itself. ### 7. Loader selection in libvips `Vips::Image.new_from_file` calls `vips_foreign_find_load(path)`. `libvips` iterates `VipsForeignLoad` subclasses in priority order, calls each class's `is_a` function against the file's leading bytes, and skips any operation whose blocked flag is set. That skip is the sole mechanism `Vips.block_untrusted` operates through. `matload` registers its sniffer in `libvips/foreign/matload.c`: ```c load_class->is_a = vips__mat_ismat; ``` And `vips__mat_ismat`, in `libvips/foreign/matlab.c`, is the complete test: ```c int vips__mat_ismat(const char *filename) { unsigned char buf[15]; if (vips__get_bytes(filename, buf, 10) == 10 && vips_isprefix("MATLAB 5.0", (char *) buf)) return 1; return 0; } ``` Ten bytes, compared against the literal string `MATLAB 5.0`. A consequence worth recording: a genuine MAT 7.3 file written by MATLAB or `libmatio` begins `MATLAB 7.3 MAT-file`, so it fails this check outright. The only MAT files `libvips` will ever hand to `libmatio` are ones whose descriptive text claims level 5. ### 8. libmatio and HDF5 `Mat_Open` in `matio/src/mat.c` reads the 128-byte header positionally. The version field at bytes 124 and 125 is both the validity check and the format selector: ```c bytesread += fread(mat->header, 1, 116, fp); /* 0-115, opaque text */ mat->header[116] = '\0'; bytesread += fread(mat->subsys_offset, 1, 8, fp); /* 116-123 */ ... mat->version = (int)tmp2; /* 124-125 */ if ( (mat->version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap ) { enum mat_ft { /* matio.h */ MAT_FT_MAT73 = 0x0200, MAT_FT_MAT5 = 0x0100, else if ( mat->version == MAT_FT_MAT5 ) return Mat_VarRead5(mat, matvar); /* ReadData */ else if ( mat->version == MAT_FT_MAT73 ) return Mat_VarRead73(mat, matvar); ``` Bytes 0 through 115 are stored as an opaque string and never parsed for a version, so `libmatio` cannot notice that the descriptive text disagrees with the field it dispatched on. `Mat_VarRead73` is the HDF5 reader. HDF5 opens the dataset, whose storage is an External File List entry recording a `(path, offset, length)` triple, reads `length` bytes from `path`, and returns them as the variable's data. Those bytes become the pixel array. From here the pipeline behaves normally. The array is thumbnailed, saved in the format from `convert(format)`, uploaded as a variant, and the controller redirects the requester to it. The contents of the target file are returned as pixel values. **This is what the detector reads.** A crafted file is obliged to get two ranges wrong together: bytes 0 through 9 must claim `MATLAB 5.0` or `libvips` never routes it to `matload`, and bytes 124 through 127 must carry the MAT 7.3 dispatch tag or `libmatio` never reaches the HDF5 reader. No legitimate writer emits both. A genuine level 5 file has the text and not the tag; a genuine 7.3 file has the tag and not the text. That pair is the whole of `lib/crafted_mat_file.rb`, and it is why the scan can classify an object from its first 128 bytes. The userblock is not a usable third signal. HDF5 accepts any power of two from 512 upward, and `libmatio` reads only the first 128 bytes before delegating, so a crafted container can begin at 1024 or 4096 and a detector anchored to the HDF5 signature at a fixed offset will miss it. The two header ranges are at fixed positions by definition. ## Why the file must be structurally inconsistent The attacker has no choice about either field. The file-read primitive is HDF5's External File List, which exists only in the 7.3 format: level 5 is flat binary with inline data elements and has no concept of external storage. So the version field must say 7.3, while the text must claim 5.0 to get past `libvips` at all. Comparing the crafted file against what `Mat_Create73` in `matio/src/mat73.c` writes for a genuine 7.3 file: | Bytes | Genuine MAT 7.3 | Crafted file | Same? | |---|---|---|---| | 0–115, descriptive text | `MATLAB 7.3 MAT-file, ... HDF5 schema 0.5` | `MATLAB 5.0 MAT-file, ...` | differs | | 116–123, subsys offset | spaces | spaces | same | | 124–125, version | `0x0200` | `0x0200` | same | | 126–127, endian | `0x4d49` | `0x4d49` | same | | userblock size | 512 | 512 | same | | 512 onward | HDF5 superblock | HDF5 superblock | same | Exactly one byte range differs. The file is a structurally genuine MAT 7.3 container whose descriptive text is spoofed to read `MATLAB 5.0`. The version field is honest. `libmatio` receives accurate information and behaves correctly throughout. `libvips` is the only deceived party. A 512-byte HDF5 userblock is what makes the dual identity physically possible. HDF5 permits a leading userblock of zero or a power of two of at least 512 bytes, which it ignores, placing its superblock at that offset. `Mat_Create73` uses exactly this, via `H5Pset_userblock(plist_id, 512)`. The `MATLAB_class` attribute of `uint8` is required for `libmatio` to treat the variable as a real numeric array, which is what forces the dataset to actually be read and the external reference resolved. ## The insight worth carrying The attack is the same bug shape stacked twice, at two different layers. 1. **Rails versus libvips.** Rails decides the file is an image from a database column; libvips decides it is MATLAB from magic bytes. Neither consults the other. 2. **libvips versus matio.** libvips decides the file is MAT level 5 from bytes 0 through 9; matio decides it is MAT 7.3 from bytes 124 through 125. Neither consults the other. That second confusion is why blocking untrusted loaders is the right control and why re-identifying the content type is not. Adding a third opinion about the file type at the Rails layer does not help when the two layers below Rails already disagree with each other about the same file. You have to stop the handoff, not add another sniffer.