August 20, 2026
CVE-2026–66066 (KindaRails2Shell): Arbitrary File Read to RCE in Rails Active Storage via libvips
Overview
By Guidancewhite
9 min read
Overview
ItemDetailCVE IDCVE-2026–66066NicknameKindaRails2ShellCVSSv49.5 (Critical)CWECWE-1188 (Insecure Default Initialization of a Resource)ComponentRails Active Storage + libvips (variant_processor = :vips)Affectedactivestorage < 7.2.3.2, 8.0.0–8.0.5.0, 8.1.0–8.1.3.0 (6.x only if vips was manually configured)Fixed in7.2.3.2 / 8.0.5.1 / 8.1.3.1Auth requiredNone (unauthenticated)PreconditionApp accepts image uploads and uses libvips as the Active Storage variant processor
Since Rails 7.0, any application running load_defaults 7.0 or later uses libvips as the default Active Storage image processor. This bug isn't a single bad line of code — it's two separate trust failures stacking on top of each other:
- The point where Rails decides a blob "is an image."
- The point where libvips decides which file-format parser to use.
And a third failure sits underneath both: libvips and libmatio (the MAT-file library libvips delegates to) don't even agree on how to read the same header.
TL;DR of the chain:
- The Direct Upload API stores the client-supplied
content_typewith zero validation. - Active Storage decides "this is an image" purely from that stored string — it never opens the file.
- The signed
variation_keyused to request a representation is verified independently of the blob ID, so a legitimate key issued for one blob can be replayed against another. - libvips picks its loader by sniffing the first 10 bytes of the file. libmatio, which actually parses MAT files, picks its format by reading bytes 124–125. A single crafted file can satisfy both checks differently — "MATLAB 5.0" for libvips, "MAT 7.3 / HDF5" for libmatio.
- MAT 7.3 is HDF5 under the hood, and HDF5 datasets support External Storage — a legitimate feature that lets a dataset's bytes come from an attacker-chosen path and offset on disk. libvips renders whatever comes back as pixel data.
- The result: arbitrary server files (
/proc/self/environ,config/master.key, etc.) get leaked as pixel values inside a normal-looking PNG. Once an attacker recoverssecret_key_basefrom that, they can sign a brand-new variation and reachKernel#spawn/Kernel#evalthroughimage_processing's method dispatch — full RCE.
Let's walk through the actual source.
1. Direct Upload stores an attacker-controlled content_type
In Rails 8.0.5, ActiveStorage::DirectUploadsController takes the client's parameters and forwards them straight into blob creation:
class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args) # [1]
render json: direct_upload_json(blob)
end
private
def blob_args
params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}])
.to_h.symbolize_keys # [2]
end
def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:,
content_type: nil, metadata: nil,
service_name: nil, record: nil)
metadata = filter_metadata(metadata)
create! key: key, filename: filename, byte_size: byte_size, checksum: checksum,
content_type: content_type, metadata: metadata, service_name: service_name # [3]
endclass ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args) # [1]
render json: direct_upload_json(blob)
end
private
def blob_args
params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}])
.to_h.symbolize_keys # [2]
end
def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:,
content_type: nil, metadata: nil,
service_name: nil, record: nil)
metadata = filter_metadata(metadata)
create! key: key, filename: filename, byte_size: byte_size, checksum: checksum,
content_type: content_type, metadata: metadata, service_name: service_name # [3]
end[1] and [2] accept content_type straight from the request. [3] writes it to the blob record without touching the actual bytes.
This matters because the direct-upload path skips the server-side sniffing step (the Marcel-based "unfurl" flow) that a normal multipart upload goes through. Upload the exact same crafted file via a standard multipart attachment and Rails re-identifies it as MATLAB data before it ever reaches the image pipeline. Direct upload never runs that re-identification at all.
Once the blob exists, whether it's eligible for transformation is decided purely from that stored column:
def variant(transformations)
if variable?
variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
else
raise ActiveStorage::InvariableError, "Can't transform blob with ID=#{id} and content_type=#{content_type}"
end
end
# Returns true if the variant processor can transform the blob (its content
# type is in +ActiveStorage.variable_content_types+).
def variable?
ActiveStorage.variable_content_types.include?(content_type) # [4]
enddef variant(transformations)
if variable?
variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
else
raise ActiveStorage::InvariableError, "Can't transform blob with ID=#{id} and content_type=#{content_type}"
end
end
# Returns true if the variant processor can transform the blob (its content
# type is in +ActiveStorage.variable_content_types+).
def variable?
ActiveStorage.variable_content_types.include?(content_type) # [4]
end[4] is a plain set-membership check against a string. No file is opened. A MAT/HDF5 payload registered as image/png sails straight into the image variant pipeline.
2. The variation_key isn't bound to a specific blob
Requesting a variant needs two signed parameters: a blob_id and a variation_key. Rails verifies them independently:
module ActiveStorage::SetBlob
extend ActiveSupport::Concern
included do
before_action :set_blob
end
private
def set_blob
@blob = blob_scope.find_signed!(params[:signed_blob_id] || params[:signed_id]) # [5]
rescue ActiveSupport::MessageVerifier::InvalidSignature
head :not_found
end
end
class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController
include ActiveStorage::SetBlob
before_action :set_representation
private
def set_representation
@representation = @blob.representation(params[:variation_key]).processed # [6]
rescue ActiveSupport::MessageVerifier::InvalidSignature
head :not_found
end
end
def decode(key)
new ActiveStorage.verifier.verify(key, purpose: :variation) # [7]
endmodule ActiveStorage::SetBlob
extend ActiveSupport::Concern
included do
before_action :set_blob
end
private
def set_blob
@blob = blob_scope.find_signed!(params[:signed_blob_id] || params[:signed_id]) # [5]
rescue ActiveSupport::MessageVerifier::InvalidSignature
head :not_found
end
end
class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController
include ActiveStorage::SetBlob
before_action :set_representation
private
def set_representation
@representation = @blob.representation(params[:variation_key]).processed # [6]
rescue ActiveSupport::MessageVerifier::InvalidSignature
head :not_found
end
end
def decode(key)
new ActiveStorage.verifier.verify(key, purpose: :variation) # [7]
end[5] checks the blob ID's signature. [6]/[7] separately check the variation key's signature. Nothing cross-checks that the key was actually issued for that blob. An attacker can grab any variation_key exposed by a legitimate representation URL from the same app and replay it against the signed ID of their own freshly-created direct-upload blob.
That's the important part: the file-read stage doesn't require secret_key_base at all.
3. The Vips pipeline leaves decoder selection to libvips
Active Storage hands the tempfile path off to image_processing. The loader(page: 0) call reads like it's picking a decoder — it isn't. It's just storing options for whichever loader libvips ends up choosing on its own:
def process(file, format:)
processor.
source(file).
loader(page: 0). # [8]
convert(format).
apply(operations). # [9]
call
enddef process(file, format:)
processor.
source(file).
loader(page: 0). # [8]
convert(format).
apply(operations). # [9]
call
endimage_processing 1.14.0's actual load logic:
def self.load_image(path_or_image, loader: nil, autorot: true, **options)
if path_or_image.is_a?(::Vips::Image)
image = path_or_image
else
path = path_or_image
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) # [12]
end
end
image = image.autorot if autorot && !options.key?(:autorotate)
image
enddef self.load_image(path_or_image, loader: nil, autorot: true, **options)
if path_or_image.is_a?(::Vips::Image)
image = path_or_image
else
path = path_or_image
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) # [12]
end
end
image = image.autorot if autorot && !options.key?(:autorotate)
image
endloader is nil, so [12] leaves the choice entirely to libvips's own file sniffers. This is the point where "what Rails believes the file is" and "what libvips decides the file is" fully diverge.
4. libvips vs. libmatio: two different opinions on the same header
In libvips 8.16.1, matload is explicitly flagged as untrusted:
static void
vips_foreign_load_mat_class_init(VipsForeignLoadMatClass *class)
{
/* ... */
operation_class->flags |= VIPS_OPERATION_UNTRUSTED; // [13]
foreign_class->suffs = vips__mat_suffs;
load_class->is_a = vips__mat_ismat; // [14]static void
vips_foreign_load_mat_class_init(VipsForeignLoadMatClass *class)
{
/* ... */
operation_class->flags |= VIPS_OPERATION_UNTRUSTED; // [13]
foreign_class->suffs = vips__mat_suffs;
load_class->is_a = vips__mat_ismat; // [14]Vulnerable Active Storage releases never actually used that flag to block the loader. And the sniffer behind it is about as minimal as it gets:
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)) // [15]
return 1;
return 0;
}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)) // [15]
return 1;
return 0;
}[15]: if the first 10 bytes start with "MATLAB 5.0", libvips commits to matload. Worth noting: a genuine MAT 7.3 file's header text actually reads "MATLAB 7.3 MAT-file", so it would fail this exact check — as far as libvips is concerned, this file looks like old-style MAT 5.0, nothing more.
The library that actually parses the content, libmatio (1.5.28), doesn't look at that descriptive text at all — it reads a fixed version field at bytes 124–125:
enum mat_ft
{
MAT_FT_MAT73 = 0x0200, /* MATLAB version 7.3 file */ // [16]
MAT_FT_MAT5 = 0x0100,
MAT_FT_MAT4 = 0x0010,
MAT_FT_UNDEFINED = 0
};
Mat_Open(const char *matname, int mode)
{
/* read 116-byte header + 8-byte subsys_offset + 2 bytes + 2 bytes (tmp) */
bytesread += fread(mat->header, 1, 116, fp);
mat->header[116] = '\0';
bytesread += fread(mat->subsys_offset, 1, 8, fp);
bytesread += 2 * fread(&tmp2, 2, 1, fp);
bytesread += fread(&tmp, 1, 2, fp);
if ( 128 == bytesread ) {
mat->byteswap = -1;
if ( tmp == 0x4d49 ) mat->byteswap = 0;
else if ( tmp == 0x494d ) { mat->byteswap = 1; Mat_int16Swap(&tmp2); }
mat->version = (int)tmp2; // [17] <- read from bytes 124–125
if ( (mat->version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap ) {
mat->bof = ftello((FILE *)mat->fp);
mat->next_index = 0;
} else {
mat->version = 0;
}
}enum mat_ft
{
MAT_FT_MAT73 = 0x0200, /* MATLAB version 7.3 file */ // [16]
MAT_FT_MAT5 = 0x0100,
MAT_FT_MAT4 = 0x0010,
MAT_FT_UNDEFINED = 0
};
Mat_Open(const char *matname, int mode)
{
/* read 116-byte header + 8-byte subsys_offset + 2 bytes + 2 bytes (tmp) */
bytesread += fread(mat->header, 1, 116, fp);
mat->header[116] = '\0';
bytesread += fread(mat->subsys_offset, 1, 8, fp);
bytesread += 2 * fread(&tmp2, 2, 1, fp);
bytesread += fread(&tmp, 1, 2, fp);
if ( 128 == bytesread ) {
mat->byteswap = -1;
if ( tmp == 0x4d49 ) mat->byteswap = 0;
else if ( tmp == 0x494d ) { mat->byteswap = 1; Mat_int16Swap(&tmp2); }
mat->version = (int)tmp2; // [17] <- read from bytes 124–125
if ( (mat->version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap ) {
mat->bof = ftello((FILE *)mat->fp);
mat->next_index = 0;
} else {
mat->version = 0;
}
}[17] decides the actual parsing path:
static int
ReadData(mat_t *mat, matvar_t *matvar)
{
if ( mat->version == MAT_FT_MAT5 )
return Mat_VarRead5(mat, matvar);
#if defined(MAT73) && MAT73
else if ( mat->version == MAT_FT_MAT73 )
return Mat_VarRead73(mat, matvar); // [18]
#endif
else if ( mat->version == MAT_FT_MAT4 )
return Mat_VarRead4(mat, matvar);
return MATIO_E_FAIL_TO_IDENTIFY;
}static int
ReadData(mat_t *mat, matvar_t *matvar)
{
if ( mat->version == MAT_FT_MAT5 )
return Mat_VarRead5(mat, matvar);
#if defined(MAT73) && MAT73
else if ( mat->version == MAT_FT_MAT73 )
return Mat_VarRead73(mat, matvar); // [18]
#endif
else if ( mat->version == MAT_FT_MAT4 )
return Mat_VarRead4(mat, matvar);
return MATIO_E_FAIL_TO_IDENTIFY;
}Put the two together:
- libvips commits to
matloadif the first 10 bytes read"MATLAB 5.0". - libmatio commits to the HDF5-backed
Mat_VarRead73path if bytes 124–125 read0x0200.
Build a single file where the front says "MATLAB 5.0" and bytes 124–125 say 0x0200, and you satisfy libvips's gate while still landing in libmatio's HDF5 parser. This works because HDF5 explicitly reserves up to a 512-byte "userblock" at the start of the file for exactly this kind of prefix data. The attacker plants a fake MAT 5.0 header text in that userblock, then appends a real HDF5 superblock right after it.
5. HDF5 External Storage as an arbitrary-file-read primitive
MAT 7.3 files are HDF5 containers. HDF5 datasets support External Storage — a legitimate, spec-compliant feature where a dataset's actual values live in a separate file, at a caller-specified path and offset, rather than inside the container itself. Nothing wrong with the feature in isolation; the problem is that here, the attacker fully controls the dataset definition, and therefore controls which file and offset get read.
libmatio's actual HDF5 read call:
static int
Mat_H5ReadData(hid_t dset_id, hid_t h5_type, hid_t mem_space, hid_t dset_space,
int isComplex, void *data)
{
herr_t herr;
if ( !isComplex ) {
herr = H5Dread(dset_id, h5_type, mem_space, dset_space, H5P_DEFAULT, data); // [19]
if ( herr < 0 ) {
return MATIO_E_GENERIC_READ_ERROR;
}static int
Mat_H5ReadData(hid_t dset_id, hid_t h5_type, hid_t mem_space, hid_t dset_space,
int isComplex, void *data)
{
herr_t herr;
if ( !isComplex ) {
herr = H5Dread(dset_id, h5_type, mem_space, dset_space, H5P_DEFAULT, data); // [19]
if ( herr < 0 ) {
return MATIO_E_GENERIC_READ_ERROR;
}There's no H5Pget_external_count() check before [19] — nothing verifies whether this dataset is even supposed to reference external storage before HDF5 goes and reads it. The runtime resolves the external reference and copies bytes straight from the attacker-chosen file into the MAT variable's data buffer. libvips then treats that buffer as pixel data, encodes it as PNG (or whatever format was requested), and Active Storage serves it back as a normal representation.
End to end: arbitrary file on disk → pixel values → PNG → HTTP response.
You don't need a variant request to trigger this
The advisory is explicit that generating variants isn't a separate requirement, and it's worth internalizing why. Active Storage also calls Vips::Image.new_from_file during image analysis, which runs automatically after a blob is attached — no variant request needed. In that path, libmatio ends up reading external bytes while computing the dimensions of an empty array, and those bytes can surface as the reported width/height metadata instead of pixel values. Lower bandwidth per request, but it breaks the assumption that "if we never render a variant, we're safe."
6. Why the patch actually works
The v8.0.5 → v8.0.5.1 diff doesn't add a content-type check. It loads a new initializer that flips a switch libvips already exposed:
diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb
+require "active_storage/vips"
diff --git a/activestorage/lib/active_storage/vips.rb (new file)
+if ActiveStorage::VIPS_AVAILABLE
+ begin
+ require "image_processing/vips"
+ rescue LoadError
+ end
+
+ unless Vips.respond_to?(:block_untrusted) # [20]
+ raise <<~ERROR.squish
+ libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
+ cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or
+ later. Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile.
+ ERROR
+ end
+
+ Vips.block_untrusted(true) # [21]
+enddiff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb
+require "active_storage/vips"
diff --git a/activestorage/lib/active_storage/vips.rb (new file)
+if ActiveStorage::VIPS_AVAILABLE
+ begin
+ require "image_processing/vips"
+ rescue LoadError
+ end
+
+ unless Vips.respond_to?(:block_untrusted) # [20]
+ raise <<~ERROR.squish
+ libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
+ cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or
+ later. Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile.
+ ERROR
+ end
+
+ Vips.block_untrusted(true) # [21]
+end[20] makes the app refuse to boot if the installed ruby-vips/libvips pair doesn't support the blocking API — no silent fallback to the vulnerable behavior. [21] blocks every operation libvips itself has flagged VIPS_OPERATION_UNTRUSTED, globally, at boot. Since matload already carried that flag, this single call closes the path before a crafted file ever reaches libmatio.
The fix isn't "check the content harder." It's "stop leaving a loader enabled that upstream already told you not to trust with untrusted input."
7. From file read to RCE: abusing send
Arbitrary file read alone is enough to pull SECRET_KEY_BASE out of /proc/self/environ. Once an attacker has that, they can derive the Active Storage verifier key and sign brand-new variations instead of just replaying stolen ones — meaning they can now request arbitrary transformations, not just the ones already exposed in the app.
image_processing 1.14.0's Chainable#apply:
def apply(operations)
operations.inject(self) do |builder, (name, argument)|
if argument == true || argument == nil
builder.public_send(name)
elsif argument.is_a?(Array)
builder.public_send(name, *argument) # [22]
elsif argument.is_a?(Hash)
builder.public_send(name, **argument)
else
builder.public_send(name, argument)
end
end
enddef apply(operations)
operations.inject(self) do |builder, (name, argument)|
if argument == true || argument == nil
builder.public_send(name)
elsif argument.is_a?(Array)
builder.public_send(name, *argument) # [22]
elsif argument.is_a?(Hash)
builder.public_send(name, **argument)
else
builder.public_send(name, argument)
end
end
end[22]: the transformation's name is passed directly into public_send. If name is "send", the first element of the array becomes a second-order method call — reaching private methods like Kernel#spawn or Kernel#eval.
These JSON-compatible shapes are enough for code execution:
{"send":["spawn","/bin/sh","-c","id"]}
{"send":["eval","File.write('/tmp/kr2s', %x{id})"]}{"send":["spawn","/bin/sh","-c","id"]}
{"send":["eval","File.write('/tmp/kr2s', %x{id})"]}The asymmetry worth noting: :mini_magick transformations go through validate_transformation, which checks method names. :vips transformations don't get the same check. That specific gap is tracked separately in Rails PR rails/rails#56995 — it's a distinct bug from the MAT/HDF5 header confusion, but chained together they turn "file read" into "read secrets → forge signature → RCE" as one continuous path. Execution happens while the processing pipeline is still being built, so even if the final HTTP response comes back as a 500, the command has already run.
8. Full attack chain
As a plain-text summary:
[Attacker] --(1) Creates a direct-upload blob with content_type=image/png-->
[Rails: stores content_type only, no byte inspection] --(2) Replays a legit variation_key from another blob-->
[Rails: blob ID / variation_key verified independently, no cross-check] --(3) Triggers a representation-->
[image_processing: no loader specified, calls Vips.new_from_file] --(4) libvips checks only the first 10 bytes-->
[libvips: "MATLAB 5.0" prefix matches -> selects matload] --(5) libmatio checks bytes 124-125-->
[libmatio: MAT_FT_MAT73 (0x0200) -> dispatches to HDF5 reader] --(6) resolves external dataset path+offset-->
[HDF5: reads attacker-chosen file via External Storage] --(7) rendered as pixel data-->
[Returned to client as a normal PNG representation]
|
+--(8) Attacker extracts secret_key_base from leaked file-->
[Attacker derives the Active Storage verifier key] --(9) Signs {"send":["spawn",...]}-->
[image_processing Chainable#apply: public_send(name, *args)] --(10)--> [Kernel#spawn / Kernel#eval = RCE][Attacker] --(1) Creates a direct-upload blob with content_type=image/png-->
[Rails: stores content_type only, no byte inspection] --(2) Replays a legit variation_key from another blob-->
[Rails: blob ID / variation_key verified independently, no cross-check] --(3) Triggers a representation-->
[image_processing: no loader specified, calls Vips.new_from_file] --(4) libvips checks only the first 10 bytes-->
[libvips: "MATLAB 5.0" prefix matches -> selects matload] --(5) libmatio checks bytes 124-125-->
[libmatio: MAT_FT_MAT73 (0x0200) -> dispatches to HDF5 reader] --(6) resolves external dataset path+offset-->
[HDF5: reads attacker-chosen file via External Storage] --(7) rendered as pixel data-->
[Returned to client as a normal PNG representation]
|
+--(8) Attacker extracts secret_key_base from leaked file-->
[Attacker derives the Active Storage verifier key] --(9) Signs {"send":["spawn",...]}-->
[image_processing Chainable#apply: public_send(name, *args)] --(10)--> [Kernel#spawn / Kernel#eval = RCE]9. Remediation
- Upgrade Active Storage to 7.2.3.2 / 8.0.5.1 / 8.1.3.1 or later.
- You need libvips ≥ 8.13 and ruby-vips ≥ 2.2.1 alongside the patch — without them,
Vips.block_untrusted(true)isn't available and the patched Active Storage will refuse to boot rather than run unprotected. - Rails 7.1 and earlier, and 6.x, have no official backport — a major version upgrade is the only vendor-supported fix. If that's not immediately feasible, mitigate with WAF rules or a backported patch in the meantime.
- Rotate
secret_key_base,RAILS_MASTER_KEY, and any service credentials the app process could read, regardless of when you patch — the read itself could already have happened before you upgraded. - Apps using
MiniMagickinstead of:vipsas the variant processor aren't exposed to this specific attack path.