{"repro_id":"REPRO-2026-00317","version":6,"title":"Rails Active Storage variant processing arbitrary file read and potential RCE","repro_type":"security","status":"published","severity":"critical","description":"GHSA-xr9x-r78c-5hrm / CVE-2026-66066. In Rails Active Storage, the default libvips-based variant processing path can allow an unauthenticated attacker who can upload crafted image content to cause arbitrary file reads from the server filesystem, including process environment secrets such as secret_key_base. Those secrets can then enable remote code execution or lateral movement. Affected configurations use config.active_storage.variant_processor = :vips and allow untrusted image uploads. Fixed versions are activestorage 7.2.3.2, 8.0.5.1, and 8.1.3.1. libvips >= 8.13 is required for secure blocking of untrusted operations; older libvips versions have no secure workaround other than removing the dependency on libvips.","root_cause":"# Root Cause Analysis — CVE-2026-66066 (GHSA-xr9x-r78c-5hrm)\n\n## Summary\n\nRails Active Storage's `:vips` variant processor (the default since Rails 7.0)\npasses attacker-uploaded files to libvips without disabling libvips'\n\"untrusted\" (unfuzzed) operations. On the standard Debian/Ubuntu libvips build\n— the same build shipped by the official `ruby` Docker images and installed by\n`rails new` generated Dockerfiles — the untrusted `matload` operation (matio +\nHDF5) is available. An unauthenticated attacker can upload a crafted MATLAB\nv7.3 (`.mat`/HDF5) file whose matrix data lives in **HDF5 external storage\nsegments pointing at an arbitrary absolute path** on the server (e.g.\n`/proc/self/environ`). When Active Storage generates an image variant from the\nupload, libvips loads it with `matload`, and HDF5/matio transparently read the\nreferenced file, returning its bytes as image pixels. The processed variant is\nserved back to the attacker, yielding an **arbitrary file read as the Rails\nprocess user**, including the process environment with `SECRET_KEY_BASE`.\nActive Storage 8.0.5.1 / 7.2.3.2 / 8.1.3.1 fix this by calling\n`Vips.block_untrusted(true)` at boot (requiring libvips >= 8.13 and\nruby-vips >= 2.2.1).\n\n## Impact\n\n- Package/component: `activestorage` (Ruby on Rails) variant processing via\n  `ruby-vips`/`image_processing` (`config.active_storage.variant_processor = :vips`,\n  default with `load_defaults 7.0` and later).\n- Affected versions: activestorage < 7.2.3.2, >= 8.0 < 8.0.5.1, >= 8.1 < 8.1.3.1,\n  with libvips linked against certain third-party libraries (Debian/Ubuntu\n  default builds include matio/HDF5, ImageMagick, poppler, librsvg).\n- Risk level: critical. Unauthenticated arbitrary file read of any file the\n  Rails process can read (environment secrets, credentials, other users'\n  data). The advisory notes these secrets (especially `secret_key_base`) may\n  enable remote code execution or lateral movement.\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: remote code execution (via arbitrary file\n  read -> secret_key_base -> RCE/lateral movement).\n- Reproduced impact in this run: **unauthenticated remote code execution**\n  through the production HTTP path, chained as:\n  1. arbitrary file read — the Rails process environment\n     (`/proc/self/environ`) is exfiltrated byte-exactly through the app's own\n     `resize_to_limit: [100, 100]` variant, leaking `SECRET_KEY_BASE`\n     (canary recovered, two independent attempts);\n  2. forged signed variation tokens — the leaked secret replicates\n     `Rails.application.message_verifier(\"ActiveStorage\")` offline and mints a\n     variation token carrying an unvalidated `{\"instance_eval\": \"<ruby>\"}`\n     transformation (the `:vips` ImageProcessingTransformer performs no\n     transformation validation; rails issue #56948);\n  3. code execution — delivering the forged token via\n     `GET /rails/active_storage/representations/redirect/<signed blob id>/<token>/pwn.png`\n     executes the Ruby in the Puma process (unique on-disk markers written in\n     three fresh vulnerable processes; wrong-key control: 404, no marker;\n     fixed 8.0.5.1: chain broken at step 1).\n- Parity: **full** — unauthenticated RCE on a default-configured app\n  (variant_processor :vips, image_processing 1.x, untrusted uploads with\n  variants displayed), matching the advisory's claimed maximum impact.\n\n## Root Cause\n\n1. **Missing hardening call.** Before the fix, Active Storage never called\n   `Vips.block_untrusted(true)` (libvips >= 8.13) nor set\n   `VIPS_BLOCK_UNTRUSTED`, so every libvips loader/saver flagged\n   `VIPS_OPERATION_UNTRUSTED` (\"unfuzzed\") remained reachable for\n   attacker-controlled uploads. libvips selects loaders by **content\n   sniffing**, so the web-facing declared MIME type\n   (`blob.content_type in variable_content_types`) does not constrain which\n   loader actually parses the bytes.\n\n2. **A loader that dereferences server-side paths.** The untrusted\n   `VipsForeignLoadMat` (`matload`, via matio) reads MATLAB files; v7.3 `.mat`\n   files are HDF5. HDF5 datasets may keep their payload in **external storage\n   segments** (`H5Pset_external(name, offset, size)`), where `name` may be an\n   absolute path. matio/HDF5 resolve and read those segments transparently, so\n   a crafted dataset's pixel values become the raw bytes of an arbitrary\n   server file. Details that make the payload viable:\n   - libvips `vips__mat_ismat()` only accepts files starting with\n     `MATLAB 5.0` (text prefix at offset 0);\n   - matio's `Mat_Open()` ignores the descriptive text and decides v7.3/HDF5\n     purely from the header **version field `0x0200`** and endian indicator\n     (`IM` on disk for little-endian), then calls `H5Fopen` (the HDF5\n     signature lives after the 512-byte user block);\n   - matio requires a `MATLAB_class` attribute stored as a fixed-size,\n     NUL-padded ASCII string.\n\n3. **End-to-end exfil channel.** Active Storage's unauthenticated flow\n   (direct upload -> representations URL) lets the attacker have a variant\n   generated for their own blob:\n   - `POST /rails/active_storage/direct_uploads` returns a `signed_id` for the\n     crafted blob (declared `image/png`; no content verification at upload).\n     `DiskController` skips CSRF protection for the subsequent PUT.\n   - Variation URL tokens (`ActiveStorage.verifier.generate(transformations,\n     purpose: :variation)`) sign **only the transformation hash**, not any\n     blob id, so a token scraped from any public page that renders an image\n     variant can be replayed against the attacker's own `signed_id`.\n   - `RepresentationsController#show` synchronously processes the variant on\n     first request and redirects to the stored PNG.\n   - The ImageProcessing vips pipeline applies a sharpen convolution\n     (`[-1,-1,-1; -1,32,-1; -1,-1,-1]/24`) after thumbnailing. The payload\n     replicates each target byte into three consecutive pixels using **one\n     1-byte external-storage segment per pixel (3 segments per file byte)**\n     and a 99x1 matrix, so (a) no resampling occurs under\n     `resize_to_limit: [100, 100]`, and (b) each triple's center pixel has\n     all-equal 3x3 neighbours and survives the convolution byte-exactly.\n\n   One quirk matters for real apps: if the crafted blob is *attached* to a\n   record, the analyzer rewrites `blob.content_type` to the sniffed\n   `application/x-matlab-data`, after which `blob.variable?` is false and\n   variants are refused (`ActiveStorage::InvariableError`). Orphan blobs\n   created by direct upload are never analyzed, so the declared `image/png`\n   stands — this is the path the exploit uses, and it requires no attachment.\n\n4. **The fix.** `activestorage/lib/active_storage/vips.rb` (new in 8.0.5.1)\n   loads ruby-vips at boot and calls `Vips.block_untrusted(true)`, raising at\n   boot when libvips < 8.13 or ruby-vips < 2.2.1 (\"unsecurable environment\").\n   With it, `matload` (and svgload, pdfload, magickload, ...) raise\n   `Vips::Error: matload: operation is blocked` — exactly the behavior the\n   fixed build shows in this reproduction.\n   Fix commit range: `v8.0.5...v8.0.5.1` (also 7.2.3.2, 8.1.3.1).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; run with\n   `PRUVA_ROOT=<bundle>` or from `bundle/repro/`).\n2. The script:\n   - installs ruby (>= 3.2), libvips-dev/tools, python3-h5py, sqlite dev\n     headers, bundler;\n   - asserts the environment precondition: libvips >= 8.13 with `matload`\n     present and flagged `untrusted` (Debian/Ubuntu default build);\n   - generates a minimal but realistic Rails app twice —\n     `rails/activestorage 8.0.5` (vulnerable) and `8.0.5.1` (fixed) — with\n     `variant_processor = :vips`, Disk service, an unauthenticated upload form\n     page (CSRF meta tag + a sample image variant URL), and\n     `SECRET_KEY_BASE` sourced from the process environment containing a\n     canary value;\n   - boots each app with Puma (two clean attempts per build);\n   - per attempt, as an unauthenticated attacker: GET / (session + CSRF\n     token + scrape signed variation token), generate the `.mat` payload\n     targeting `/proc/self/environ` at increasing offsets, direct-upload each\n     payload (declared `image/png`), replay the scraped variation token\n     against the attacker's own signed blob id, download the served variant\n     PNG, and decode the exfiltrated bytes;\n   - asserts the vulnerable builds leak `SECRET_KEY_BASE=KINDARAILS2SHELL_...`\n     and the fixed builds fail closed with\n     `Vips::Error (matload: operation is blocked)`.\n3. Expected evidence: `RESULT: VULNERABILITY CONFIRMED` with\n   `vulnerable leaks=2/2, fixed blocked=2/2`, exit code 0.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full run log; key excerpts:\n  - `[deps] vips-8.14.1` / `libvips matload present and marked untrusted`\n  - `* activestorage (8.0.5)` vs `* activestorage (8.0.5.1)`,\n    `image_processing (1.14.0)`, `ruby-vips (2.3.0)`\n  - `[vuln 1] canary SECRET_KEY_BASE recovered from /proc/self/environ`\n    followed by the leaked environment, containing\n    `SECRET_KEY_BASE=KINDARAILS2SHELL_CANARY_...` (both attempts)\n  - `[fixed 1/2] variant processing blocked: Vips::Error (matload: operation is blocked`\n- `bundle/logs/attempts/vuln_*/leaked_all.raw` — raw exfiltrated\n  `/proc/self/environ` bytes (decoded from the served variant PNGs).\n- `bundle/logs/attempts/vuln_*/server.log`, `fixed_*/server.log` — Puma/Rails\n  logs of both builds.\n- `bundle/logs/attempts/vuln_*/chunk_*/du.json`, `pwn.png` — per-chunk direct\n  upload responses and served variant images.\n- `bundle/repro/runtime_manifest.json` — runtime manifest (entrypoint\n  `endpoint`, service/health/target all true).\n- Environment: Debian bookworm (ruby:3.4-bookworm container), ruby 3.4.10,\n  libvips 8.14.1 (matio/HDF5 build, `matload` untrusted), matio 1.5.21,\n  HDF5 1.10, no sanitizers (production-path proof).\n\n## Recommendations / Next Steps\n\n- Upgrade to activestorage 7.2.3.2 / 8.0.5.1 / 8.1.3.1 (or later) **and**\n  libvips >= 8.13; rotate `secret_key_base` and any credentials present in the\n  application environment.\n- Stopgap on libvips >= 8.13: set `VIPS_BLOCK_UNTRUSTED=1` or call\n  `Vips.block_untrusted(true)` in an initializer; on libvips < 8.13 remove the\n  ruby-vips dependency entirely.\n- The same hardening should be considered defense-in-depth for *any* product\n  that runs libvips on untrusted content without `block_untrusted`.\n- Escalation artifacts: `bundle/repro/escalation_experiments.sh` (runnable),\n  `bundle/logs/escalation/escalation.log`, `rce_marker.txt`, `rce_marker2.txt`\n  (unique markers written by injected code inside fresh Puma processes),\n  `negative_control.json` + `srvNC.log` (wrong-key control, HTTP 404, no\n  marker), `logs/escalation/leak/` (full-environ leak used to recover the\n  complete secret). The second-stage `instance_eval` transformation injection\n  relies on the `:vips` transformer applying no transformation validation\n  (rails issue #56948); it is only reachable to an unauthenticated attacker\n  because variation tokens are signed and the CVE-2026-66066 file read yields\n  the signing secret.\n- Note for testing: `image_processing` 2.x independently calls\n  `Vips.block_untrusted(true)` when it loads; pin observations accordingly\n  when evaluating exploitability (this reproduction pins 1.14.0 so the only\n  variable is the activestorage version).\n\n## Additional Notes\n\n- Idempotency: the script resets each app's DB/storage per attempt and was\n  run twice consecutively in a fresh container; both runs passed\n  (`vulnerable leaks=2/2, fixed blocked=2/2`).\n- Other untrusted loaders on Debian/Ubuntu libvips (svgload, pdfload,\n  openslideload, magickload, jxlload, jp2kload, fitsload, openexrload,\n  analyzeload, radload, ppmload, csvload, rawload, vipsload, matload) are\n  additional candidate vectors; the ImageMagick route is heavily restricted on\n  Debian/Ubuntu by the `@*` path policy, MVG/MSL stealth registration and\n  `StrictReadImage` nested-coder blocking, while the matload/HDF5 route used\n  here works on the default build. The upstream disclosure notes the reported\n  chain may differ; any single untrusted loader suffices to prove the CVE.\n- The chunk size (33 bytes/request) is an artifact of defeating the\n  sharpen convolution under `resize_to_limit: [100, 100]`; larger chunks are\n  possible with larger variant limits or format-only variants.\n- The escalation was additionally validated with negative controls: a forged\n  token signed with a wrong key is rejected (HTTP 404, no marker), and the\n  fixed app (8.0.5.1) blocks the initial file read (`matload: operation is\n  blocked`), leaving the signing key unobtainable. See\n  `bundle/learning/exploit_escalation.json` (outcome: demonstrated) and\n  `bundle/repro/exploit_knowledge.json` for the recorded primitives and the\n  derived command-execution capability.\n","cve_id":"CVE-2026-66066","cwe_id":"CWE-1188","source_url":"https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm","package":{"name":"rails/rails","ecosystem":"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"},"reproduced_at":"2026-08-01T05:51:29.604779+00:00","duration_secs":8922.0,"tool_calls":356,"handoffs":3,"total_cost_usd":17.088089,"agent_costs":{"claim_matcher":0.029236,"coding":0.714049,"judge":0.769801,"learning_policy":0.011004,"repro":13.799059,"support":0.12299,"vuln_variant":1.64195},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.029236},"coding":{"accounts/fireworks/models/kimi-k3":0.714049},"judge":{"gpt-5.6-sol":0.769801},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.011004},"repro":{"accounts/fireworks/models/kimi-k3":13.799059},"support":{"accounts/fireworks/models/kimi-k3":0.12299},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.64195}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant","coding"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-01T05:51:30.402573+00:00","retracted":false,"artifacts":[{"path":"bundle/coding/proposed_fix.diff","filename":"proposed_fix.diff","size":2018,"category":"patch"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":12912,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":32103,"category":"reproduction_script"},{"path":"bundle/coding/summary_report.md","filename":"summary_report.md","size":5437,"category":"documentation"},{"path":"bundle/coding/verify_fix.log","filename":"verify_fix.log","size":911,"category":"log"},{"path":"bundle/coding/verify_fix.sh","filename":"verify_fix.sh","size":6819,"category":"other"},{"path":"bundle/logs/attempts/fixed_2/server.log","filename":"server.log","size":16487,"category":"log"},{"path":"bundle/logs/attempts/vuln_1/index.html","filename":"index.html","size":756,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/server.log","filename":"server.log","size":143955,"category":"log"},{"path":"bundle/logs/attempts/vuln_2/leaked_all.raw","filename":"leaked_all.raw","size":429,"category":"other"},{"path":"bundle/logs/bundle_fixed.log","filename":"bundle_fixed.log","size":210,"category":"log"},{"path":"bundle/logs/bundle_vuln.log","filename":"bundle_vuln.log","size":210,"category":"log"},{"path":"bundle/repro/escalation_experiments.sh","filename":"escalation_experiments.sh","size":9066,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1245,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1291,"category":"other"}]}