# 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": "<ruby>"}`
     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/<signed blob id>/<token>/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=<bundle>` 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.
