# REPRO-2026-00389: Rails Active Storage ImageMagick image analysis: arbitrary file write and read (possible RCE) ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-1188 Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00389 GHSA: GHSA-F3W6-W445-FWH9 ## Package Name: activestorage (rubygems) Ecosystem: rubygems Affected: activestorage < 8.0.5.2 and >= 8.1, < 8.1.4.1 Fixed: 8.0.5.2, 8.1.4.1 ## Root Cause # Root Cause Analysis — GHSA-f3w6-w445-fwh9 Active Storage image analysis: arbitrary file write, arbitrary file read, and code execution through ImageMagick's MSL scripting coder reached from an attached upload. ## Summary `activestorage` hands every attached upload to ImageMagick for image analysis without constraining which of ImageMagick's coders the file may reach. ImageMagick selects a coder from the **content** of the file it is given, and some coders act on files *named inside* the image rather than on the image itself. A file whose content is XML is sniffed as `SVG` (ImageMagick's built-in magic table maps `?xml`/`?xml`/`SVG`/`svg` at offset 1 to the SVG coder), and ImageMagick's own SVG renderer resolves the `xlink:href` of every `` element through `ReadImage`. Because that reference may carry an **explicit coder prefix**, an uploaded SVG containing `` makes ImageMagick parse the named file with the **MSL** ("Magick Scripting Language") coder. MSL is a scripting coder that reads and writes the files its script names, so the upload alone — no variant, preview, or `analyze` call — gives an unauthenticated attacker arbitrary file write, and (with the Disk service, where the attacker's own blob path is knowable) arbitrary file read. This run reproduced the full chain against the real product code: `activestorage` at the parent of the fix commit, driven through a real HTTP upload endpoint, with a real Puma server, a real Active Storage Disk service, the real ImageMagick analyzer, and a real ImageMagick process. It also demonstrated escalation to code execution by writing the attacker's content into a path the application loads and renders. ## Impact - **Component**: `activestorage` (Ruby on Rails) — `ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick` and, more fundamentally, the absence of any ImageMagick coder restriction in Active Storage. - **Affected versions**: `activestorage < 8.0.5.2` and `>= 8.1, < 8.1.4.1`; an application is affected only when all of the following hold: 1. `config.active_storage.variant_processor = :mini_magick` (default for `load_defaults` 6.1 or earlier; the same setting selects the analyzer), 2. ImageMagick runs under a policy that does not deny the coders involved (the policies shipped by Debian and Ubuntu do not deny MSL), 3. untrusted users can attach uploads. - **Risk level**: critical (advisory CVSS 3.0 9.8, `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`). - **Consequences observed in this run**: attacker-chosen file written at an attacker-chosen path with attacker-chosen bytes; files readable by the application process read and exfiltrated over HTTP (the process environment including `SECRET_KEY_BASE`, and a planted credential file); attacker-supplied Ruby executed inside the application process. ## Impact Parity - **Disclosed/claimed maximum impact**: the advisory asserts *arbitrary file write* and *arbitrary file read when attachments are stored with the Disk service*, and states that readable secrets "may in turn allow escalation to remote code execution". The submitted claim contract expects `code_execution` on the `api_remote` surface. - **Reproduced impact from this run**: - arbitrary file write — `full` (exact attacker bytes at an attacker-chosen absolute path), - arbitrary file read — `full` (a planted credential file and `/proc/self/environ` including `SECRET_KEY_BASE`, both exfiltrated over HTTP through the attacker's own blob), - code execution — demonstrated (unique marker file content plus `id` output reflected in an HTTP response). - **Parity**: `full`. - **Not demonstrated / conditions**: the code-execution leg required the write target to be a path the application loads and re-reads, which the reproduced application has because it runs with code reloading enabled (Rails development defaults). The advisory-asserted write and read primitives are unconditional. A deployment with eager loading, template caching, and a non-writable application tree would still be fully exposed to arbitrary file write and read; its escalation to code execution would then depend on the deployment (a scheduled job, a writable autoload path, an executable script, `ld.so.preload` for a root-run process, and so on) and was not separately validated here. ## Root Cause 1. **Coder selection is attacker-controlled.** `ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick#read_image` downloads the blob and runs `MiniMagick::Image.new(file.path)` (i.e. `magick identify` on the file). Nothing in Active Storage restricts the set of coders ImageMagick may pick, so the uploader decides which parser handles the bytes. ImageMagick's built-in magic table maps XML-looking content to the SVG coder. 2. **The SVG coder resolves references through ImageMagick's read path.** `coders/svg.c` stores the `xlink:href` of `` in `svg_info->url` and passes it to `StrictReadImage()`/`ReadImage()` (and emits `image Over x,y w,h "url"` into the MVG it renders). An explicit coder prefix in that reference is honoured, so `msl:` selects the MSL coder for the named file. 3. **MSL acts on files named inside the script.** `coders/msl.c` implements `` and ``: `read` calls `ReadImage` on any filename (raw coders such as `gray:` read the file's bytes verbatim) and `write` calls `WriteImage` to any path. Both run with the privileges of the Rails process. 4. **The Disk service makes the primitive remotely useful.** The blob key is recoverable by decoding the *signed* (not encrypted) disk-service token that Active Storage puts in the `Location` header of the blob redirect, so the attacker knows the on-disk path of their own blob. The MSL script can therefore copy any readable file's bytes into that blob file, and the attacker downloads them through the ordinary blob URL — an arbitrary file read over HTTP. Overwriting any other writable path gives the arbitrary file write. 5. **Trigger point.** `ActiveStorage::Attachment` runs `after_create_commit :analyze_blob_later`, so analysis — and therefore the MSL execution — happens in the request that attaches the file. No variant, preview, or explicit `analyze` call is needed. Fix commit: `ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776` ("Ship an ImageMagick security policy for every MiniMagick process", 8.0 branch; 8.1 branch `468aa9a93fc98f5476b6821770f4ccfcb7f7d328`). The fix ships `activestorage/lib/active_storage/image_magick/policy.xml` and prepends its directory to `MAGICK_CONFIGURE_PATH` through `MiniMagick.cli_env`, so every ImageMagick process MiniMagick spawns denies `MSL`, the other file-reaching coders, the network coders, the Ghostscript coders/delegate, and `@filename` indirection. It also raises at boot for `:mini_magick` applications whose `mini_magick` is older than 5.1 (which cannot apply the policy) — a behaviour observed directly in this run when the fixed application was first started with `mini_magick 5.0.1`. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` is the single entrypoint. It: - resolves the Rails repository from `bundle/project_cache_context.json` (`/repo`), creates a worktree of the vulnerable commit (`ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776^`, `RAILS_VERSION` 8.0.5.1) and of the fixed commit (`ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776`), and asserts the patch is absent from the first and present in the second; - builds `bundle/repro/docker/Dockerfile`: ImageMagick 7.1.1-32 built from upstream source **without librsvg** (so the SVG coder uses ImageMagick's own internal renderer, exactly as in the reported chain) under the Debian/Ubuntu-style `policy.xml` (which does not deny MSL), plus two minimal but real Rails applications (`/app-vuln`, `/app-fixed`) that use `activestorage` from the two worktrees, `variant_processor = :mini_magick`, the Disk service, `active_job.queue_adapter = :inline`, `mini_magick 5.0.x` for the vulnerable app and `mini_magick 5.1+` for the fixed app; - starts both applications under Puma with `SECRET_KEY_BASE` in the process environment, plants a credential file at `/srv/pruva_credentials.txt`, and runs `bundle/repro/attack/attack.py` twice against each application. 2. `attack.py` is a pure HTTP client. It fetches the upload form and its CSRF token, attaches four uploads through `POST /uploads` (an exfiltration blob, a second exfiltration blob, the attacker's payload bytes, and the MSL script), recovers each blob key by decoding the signed disk token in the blob redirect, attaches the SVG `` (which triggers the analyzer at attach time), retries the environment read with decreasing sizes, and then verifies the effects. 3. Expected evidence: on the vulnerable application the attacker's bytes appear at `/tmp/pruva_arbitrary_write.txt`, the attacker's own blob now carries the server's process environment (including `SECRET_KEY_BASE`) and the planted credential file, and a subsequent page request executes the template the MSL write replaced. On the fixed application nothing happens and the server log shows ImageMagick refusing the coder: `attempt to perform an operation not allowed by the security policy 'MSL'`. ## Evidence All paths are relative to `bundle/`. | Evidence | Location | | --- | --- | | Reproduction log (both runs) | `logs/reproduction_steps.log` | | Vulnerable attempt 1 / 2 attack transcript | `logs/attempts/vuln_1/attack.stdout`, `logs/attempts/vuln_2/attack.stdout` | | Structured per-attempt results | `logs/attempts/vuln_1/summary.json`, `logs/attempts/vuln_2/summary.json` | | Delivered MSL script and SVG | `logs/attempts/vuln_1/05_payload_env1024.msl`, `logs/attempts/vuln_1/06_payload_env1024.svg` | | Arbitrary write target content | `logs/attempts/vuln_1/10_arbitrary_write.txt` | | Exfiltrated bytes (raw / newline-separated) | `logs/attempts/vuln_1/11_exfil_blob_download.bin`, `logs/attempts/vuln_1/12_exfil_environ.txt` | | Planted credential recovered through the blob | `logs/attempts/vuln_1/11b_exfil_canary_download.bin` | | Code execution in the HTTP response | `logs/attempts/vuln_1/13_response_after_template_write.html` | | Target-local RCE marker files | `logs/attempts/vuln_1/14_rce_marker.txt`, `logs/attempts/vuln_2/14_rce_marker.txt` | | Negative-control observations (fix commit) | `logs/attempts/fixed_1/negative_control.json`, `logs/attempts/fixed_2/negative_control.json` | | Fixed attempt transcripts and server policy denials | `logs/attempts/fixed_1/attack.stdout`, `logs/attempts/fixed_1/server.log` | Key excerpts (vulnerable application, attempt 1): ``` exfiltration channel on disk: /app-vuln/storage/fe/8k/fe8k0q5kmsjoxy2exjuo5r4a43t7 attached script_env1024.msl (1888 bytes, application/octet-stream) -> /uploads/3 delivering SVG env1024: arbitrary write to /tmp/pruva_arbitrary_write.txt: matches attacker content environment read with 64 samples (128 bytes) returned 1664 bytes exfiltrated 1664 bytes through the attacker's own blob (SECRET_KEY_BASE present: True, planted credential recovered: True) response after the template write contains command output: 'uid=0(root) gid=0(root) groups=0(root)' server-side RCE marker file content: 'PRUVA_RCE_841517157f8b' (expected 'PRUVA_RCE_841517157f8b') ``` Exfiltrated environment (fragment, from `logs/attempts/vuln_1/12_exfil_environ.txt`): ``` HOSTNAME=32925638596e RUBY_VERSION=3.4.12 PWD=/app-vuln ... SECRET_KEY_BASE=04c5dd7e95... ``` Fixed application (both attempts): ``` exfiltrated 68 bytes through the attacker's own blob (SECRET_KEY_BASE present: False, planted credential recovered: False) response after the template write contains command output: None identify: attempt to perform an operation not allowed by the security policy `MSL' @ error/constitute.c/IsCoderAuthorized/454. ``` Environment: Linux x86_64, Docker 27.5.1, `ruby:3.4-bookworm`, Ruby 3.4.12, `activestorage` 8.0.5.1 (vulnerable worktree `7fe85a72a789eae5ee070ce3ee1dad10a8a5f70c`) and the fix commit `ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776` for the control, `mini_magick` 5.0.1 / 5.4.0, ImageMagick 7.1.1-32 Q16-HDRI (source build, internal SVG renderer, Debian/Ubuntu-style `policy.xml` that does not deny MSL), Puma, SQLite, Active Storage Disk service. ## Recommendations / Next Steps - **Apply the vendor fix**: upgrade `activestorage` to 8.0.5.2 / 8.1.4.1 or later, which ships the ImageMagick policy and applies it through `MiniMagick.cli_env`; with `:mini_magick`, also upgrade `mini_magick` to 5.1 or later (older versions make the fixed release raise at boot, as observed here). - **Do not rely on the host policy alone.** A host policy that denies `MSL` blocks the reported chain, but the advisory is explicit that it does not assume this is the only chain; the shipped policy is a denylist and the variant-analysis stage should test its completeness (coder aliases, host policy re-allow rules placed after the shipped one, GraphicsMagick, applications that replace `MiniMagick.cli_env`/`cli_prefix` or install their own `MiniMagick::Shell`). - **Constrain coders, not just files**: prefer `:vips` for variant processing, and treat "attach" as an untrusted-input boundary — analysis must not run with privileges or file access beyond the blob. - **Run the app without write access to its own code** and without reloading in production; that removes the demonstrated write→execution path but not the write or the read. - **Testing recommendation**: keep a regression test that attaches a file whose content is ``-bearing SVG and asserts that no file named by the script is created. ## Additional Notes - **Idempotency**: the script is self-contained and was run twice consecutively; both runs ended with `=== reproduction confirmed ===` and exit status 0. Each attempt uses fresh random marker content, so the marker evidence cannot be satisfied by leftovers from an earlier run; the show template is restored from `/app-src` before every attempt. - **Limitations**: (1) the RCE leg is conditional on the application loading the overwritten path (demonstrated with code reloading enabled; see Impact Parity); (2) ImageMagick is a source build of upstream 7.1.1-32 without librsvg, chosen because the Debian/Ubuntu packages of the same era carry additional hardening that refuses explicit coder prefixes inside SVG references, and because the reported chain relies on ImageMagick's own internal SVG renderer; the shipped Debian/Ubuntu-style policy (which does not deny MSL) is installed unchanged; (3) the environment read is attempted with progressively smaller raw sizes because ImageMagick aborts an MSL script when a raw read runs past the end of a regular file (the payload writes therefore run first); on this target the very first attempt returned the whole `/proc/self/environ` (1664 bytes, `SECRET_KEY_BASE` included) even though 128 bytes were requested, because procfs reports no size. ## Reproduction Details Reproduced: 2026-11-04T15:12:57.652Z Duration: 6306 seconds Tool calls: 479 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00389 pruva-verify GHSA-F3W6-W445-FWH9 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00389&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00389/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 - GitHub Advisory: https://github.com/rails/rails/security/advisories/GHSA-f3w6-w445-fwh9 - Source: https://github.com/rails/rails/security/advisories/GHSA-f3w6-w445-fwh9 ## Artifacts - bundle/repro/rca_report.md (analysis, 15273 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 12602 bytes) - bundle/logs/attempts/fixed_1/attack.log (log, 33682 bytes) - bundle/logs/attempts/fixed_1/attack.stdout (other, 6331 bytes) - bundle/logs/attempts/fixed_1/server.log (log, 73022 bytes) - bundle/logs/attempts/fixed_1/summary.json (other, 369 bytes) - bundle/logs/attempts/fixed_2/attack.log (log, 33727 bytes) - bundle/logs/attempts/fixed_2/attack.stdout (other, 6340 bytes) - bundle/logs/attempts/fixed_2/negative_control.json (other, 364 bytes) - bundle/logs/attempts/fixed_2/server.log (log, 145483 bytes) - bundle/logs/attempts/fixed_2/summary.json (other, 369 bytes) - bundle/logs/attempts/vuln_1/05_payload_env1024.msl (other, 745 bytes) - bundle/logs/attempts/vuln_1/05_payload_env64.msl (other, 743 bytes) - bundle/logs/attempts/vuln_1/06_payload_env64.svg (other, 305 bytes) - bundle/logs/attempts/vuln_1/11_exfil_blob_download.bin (other, 1664 bytes) - bundle/logs/attempts/vuln_1/11b_exfil_canary_download.bin (other, 30 bytes) - bundle/logs/attempts/vuln_1/12_exfil_environ.txt (other, 1664 bytes) - bundle/logs/attempts/vuln_1/attack.log (log, 23077 bytes) - bundle/logs/attempts/vuln_1/server.log (log, 46574 bytes) - bundle/logs/attempts/vuln_1/summary.json (other, 509 bytes) - bundle/logs/attempts/vuln_2/05_payload_env1024.msl (other, 745 bytes) - bundle/logs/attempts/vuln_2/10_arbitrary_write.txt (other, 80 bytes) - bundle/logs/attempts/vuln_2/11_exfil_blob_download.bin (other, 1664 bytes) - bundle/logs/attempts/vuln_2/11b_exfil_canary_download.bin (other, 30 bytes) - bundle/logs/attempts/vuln_2/12_exfil_environ.txt (other, 1664 bytes) - bundle/logs/attempts/vuln_2/13_response_after_template_write.html (other, 324 bytes) - bundle/logs/attempts/vuln_2/attack.log (log, 23122 bytes) - bundle/logs/attempts/vuln_2/server.log (log, 92565 bytes) - bundle/logs/attempts/vuln_2/summary.json (other, 509 bytes) - bundle/logs/reproduction_steps.log (log, 88095 bytes) - bundle/repro/app/Gemfile (other, 557 bytes) - bundle/repro/app/Rakefile (other, 68 bytes) - bundle/repro/app/app/controllers/application_controller.rb (other, 57 bytes) - bundle/repro/app/app/controllers/uploads_controller.rb (other, 362 bytes) - bundle/repro/app/app/models/application_record.rb (other, 74 bytes) - bundle/repro/app/app/models/upload.rb (other, 62 bytes) - bundle/repro/app/app/views/layouts/application.html.erb (other, 137 bytes) - bundle/repro/app/app/views/uploads/index.html.erb (other, 191 bytes) - bundle/repro/app/app/views/uploads/new.html.erb (other, 142 bytes) - bundle/repro/app/app/views/uploads/show.html.erb (other, 177 bytes) - bundle/repro/app/bin/rails (other, 141 bytes) - bundle/repro/app/config.ru (other, 61 bytes) - bundle/repro/app/config/application.rb (other, 613 bytes) - bundle/repro/app/config/boot.rb (other, 90 bytes) - bundle/repro/app/config/database.yml (other, 127 bytes) - bundle/repro/app/config/environment.rb (other, 62 bytes) - bundle/repro/app/config/environments/development.rb (other, 424 bytes) - bundle/repro/app/config/puma.rb (other, 213 bytes) - bundle/repro/app/config/routes.rb (other, 121 bytes) - bundle/repro/app/config/storage.yml (other, 65 bytes) - bundle/repro/app/db/migrate/0001_create_uploads.rb (other, 155 bytes) - bundle/repro/attack/attack.py (script, 14649 bytes) - bundle/repro/docker/Dockerfile (other, 3438 bytes) - bundle/repro/runtime_manifest.json (other, 6079 bytes) - bundle/repro/validation_verdict.json (other, 1595 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00389 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00389/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00389 ## 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://www.pruva.dev