{"repro_id":"REPRO-2026-00389","version":10,"title":"Rails Active Storage ImageMagick image analysis: arbitrary file write and read (possible RCE)","repro_type":"security","status":"published","severity":"critical","description":"GHSA-f3w6-w445-fwh9 (no CVE assigned as of 2026-11-04). Critical, CVSS v3.0 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, CWE-1188. Reported by RyotaK (GMO Flatt Security) via HackerOne.","root_cause":"# Root Cause Analysis — GHSA-f3w6-w445-fwh9\n\nActive Storage image analysis: arbitrary file write, arbitrary file read, and code execution\nthrough ImageMagick's MSL scripting coder reached from an attached upload.\n\n## Summary\n\n`activestorage` hands every attached upload to ImageMagick for image analysis without\nconstraining which of ImageMagick's coders the file may reach. ImageMagick selects a coder\nfrom the **content** of the file it is given, and some coders act on files *named inside* the\nimage rather than on the image itself. A file whose content is XML is sniffed as `SVG`\n(ImageMagick's built-in magic table maps `?xml`/`?xml`/`SVG`/`svg` at offset 1 to the SVG\ncoder), and ImageMagick's own SVG renderer resolves the `xlink:href` of every `<image>`\nelement through `ReadImage`. Because that reference may carry an **explicit coder prefix**,\nan uploaded SVG containing `<image xlink:href=\"msl:/path/to/file\"/>` makes ImageMagick parse\nthe named file with the **MSL** (\"Magick Scripting Language\") coder. MSL is a scripting coder\nthat reads and writes the files its script names, so the upload alone — no variant, preview,\nor `analyze` call — gives an unauthenticated attacker arbitrary file write, and (with the\nDisk service, where the attacker's own blob path is knowable) arbitrary file read.\n\nThis run reproduced the full chain against the real product code: `activestorage` at the\nparent of the fix commit, driven through a real HTTP upload endpoint, with a real Puma\nserver, a real Active Storage Disk service, the real ImageMagick analyzer, and a real\nImageMagick process. It also demonstrated escalation to code execution by writing the\nattacker's content into a path the application loads and renders.\n\n## Impact\n\n- **Component**: `activestorage` (Ruby on Rails) — `ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick`\n  and, more fundamentally, the absence of any ImageMagick coder restriction in Active Storage.\n- **Affected versions**: `activestorage < 8.0.5.2` and `>= 8.1, < 8.1.4.1`; an application is\n  affected only when all of the following hold:\n  1. `config.active_storage.variant_processor = :mini_magick` (default for `load_defaults` 6.1\n     or earlier; the same setting selects the analyzer),\n  2. ImageMagick runs under a policy that does not deny the coders involved (the policies\n     shipped by Debian and Ubuntu do not deny MSL),\n  3. untrusted users can attach uploads.\n- **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`).\n- **Consequences observed in this run**: attacker-chosen file written at an attacker-chosen\n  path with attacker-chosen bytes; files readable by the application process read and\n  exfiltrated over HTTP (the process environment including `SECRET_KEY_BASE`, and a planted\n  credential file); attacker-supplied Ruby executed inside the application process.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: the advisory asserts *arbitrary file write* and\n  *arbitrary file read when attachments are stored with the Disk service*, and states that\n  readable secrets \"may in turn allow escalation to remote code execution\". The submitted\n  claim contract expects `code_execution` on the `api_remote` surface.\n- **Reproduced impact from this run**:\n  - arbitrary file write — `full` (exact attacker bytes at an attacker-chosen absolute path),\n  - arbitrary file read — `full` (a planted credential file and `/proc/self/environ` including\n    `SECRET_KEY_BASE`, both exfiltrated over HTTP through the attacker's own blob),\n  - code execution — demonstrated (unique marker file content plus `id` output reflected in an\n    HTTP response).\n- **Parity**: `full`.\n- **Not demonstrated / conditions**: the code-execution leg required the write target to be a\n  path the application loads and re-reads, which the reproduced application has because it runs\n  with code reloading enabled (Rails development defaults). The advisory-asserted write and read\n  primitives are unconditional. A deployment with eager loading, template caching, and a\n  non-writable application tree would still be fully exposed to arbitrary file write and read;\n  its escalation to code execution would then depend on the deployment (a scheduled job, a\n  writable autoload path, an executable script, `ld.so.preload` for a root-run process, and so\n  on) and was not separately validated here.\n\n## Root Cause\n\n1. **Coder selection is attacker-controlled.** `ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick#read_image`\n   downloads the blob and runs `MiniMagick::Image.new(file.path)` (i.e. `magick identify` on the\n   file). Nothing in Active Storage restricts the set of coders ImageMagick may pick, so the\n   uploader decides which parser handles the bytes. ImageMagick's built-in magic table maps\n   XML-looking content to the SVG coder.\n2. **The SVG coder resolves references through ImageMagick's read path.** `coders/svg.c`\n   stores the `xlink:href` of `<image>` in `svg_info->url` and passes it to\n   `StrictReadImage()`/`ReadImage()` (and emits `image Over x,y w,h \"url\"` into the MVG it\n   renders). An explicit coder prefix in that reference is honoured, so `msl:<path>` selects\n   the MSL coder for the named file.\n3. **MSL acts on files named inside the script.** `coders/msl.c` implements `<read>` and\n   `<write>`: `read` calls `ReadImage` on any filename (raw coders such as `gray:` read the\n   file's bytes verbatim) and `write` calls `WriteImage` to any path. Both run with the\n   privileges of the Rails process.\n4. **The Disk service makes the primitive remotely useful.** The blob key is recoverable by\n   decoding the *signed* (not encrypted) disk-service token that Active Storage puts in the\n   `Location` header of the blob redirect, so the attacker knows the on-disk path of their own\n   blob. The MSL script can therefore copy any readable file's bytes into that blob file, and\n   the attacker downloads them through the ordinary blob URL — an arbitrary file read over\n   HTTP. Overwriting any other writable path gives the arbitrary file write.\n5. **Trigger point.** `ActiveStorage::Attachment` runs `after_create_commit :analyze_blob_later`,\n   so analysis — and therefore the MSL execution — happens in the request that attaches the\n   file. No variant, preview, or explicit `analyze` call is needed.\n\nFix commit: `ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776` (\"Ship an ImageMagick security policy\nfor every MiniMagick process\", 8.0 branch; 8.1 branch `468aa9a93fc98f5476b6821770f4ccfcb7f7d328`).\nThe fix ships `activestorage/lib/active_storage/image_magick/policy.xml` and prepends its\ndirectory to `MAGICK_CONFIGURE_PATH` through `MiniMagick.cli_env`, so every ImageMagick process\nMiniMagick spawns denies `MSL`, the other file-reaching coders, the network coders, the\nGhostscript coders/delegate, and `@filename` indirection. It also raises at boot for\n`:mini_magick` applications whose `mini_magick` is older than 5.1 (which cannot apply the\npolicy) — a behaviour observed directly in this run when the fixed application was first\nstarted with `mini_magick 5.0.1`.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` is the single entrypoint. It:\n   - resolves the Rails repository from `bundle/project_cache_context.json`\n     (`<project_cache_dir>/repo`), creates a worktree of the vulnerable commit\n     (`ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776^`, `RAILS_VERSION` 8.0.5.1) and of the fixed\n     commit (`ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776`), and asserts the patch is absent from\n     the first and present in the second;\n   - builds `bundle/repro/docker/Dockerfile`: ImageMagick 7.1.1-32 built from upstream source\n     **without librsvg** (so the SVG coder uses ImageMagick's own internal renderer, exactly as\n     in the reported chain) under the Debian/Ubuntu-style `policy.xml` (which does not deny\n     MSL), plus two minimal but real Rails applications (`/app-vuln`, `/app-fixed`) that use\n     `activestorage` from the two worktrees, `variant_processor = :mini_magick`, the Disk\n     service, `active_job.queue_adapter = :inline`, `mini_magick 5.0.x` for the vulnerable app\n     and `mini_magick 5.1+` for the fixed app;\n   - starts both applications under Puma with `SECRET_KEY_BASE` in the process environment,\n     plants a credential file at `/srv/pruva_credentials.txt`, and runs\n     `bundle/repro/attack/attack.py` twice against each application.\n2. `attack.py` is a pure HTTP client. It fetches the upload form and its CSRF token, attaches\n   four uploads through `POST /uploads` (an exfiltration blob, a second exfiltration blob, the\n   attacker's payload bytes, and the MSL script), recovers each blob key by decoding the signed\n   disk token in the blob redirect, attaches the SVG `<image xlink:href=\"msl:<blob path>\">`\n   (which triggers the analyzer at attach time), retries the environment read with decreasing\n   sizes, and then verifies the effects.\n3. Expected evidence: on the vulnerable application the attacker's bytes appear at\n   `/tmp/pruva_arbitrary_write.txt`, the attacker's own blob now carries the server's process\n   environment (including `SECRET_KEY_BASE`) and the planted credential file, and a subsequent\n   page request executes the template the MSL write replaced. On the fixed application nothing\n   happens and the server log shows ImageMagick refusing the coder:\n   `attempt to perform an operation not allowed by the security policy 'MSL'`.\n\n## Evidence\n\nAll paths are relative to `bundle/`.\n\n| Evidence | Location |\n| --- | --- |\n| Reproduction log (both runs) | `logs/reproduction_steps.log` |\n| Vulnerable attempt 1 / 2 attack transcript | `logs/attempts/vuln_1/attack.stdout`, `logs/attempts/vuln_2/attack.stdout` |\n| Structured per-attempt results | `logs/attempts/vuln_1/summary.json`, `logs/attempts/vuln_2/summary.json` |\n| Delivered MSL script and SVG | `logs/attempts/vuln_1/05_payload_env1024.msl`, `logs/attempts/vuln_1/06_payload_env1024.svg` |\n| Arbitrary write target content | `logs/attempts/vuln_1/10_arbitrary_write.txt` |\n| Exfiltrated bytes (raw / newline-separated) | `logs/attempts/vuln_1/11_exfil_blob_download.bin`, `logs/attempts/vuln_1/12_exfil_environ.txt` |\n| Planted credential recovered through the blob | `logs/attempts/vuln_1/11b_exfil_canary_download.bin` |\n| Code execution in the HTTP response | `logs/attempts/vuln_1/13_response_after_template_write.html` |\n| Target-local RCE marker files | `logs/attempts/vuln_1/14_rce_marker.txt`, `logs/attempts/vuln_2/14_rce_marker.txt` |\n| Negative-control observations (fix commit) | `logs/attempts/fixed_1/negative_control.json`, `logs/attempts/fixed_2/negative_control.json` |\n| Fixed attempt transcripts and server policy denials | `logs/attempts/fixed_1/attack.stdout`, `logs/attempts/fixed_1/server.log` |\n\nKey excerpts (vulnerable application, attempt 1):\n\n```\nexfiltration channel on disk: /app-vuln/storage/fe/8k/fe8k0q5kmsjoxy2exjuo5r4a43t7\nattached script_env1024.msl (1888 bytes, application/octet-stream) -> /uploads/3\ndelivering SVG env1024: <image xlink:href=\"msl:/app-vuln/storage/t3/c0/t3c0s3ff8jvzeagukdhys8grjfa1\">\narbitrary write to /tmp/pruva_arbitrary_write.txt: matches attacker content\nenvironment read with 64 samples (128 bytes) returned 1664 bytes\nexfiltrated 1664 bytes through the attacker's own blob (SECRET_KEY_BASE present: True, planted credential recovered: True)\nresponse after the template write contains command output: 'uid=0(root) gid=0(root) groups=0(root)'\nserver-side RCE marker file content: 'PRUVA_RCE_841517157f8b' (expected 'PRUVA_RCE_841517157f8b')\n```\n\nExfiltrated environment (fragment, from `logs/attempts/vuln_1/12_exfil_environ.txt`):\n\n```\nHOSTNAME=32925638596e\nRUBY_VERSION=3.4.12\nPWD=/app-vuln\n...\nSECRET_KEY_BASE=04c5dd7e95...\n```\n\nFixed application (both attempts):\n\n```\nexfiltrated 68 bytes through the attacker's own blob (SECRET_KEY_BASE present: False, planted credential recovered: False)\nresponse after the template write contains command output: None\nidentify: attempt to perform an operation not allowed by the security policy `MSL' @ error/constitute.c/IsCoderAuthorized/454.\n```\n\nEnvironment: Linux x86_64, Docker 27.5.1, `ruby:3.4-bookworm`, Ruby 3.4.12, `activestorage`\n8.0.5.1 (vulnerable worktree `7fe85a72a789eae5ee070ce3ee1dad10a8a5f70c`) and the fix commit\n`ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776` for the control, `mini_magick` 5.0.1 / 5.4.0,\nImageMagick 7.1.1-32 Q16-HDRI (source build, internal SVG renderer, Debian/Ubuntu-style\n`policy.xml` that does not deny MSL), Puma, SQLite, Active Storage Disk service.\n\n## Recommendations / Next Steps\n\n- **Apply the vendor fix**: upgrade `activestorage` to 8.0.5.2 / 8.1.4.1 or later, which ships\n  the ImageMagick policy and applies it through `MiniMagick.cli_env`; with `:mini_magick`, also\n  upgrade `mini_magick` to 5.1 or later (older versions make the fixed release raise at boot,\n  as observed here).\n- **Do not rely on the host policy alone.** A host policy that denies `MSL` blocks the reported\n  chain, but the advisory is explicit that it does not assume this is the only chain; the\n  shipped policy is a denylist and the variant-analysis stage should test its completeness\n  (coder aliases, host policy re-allow rules placed after the shipped one, GraphicsMagick,\n  applications that replace `MiniMagick.cli_env`/`cli_prefix` or install their own\n  `MiniMagick::Shell`).\n- **Constrain coders, not just files**: prefer `:vips` for variant processing, and treat\n  \"attach\" as an untrusted-input boundary — analysis must not run with privileges or file\n  access beyond the blob.\n- **Run the app without write access to its own code** and without reloading in production;\n  that removes the demonstrated write→execution path but not the write or the read.\n- **Testing recommendation**: keep a regression test that attaches a file whose content is\n  `<msl>`-bearing SVG and asserts that no file named by the script is created.\n\n## Additional Notes\n\n- **Idempotency**: the script is self-contained and was run twice consecutively; both runs\n  ended with `=== reproduction confirmed ===` and exit status 0. Each attempt uses fresh\n  random marker content, so the marker evidence cannot be satisfied by leftovers from an\n  earlier run; the show template is restored from `/app-src` before every attempt.\n- **Limitations**: (1) the RCE leg is conditional on the application loading the overwritten\n  path (demonstrated with code reloading enabled; see Impact Parity); (2) ImageMagick is a\n  source build of upstream 7.1.1-32 without librsvg, chosen because the Debian/Ubuntu packages\n  of the same era carry additional hardening that refuses explicit coder prefixes inside SVG\n  references, and because the reported chain relies on ImageMagick's own internal SVG renderer;\n  the shipped Debian/Ubuntu-style policy (which does not deny MSL) is installed unchanged;\n  (3) the environment read is attempted with progressively smaller raw sizes because ImageMagick\n  aborts an MSL script when a raw read runs past the end of a regular file (the payload writes\n  therefore run first); on this target the very first attempt returned the whole\n  `/proc/self/environ` (1664 bytes, `SECRET_KEY_BASE` included) even though 128 bytes were\n  requested, because procfs reports no size.\n","ghsa_id":"GHSA-F3W6-W445-FWH9","cwe_id":"CWE-1188","source_url":"https://github.com/rails/rails/security/advisories/GHSA-f3w6-w445-fwh9","package":{"name":"activestorage (rubygems)","ecosystem":"rubygems","affected_versions":"activestorage < 8.0.5.2 and >= 8.1, < 8.1.4.1","fixed_version":"8.0.5.2, 8.1.4.1","tested_vulnerable":"8.0.5.1","tested_patched":"8.1.4.1"},"reproduced_at":"2026-11-04T15:12:57.652303+00:00","duration_secs":6305.64762,"tool_calls":479,"handoffs":2,"total_cost_usd":1.478754,"agent_costs":{"claim_matcher":0.031696,"judge":0.638761,"learning_policy":0.017868,"repro":0.451549,"support":0.00681,"vuln_variant":0.33207},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.031696},"judge":{"gpt-5.6-sol":0.638761},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.017868},"repro":{"accounts/fireworks/models/deepseek-v4p1-flash":0.451549},"support":{"accounts/fireworks/models/deepseek-v4p1-flash":0.00681},"vuln_variant":{"accounts/fireworks/models/deepseek-v4p1-flash":0.33207}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","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"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-11-04T15:12:59.580758+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":15273,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12602,"category":"reproduction_script"},{"path":"bundle/logs/attempts/fixed_1/attack.log","filename":"attack.log","size":33682,"category":"log"},{"path":"bundle/logs/attempts/fixed_1/attack.stdout","filename":"attack.stdout","size":6331,"category":"other"},{"path":"bundle/logs/attempts/fixed_1/server.log","filename":"server.log","size":73022,"category":"log"},{"path":"bundle/logs/attempts/fixed_1/summary.json","filename":"summary.json","size":369,"category":"other"},{"path":"bundle/logs/attempts/fixed_2/attack.log","filename":"attack.log","size":33727,"category":"log"},{"path":"bundle/logs/attempts/fixed_2/attack.stdout","filename":"attack.stdout","size":6340,"category":"other"},{"path":"bundle/logs/attempts/fixed_2/negative_control.json","filename":"negative_control.json","size":364,"category":"other"},{"path":"bundle/logs/attempts/fixed_2/server.log","filename":"server.log","size":145483,"category":"log"},{"path":"bundle/logs/attempts/fixed_2/summary.json","filename":"summary.json","size":369,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/05_payload_env1024.msl","filename":"05_payload_env1024.msl","size":745,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/05_payload_env64.msl","filename":"05_payload_env64.msl","size":743,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/06_payload_env64.svg","filename":"06_payload_env64.svg","size":305,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/11_exfil_blob_download.bin","filename":"11_exfil_blob_download.bin","size":1664,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/11b_exfil_canary_download.bin","filename":"11b_exfil_canary_download.bin","size":30,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/12_exfil_environ.txt","filename":"12_exfil_environ.txt","size":1664,"category":"other"},{"path":"bundle/logs/attempts/vuln_1/attack.log","filename":"attack.log","size":23077,"category":"log"},{"path":"bundle/logs/attempts/vuln_1/server.log","filename":"server.log","size":46574,"category":"log"},{"path":"bundle/logs/attempts/vuln_1/summary.json","filename":"summary.json","size":509,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/05_payload_env1024.msl","filename":"05_payload_env1024.msl","size":745,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/10_arbitrary_write.txt","filename":"10_arbitrary_write.txt","size":80,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/11_exfil_blob_download.bin","filename":"11_exfil_blob_download.bin","size":1664,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/11b_exfil_canary_download.bin","filename":"11b_exfil_canary_download.bin","size":30,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/12_exfil_environ.txt","filename":"12_exfil_environ.txt","size":1664,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/13_response_after_template_write.html","filename":"13_response_after_template_write.html","size":324,"category":"other"},{"path":"bundle/logs/attempts/vuln_2/attack.log","filename":"attack.log","size":23122,"category":"log"},{"path":"bundle/logs/attempts/vuln_2/server.log","filename":"server.log","size":92565,"category":"log"},{"path":"bundle/logs/attempts/vuln_2/summary.json","filename":"summary.json","size":509,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":88095,"category":"log"},{"path":"bundle/repro/app/Gemfile","filename":"Gemfile","size":557,"category":"other"},{"path":"bundle/repro/app/Rakefile","filename":"Rakefile","size":68,"category":"other"},{"path":"bundle/repro/app/app/controllers/application_controller.rb","filename":"application_controller.rb","size":57,"category":"other"},{"path":"bundle/repro/app/app/controllers/uploads_controller.rb","filename":"uploads_controller.rb","size":362,"category":"other"},{"path":"bundle/repro/app/app/models/application_record.rb","filename":"application_record.rb","size":74,"category":"other"},{"path":"bundle/repro/app/app/models/upload.rb","filename":"upload.rb","size":62,"category":"other"},{"path":"bundle/repro/app/app/views/layouts/application.html.erb","filename":"application.html.erb","size":137,"category":"other"},{"path":"bundle/repro/app/app/views/uploads/index.html.erb","filename":"index.html.erb","size":191,"category":"other"},{"path":"bundle/repro/app/app/views/uploads/new.html.erb","filename":"new.html.erb","size":142,"category":"other"},{"path":"bundle/repro/app/app/views/uploads/show.html.erb","filename":"show.html.erb","size":177,"category":"other"},{"path":"bundle/repro/app/bin/rails","filename":"rails","size":141,"category":"other"},{"path":"bundle/repro/app/config.ru","filename":"config.ru","size":61,"category":"other"},{"path":"bundle/repro/app/config/application.rb","filename":"application.rb","size":613,"category":"other"},{"path":"bundle/repro/app/config/boot.rb","filename":"boot.rb","size":90,"category":"other"},{"path":"bundle/repro/app/config/database.yml","filename":"database.yml","size":127,"category":"other"},{"path":"bundle/repro/app/config/environment.rb","filename":"environment.rb","size":62,"category":"other"},{"path":"bundle/repro/app/config/environments/development.rb","filename":"development.rb","size":424,"category":"other"},{"path":"bundle/repro/app/config/puma.rb","filename":"puma.rb","size":213,"category":"other"},{"path":"bundle/repro/app/config/routes.rb","filename":"routes.rb","size":121,"category":"other"},{"path":"bundle/repro/app/config/storage.yml","filename":"storage.yml","size":65,"category":"other"},{"path":"bundle/repro/app/db/migrate/0001_create_uploads.rb","filename":"0001_create_uploads.rb","size":155,"category":"other"},{"path":"bundle/repro/attack/attack.py","filename":"attack.py","size":14649,"category":"script"},{"path":"bundle/repro/docker/Dockerfile","filename":"Dockerfile","size":3438,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":6079,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1595,"category":"other"}]}