CVE-2026-66066: Verified Reproduction
CVE-2026-66066: Rails Active Storage variant processing arbitrary file read and potential RCE
CVE-2026-66066 is verified against rails/rails · github. Affected versions: activestorage < 7.2.3.2 (Rails 7.0.0-7.2.3.1 affected in default config); 8.0.0-8.0.5; 8.1.0-8.1.3; Rails 6.x only with non-default Active Storage config. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00317.
What Is CVE-2026-66066?
CVE-2026-66066 is a critical-severity RCE vulnerability affecting rails/rails activestorage < 7.2.3.2 (Rails 7.0.0-7.2.3.1 affected in default config); 8.0.0-8.0.5; 8.1.0-8.1.3; Rails 6.x only with non-default Active Storage config. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00317).
CVE-2026-66066 Severity
CVE-2026-66066 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected rails/rails Versions
rails/rails · github versions activestorage < 7.2.3.2 (Rails 7.0.0-7.2.3.1 affected in default config); 8.0.0-8.0.5; 8.1.0-8.1.3; Rails 6.x only with non-default Active Storage config are affected.
How to Reproduce CVE-2026-66066
pruva-verify REPRO-2026-00317 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00317/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-66066
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
crafted .mat (HDF5) file uploaded via POST /rails/active_storage/direct_uploads + PUT to the disk-service token URL, declared as image/png; plus a signed variation token scraped from a public page and, for the RCE stage, an offline-forged variation token minted with the leaked secret_key_base
- GET /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/:filename
- ActiveStorage::Variant#process
- ImageProcessing::Vips
- Vips::Image.new_from_file
- libvips matload (untrusted)
- matio/HDF5 external storage reads /proc/self/environ; RCE stage: same endpoint with forged {"instance_eval": "<ruby>"} variation token
How the agent worked
Root Cause and Exploit Chain for CVE-2026-66066
Rails Active Storage's :vips variant processor (the default since Rails 7.0)
passes attacker-uploaded files to libvips without disabling libvips'
"untrusted" (unfuzzed) operations. On the standard Debian/Ubuntu libvips build
— the same build shipped by the official ruby Docker images and installed by
rails new generated Dockerfiles — the untrusted matload operation (matio +
HDF5) is available. An unauthenticated attacker can upload a crafted MATLAB
v7.3 (.mat/HDF5) file whose matrix data lives in HDF5 external storage
segments pointing at an arbitrary absolute path on the server (e.g.
/proc/self/environ). When Active Storage generates an image variant from the
upload, libvips loads it with matload, and HDF5/matio transparently read the
referenced file, returning its bytes as image pixels. The processed variant is
served back to the attacker, yielding an arbitrary file read as the Rails
process user, including the process environment with SECRET_KEY_BASE.
Active Storage 8.0.5.1 / 7.2.3.2 / 8.1.3.1 fix this by calling
Vips.block_untrusted(true) at boot (requiring libvips >= 8.13 and
ruby-vips >= 2.2.1).
- Package/component:
activestorage(Ruby on Rails) variant processing viaruby-vips/image_processing(config.active_storage.variant_processor = :vips, default withload_defaults 7.0and later). - Affected versions: activestorage < 7.2.3.2, >= 8.0 < 8.0.5.1, >= 8.1 < 8.1.3.1, with libvips linked against certain third-party libraries (Debian/Ubuntu default builds include matio/HDF5, ImageMagick, poppler, librsvg).
- Risk level: critical. Unauthenticated arbitrary file read of any file the
Rails process can read (environment secrets, credentials, other users'
data). The advisory notes these secrets (especially
secret_key_base) may enable remote code execution or lateral movement.
Impact Parity
- Disclosed/claimed maximum impact: remote code execution (via arbitrary file read -> secret_key_base -> RCE/lateral movement).
- Reproduced impact in this run: unauthenticated remote code execution
through the production HTTP path, chained as:
- arbitrary file read — the Rails process environment
(
/proc/self/environ) is exfiltrated byte-exactly through the app's ownresize_to_limit: [100, 100]variant, leakingSECRET_KEY_BASE(canary recovered, two independent attempts); - forged signed variation tokens — the leaked secret replicates
Rails.application.message_verifier("ActiveStorage")offline and mints a variation token carrying an unvalidated{"instance_eval": "<ruby>"}transformation (the:vipsImageProcessingTransformer performs no transformation validation; rails issue #56948); - code execution — delivering the forged token via
GET /rails/active_storage/representations/redirect/<signed blob id>/<token>/pwn.pngexecutes the Ruby in the Puma process (unique on-disk markers written in three fresh vulnerable processes; wrong-key control: 404, no marker; fixed 8.0.5.1: chain broken at step 1).
- arbitrary file read — the Rails process environment
(
- Parity: full — unauthenticated RCE on a default-configured app (variant_processor :vips, image_processing 1.x, untrusted uploads with variants displayed), matching the advisory's claimed maximum impact.
Root Cause
Missing hardening call. Before the fix, Active Storage never called
Vips.block_untrusted(true)(libvips >= 8.13) nor setVIPS_BLOCK_UNTRUSTED, so every libvips loader/saver flaggedVIPS_OPERATION_UNTRUSTED("unfuzzed") remained reachable for attacker-controlled uploads. libvips selects loaders by content sniffing, so the web-facing declared MIME type (blob.content_type in variable_content_types) does not constrain which loader actually parses the bytes.A loader that dereferences server-side paths. The untrusted
VipsForeignLoadMat(matload, via matio) reads MATLAB files; v7.3.matfiles are HDF5. HDF5 datasets may keep their payload in external storage segments (H5Pset_external(name, offset, size)), wherenamemay be an absolute path. matio/HDF5 resolve and read those segments transparently, so a crafted dataset's pixel values become the raw bytes of an arbitrary server file. Details that make the payload viable:- libvips
vips__mat_ismat()only accepts files starting withMATLAB 5.0(text prefix at offset 0); - matio's
Mat_Open()ignores the descriptive text and decides v7.3/HDF5 purely from the header version field0x0200and endian indicator (IMon disk for little-endian), then callsH5Fopen(the HDF5 signature lives after the 512-byte user block); - matio requires a
MATLAB_classattribute stored as a fixed-size, NUL-padded ASCII string.
- libvips
End-to-end exfil channel. Active Storage's unauthenticated flow (direct upload -> representations URL) lets the attacker have a variant generated for their own blob:
POST /rails/active_storage/direct_uploadsreturns asigned_idfor the crafted blob (declaredimage/png; no content verification at upload).DiskControllerskips CSRF protection for the subsequent PUT.- Variation URL tokens (
ActiveStorage.verifier.generate(transformations, purpose: :variation)) sign only the transformation hash, not any blob id, so a token scraped from any public page that renders an image variant can be replayed against the attacker's ownsigned_id. RepresentationsController#showsynchronously processes the variant on first request and redirects to the stored PNG.- The ImageProcessing vips pipeline applies a sharpen convolution
(
[-1,-1,-1; -1,32,-1; -1,-1,-1]/24) after thumbnailing. The payload replicates each target byte into three consecutive pixels using one 1-byte external-storage segment per pixel (3 segments per file byte) and a 99x1 matrix, so (a) no resampling occurs underresize_to_limit: [100, 100], and (b) each triple's center pixel has all-equal 3x3 neighbours and survives the convolution byte-exactly.
One quirk matters for real apps: if the crafted blob is attached to a record, the analyzer rewrites
blob.content_typeto the sniffedapplication/x-matlab-data, after whichblob.variable?is false and variants are refused (ActiveStorage::InvariableError). Orphan blobs created by direct upload are never analyzed, so the declaredimage/pngstands — this is the path the exploit uses, and it requires no attachment.The fix.
activestorage/lib/active_storage/vips.rb(new in 8.0.5.1) loads ruby-vips at boot and callsVips.block_untrusted(true), raising at boot when libvips < 8.13 or ruby-vips < 2.2.1 ("unsecurable environment"). With it,matload(and svgload, pdfload, magickload, ...) raiseVips::Error: matload: operation is blocked— exactly the behavior the fixed build shows in this reproduction. Fix commit range:v8.0.5...v8.0.5.1(also 7.2.3.2, 8.1.3.1).
Reproduction Steps
bundle/repro/reproduction_steps.sh(self-contained; run withPRUVA_ROOT=<bundle>or frombundle/repro/).- The script:
- installs ruby (>= 3.2), libvips-dev/tools, python3-h5py, sqlite dev headers, bundler;
- asserts the environment precondition: libvips >= 8.13 with
matloadpresent and flaggeduntrusted(Debian/Ubuntu default build); - generates a minimal but realistic Rails app twice —
rails/activestorage 8.0.5(vulnerable) and8.0.5.1(fixed) — withvariant_processor = :vips, Disk service, an unauthenticated upload form page (CSRF meta tag + a sample image variant URL), andSECRET_KEY_BASEsourced from the process environment containing a canary value; - boots each app with Puma (two clean attempts per build);
- per attempt, as an unauthenticated attacker: GET / (session + CSRF
token + scrape signed variation token), generate the
.matpayload targeting/proc/self/environat increasing offsets, direct-upload each payload (declaredimage/png), replay the scraped variation token against the attacker's own signed blob id, download the served variant PNG, and decode the exfiltrated bytes; - asserts the vulnerable builds leak
SECRET_KEY_BASE=KINDARAILS2SHELL_...and the fixed builds fail closed withVips::Error (matload: operation is blocked).
- Expected evidence:
RESULT: VULNERABILITY CONFIRMEDwithvulnerable leaks=2/2, fixed blocked=2/2, exit code 0.
Evidence
bundle/logs/reproduction_steps.log— full run log; key excerpts:[deps] vips-8.14.1/libvips matload present and marked untrusted* activestorage (8.0.5)vs* activestorage (8.0.5.1),image_processing (1.14.0),ruby-vips (2.3.0)[vuln 1] canary SECRET_KEY_BASE recovered from /proc/self/environfollowed by the leaked environment, containingSECRET_KEY_BASE=KINDARAILS2SHELL_CANARY_...(both attempts)[fixed 1/2] variant processing blocked: Vips::Error (matload: operation is blocked
bundle/logs/attempts/vuln_*/leaked_all.raw— raw exfiltrated/proc/self/environbytes (decoded from the served variant PNGs).bundle/logs/attempts/vuln_*/server.log,fixed_*/server.log— Puma/Rails logs of both builds.bundle/logs/attempts/vuln_*/chunk_*/du.json,pwn.png— per-chunk direct upload responses and served variant images.bundle/repro/runtime_manifest.json— runtime manifest (entrypointendpoint, service/health/target all true).- Environment: Debian bookworm (ruby:3.4-bookworm container), ruby 3.4.10,
libvips 8.14.1 (matio/HDF5 build,
matloaduntrusted), matio 1.5.21, HDF5 1.10, no sanitizers (production-path proof).
Recommendations / Next Steps
- Upgrade to activestorage 7.2.3.2 / 8.0.5.1 / 8.1.3.1 (or later) and
libvips >= 8.13; rotate
secret_key_baseand any credentials present in the application environment. - Stopgap on libvips >= 8.13: set
VIPS_BLOCK_UNTRUSTED=1or callVips.block_untrusted(true)in an initializer; on libvips < 8.13 remove the ruby-vips dependency entirely. - The same hardening should be considered defense-in-depth for any product
that runs libvips on untrusted content without
block_untrusted. - Escalation artifacts:
bundle/repro/escalation_experiments.sh(runnable),bundle/logs/escalation/escalation.log,rce_marker.txt,rce_marker2.txt(unique markers written by injected code inside fresh Puma processes),negative_control.json+srvNC.log(wrong-key control, HTTP 404, no marker),logs/escalation/leak/(full-environ leak used to recover the complete secret). The second-stageinstance_evaltransformation injection relies on the:vipstransformer applying no transformation validation (rails issue #56948); it is only reachable to an unauthenticated attacker because variation tokens are signed and the CVE-2026-66066 file read yields the signing secret. - Note for testing:
image_processing2.x independently callsVips.block_untrusted(true)when it loads; pin observations accordingly when evaluating exploitability (this reproduction pins 1.14.0 so the only variable is the activestorage version).
Additional Notes
- Idempotency: the script resets each app's DB/storage per attempt and was
run twice consecutively in a fresh container; both runs passed
(
vulnerable leaks=2/2, fixed blocked=2/2). - Other untrusted loaders on Debian/Ubuntu libvips (svgload, pdfload,
openslideload, magickload, jxlload, jp2kload, fitsload, openexrload,
analyzeload, radload, ppmload, csvload, rawload, vipsload, matload) are
additional candidate vectors; the ImageMagick route is heavily restricted on
Debian/Ubuntu by the
@*path policy, MVG/MSL stealth registration andStrictReadImagenested-coder blocking, while the matload/HDF5 route used here works on the default build. The upstream disclosure notes the reported chain may differ; any single untrusted loader suffices to prove the CVE. - The chunk size (33 bytes/request) is an artifact of defeating the
sharpen convolution under
resize_to_limit: [100, 100]; larger chunks are possible with larger variant limits or format-only variants. - The escalation was additionally validated with negative controls: a forged
token signed with a wrong key is rejected (HTTP 404, no marker), and the
fixed app (8.0.5.1) blocks the initial file read (
matload: operation is blocked), leaving the signing key unobtainable. Seebundle/learning/exploit_escalation.json(outcome: demonstrated) andbundle/repro/exploit_knowledge.jsonfor the recorded primitives and the derived command-execution capability.
CVE-2026-66066 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls /pruva/project-cache/ 2>/dev/null; which ruby; ruby -v 2>/dev/null; which docker; gem --version 2>/dev/null{"cwd":"/workspace","duration_ms":49,"exit_code":127,"stderr":"","stdout":"repo-mirrors\n/usr/bin/docker\n","success":false,"timeout_secs":120}Unknown error
Artifacts and Evidence for CVE-2026-66066
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-66066
FAQ: CVE-2026-66066
Is CVE-2026-66066 exploitable?
How severe is CVE-2026-66066?
What type of vulnerability is CVE-2026-66066?
Which versions of rails/rails are affected by CVE-2026-66066?
How can I reproduce CVE-2026-66066?
Is the CVE-2026-66066 reproduction verified?
References for CVE-2026-66066
Authoritative sources for CVE-2026-66066 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.