
Re-moo-te Code Execution in Mailcow: Always Sanitize Error Messages
Mailcow is an easy-to-use email solution that can be set up in minutes. It features SMTP, IMAP, and POP3 servers, a webmail client, an admin panel, and more. All of its components are open-source and some are written in PHP.
While scanning mailcow's code base, SonarCloud found a Path Traversal vulnerability which looked like it could lead to Remote Code Execution. We then started investigating the code manually, confirmed the issue, and found an additional Cross-Site Scripting (XSS) flaw. Both vulnerabilities can be combined to take over a mailcow instance with a single email viewed by an admin.
In this blog post, we will cover the code intricacies that led to the vulnerabilities. We will first go over the details of the XSS vulnerability and then explore the Path Traversal flaw. We will also cover how the mailcow maintainers have tackled these issues and give advice on how to avoid such vulnerabilities in your code.
Impact
The vulnerabilities we found and reported are tracked as CVE-2024-31204 (XSS) and CVE-2024-30270 (Path Traversal). They have been fixed in mailcow 2024-04 and seem to have existed for at least three years.
An attacker can combine both vulnerabilities to execute arbitrary code on the admin panel server of a vulnerable mailcow instance. The requirement for this is that an admin user views a malicious email while being logged into the admin panel. The victim does not have to click a link inside the email or perform any other interaction with the email itself, they only have to continue using the admin panel after viewing the email.
The following video demonstrates the flow of an attack on our test instance:
Technical Details
The journey of these vulnerabilities begins in the code of mailcow's admin panel. It is written in PHP and has, among others, an API endpoint that is implemented in json_api.php
. To capture API errors and show them to the user, mailcow registers a custom exception handler:
data/web/inc/prerequisites.inc.php:
function exception_handler($e) {
    if ($e instanceof PDOException) {
      // ...
    }
    else {
      $_SESSION['return'][] = array(
        'type' => 'danger',
        'log' => array(__FUNCTION__),
        'msg' => 'An unknown error occured: ' . print_r($e, true)
      );
      return false;
    }
}
if(!$DEV_MODE) {
  set_exception_handler('exception_handler');
}
This handler saves exception details in the session's return
array. From there, they are processed and passed on to the base template when the UI is rendered the next time:
$alertbox_log_parser = alertbox_log_parser($_SESSION);
$alerts = [];
if (is_array($alertbox_log_parser)) {
  foreach ($alertbox_log_parser as $log) {
    $message = strtr($log['msg'], ["\n" => '', "\r" => '', "\t" => '
']);
    $alerts[trim($log['type'], '"')][] = trim($message, '"');
  }
  $alert = array_filter(array_unique($alerts));
  foreach($alert as $alert_type => $alert_msg) {
    // html breaks from mysql alerts, replace ` with '
    $alerts[$alert_type] = implode('
', str_replace("`", "'", $alert_msg));
  }
  unset($_SESSION['return']);
}
The base template takes them and inserts the data into JavaScript function calls inside of an inline script block:
{% for alert_type, alert_msg in alerts %}
    mailcow_alert_box('{{ alert_msg|raw|e("js") }}', '{{ alert_type }}');
{% endfor %}
Finally, when the page is rendered in the browser, mailcow's JavaScript renders an alert box for each error using a jQuery-based notification library:
data/web/js/build/013-mailcow.js:
window.mailcow_alert_box = function(message, type) {
  msg = $('').text(message).text();
  if (type == 'danger' || type == 'info') {
    auto_hide = 0;
    $('#' + localStorage.getItem("add_modal")).modal('show');
    localStorage.removeItem("add_modal");
  } else {
    auto_hide = 5000;
  }
  $.notify({message: msg},{z_index: 20000, delay: auto_hide, type: type,placement: {from: "bottom",align: "right"},animate: {enter: 'animated fadeInUp',exit: 'animated fadeOutDown'}});
}
And the result looks like this:
Did you spot the vulnerability?
CVE-2024-31204: XSS in the Admin Panel
If you guessed that an attacker could directly insert malicious JavaScript into the inline script block in the base.twig
template, then you're wrong. It's a good idea, but Twig's escaping properly handles all characters so it's not possible to leave the string context.
The correct answer is that the jQuery-based notification library does not escape HTML entities, causing a Cross-Site Scripting (XSS) vulnerability when attackers can control an exception that is being raised. This is given away by the fact that there were raw <hr>
elements added in footer.inc.php
.
But is this just a functional bug, or an exploitable security vulnerability? Can an attacker control the content of an exception and inject a malicious JavaScript payload? The answer is yes! Let's see how that can happen.
Since the exception handler uses print_r()
to convert the exception to a string, we can see that not only the error's location and error message are included, but also the arguments to functions in the call stack! This happens because the zend.exception_ignore_args
configuration directive is set to Off in mailcow's PHP container, which inherits the setting from the official PHP-FPM Docker image. The resulting string representation looks like this:
Controlled Arguments
There are plenty of locations where an attacker can control arguments to functions, so now all they need is a function that reliably errors on a certain input. One great example is explode()
which is used very early in the API handler script:
if (isset($_GET['query'])) {
  $query = explode('/', $_GET['query']);
  $action =   (isset($query[0])) ? $query[0] : null;
  $category =  (isset($query[1])) ? $query[1] : null;
  $object =   (isset($query[2])) ? $query[2] : null;
  $extra =   (isset($query[3])) ? $query[3] : null;
  // ...
}
The explode()
function expects two strings, and the second one is provided from a query parameter ($_GET['query']
). So when does this function error? If it receives an argument with the wrong type!
Since PHP performs "extended" query parameter parsing, it is possible to make $_GET['query']
an array: json_api.php?query[]=<script>alert(1)</script>
In such a case, the output of print_r($exception)
will look like this:
An unknown error occured: TypeError Object(
   [message:protected] => explode(): Argument #($string) must be of type string, array given
    [string:Error:private] =>Â
    [code:protected] => 0
    [file:protected] => /web/json_api.php
    [line:protected] => 52
    [trace:Error:private] => Array(
        [0] => Array(
            [file] => /web/json_api.php
            [line] => 52
            [function] => explode
            [args] => Array(
                [0] => /
                [1] => Array(
                    [0] =>
- Chariot Continuous Threat Exposure Management (CTEM) Updates
- Enhancing Enterprise Browser Security