# REPRO-2026-00317: Rails Active Storage variant processing arbitrary file read and potential RCE ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-1188 Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00317 CVE: CVE-2026-66066 ## Package Name: rails/rails Ecosystem: github Affected: 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 Fixed: Unknown ## Root Cause # Root Cause Analysis — CVE-2026-66066 (GHSA-xr9x-r78c-5hrm) ## Summary 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). ## Impact - Package/component: `activestorage` (Ruby on Rails) variant processing via `ruby-vips`/`image_processing` (`config.active_storage.variant_processor = :vips`, default with `load_defaults 7.0` and 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: 1. arbitrary file read — the Rails process environment (`/proc/self/environ`) is exfiltrated byte-exactly through the app's own `resize_to_limit: [100, 100]` variant, leaking `SECRET_KEY_BASE` (canary recovered, two independent attempts); 2. forged signed variation tokens — the leaked secret replicates `Rails.application.message_verifier("ActiveStorage")` offline and mints a variation token carrying an unvalidated `{"instance_eval": ""}` transformation (the `:vips` ImageProcessingTransformer performs no transformation validation; rails issue #56948); 3. code execution — delivering the forged token via `GET /rails/active_storage/representations/redirect///pwn.png` executes 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). - 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 1. **Missing hardening call.** Before the fix, Active Storage never called `Vips.block_untrusted(true)` (libvips >= 8.13) nor set `VIPS_BLOCK_UNTRUSTED`, so every libvips loader/saver flagged `VIPS_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. 2. **A loader that dereferences server-side paths.** The untrusted `VipsForeignLoadMat` (`matload`, via matio) reads MATLAB files; v7.3 `.mat` files are HDF5. HDF5 datasets may keep their payload in **external storage segments** (`H5Pset_external(name, offset, size)`), where `name` may 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 with `MATLAB 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 field `0x0200`** and endian indicator (`IM` on disk for little-endian), then calls `H5Fopen` (the HDF5 signature lives after the 512-byte user block); - matio requires a `MATLAB_class` attribute stored as a fixed-size, NUL-padded ASCII string. 3. **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_uploads` returns a `signed_id` for the crafted blob (declared `image/png`; no content verification at upload). `DiskController` skips 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 own `signed_id`. - `RepresentationsController#show` synchronously 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 under `resize_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_type` to the sniffed `application/x-matlab-data`, after which `blob.variable?` is false and variants are refused (`ActiveStorage::InvariableError`). Orphan blobs created by direct upload are never analyzed, so the declared `image/png` stands — this is the path the exploit uses, and it requires no attachment. 4. **The fix.** `activestorage/lib/active_storage/vips.rb` (new in 8.0.5.1) loads ruby-vips at boot and calls `Vips.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, ...) raise `Vips::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 1. `bundle/repro/reproduction_steps.sh` (self-contained; run with `PRUVA_ROOT=` or from `bundle/repro/`). 2. The script: - installs ruby (>= 3.2), libvips-dev/tools, python3-h5py, sqlite dev headers, bundler; - asserts the environment precondition: libvips >= 8.13 with `matload` present and flagged `untrusted` (Debian/Ubuntu default build); - generates a minimal but realistic Rails app twice — `rails/activestorage 8.0.5` (vulnerable) and `8.0.5.1` (fixed) — with `variant_processor = :vips`, Disk service, an unauthenticated upload form page (CSRF meta tag + a sample image variant URL), and `SECRET_KEY_BASE` sourced 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 `.mat` payload targeting `/proc/self/environ` at increasing offsets, direct-upload each payload (declared `image/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 with `Vips::Error (matload: operation is blocked)`. 3. Expected evidence: `RESULT: VULNERABILITY CONFIRMED` with `vulnerable 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/environ` followed by the leaked environment, containing `SECRET_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/environ` bytes (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 (entrypoint `endpoint`, service/health/target all true). - Environment: Debian bookworm (ruby:3.4-bookworm container), ruby 3.4.10, libvips 8.14.1 (matio/HDF5 build, `matload` untrusted), 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_base` and any credentials present in the application environment. - Stopgap on libvips >= 8.13: set `VIPS_BLOCK_UNTRUSTED=1` or call `Vips.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-stage `instance_eval` transformation injection relies on the `:vips` transformer 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_processing` 2.x independently calls `Vips.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 and `StrictReadImage` nested-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. See `bundle/learning/exploit_escalation.json` (outcome: demonstrated) and `bundle/repro/exploit_knowledge.json` for the recorded primitives and the derived command-execution capability. ## Reproduction Details Reproduced: 2026-08-01T05:51:29.604Z Duration: 8922 seconds Tool calls: 356 Turns: Unknown Handoffs: 3 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00317 pruva-verify CVE-2026-66066 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00317&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00317/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-66066 - Source: https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm ## Artifacts - bundle/coding/proposed_fix.diff (patch, 2018 bytes) - bundle/repro/rca_report.md (analysis, 12912 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 32103 bytes) - bundle/coding/summary_report.md (documentation, 5437 bytes) - bundle/coding/verify_fix.log (log, 911 bytes) - bundle/coding/verify_fix.sh (other, 6819 bytes) - bundle/logs/attempts/fixed_2/server.log (log, 16487 bytes) - bundle/logs/attempts/vuln_1/index.html (other, 756 bytes) - bundle/logs/attempts/vuln_1/server.log (log, 143955 bytes) - bundle/logs/attempts/vuln_2/leaked_all.raw (other, 429 bytes) - bundle/logs/bundle_fixed.log (log, 210 bytes) - bundle/logs/bundle_vuln.log (log, 210 bytes) - bundle/repro/escalation_experiments.sh (other, 9066 bytes) - bundle/repro/runtime_manifest.json (other, 1245 bytes) - bundle/repro/validation_verdict.json (other, 1291 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00317 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00317/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/reproductions/REPRO-2026-00317 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev