# 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 `<image>`
element through `ReadImage`. Because that reference may carry an **explicit coder prefix**,
an uploaded SVG containing `<image xlink:href="msl:/path/to/file"/>` 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 `<image>` 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:<path>` selects
   the MSL coder for the named file.
3. **MSL acts on files named inside the script.** `coders/msl.c` implements `<read>` and
   `<write>`: `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`
     (`<project_cache_dir>/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 `<image xlink:href="msl:<blob path>">`
   (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: <image xlink:href="msl:/app-vuln/storage/t3/c0/t3c0s3ff8jvzeagukdhys8grjfa1">
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
  `<msl>`-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.
