Письмо, которое ходит за вас: разбираю свежий SSRF в Roundcube на живом стенде

от автора

Вы открыли письмо. Пока вы его читаете, ваш почтовый сервер по указанию из этого письма уже стучится на внутренний адрес, которого вы ему не давали. Не выдумка, я воспроизвёл это на стенде и поймал запрос в логе.

Прочитать письмо это давно не пассивное действие. Клиент разбирает MIME, рендерит текст в HTML, подтягивает стили, показывает вложения, линкует адреса и ссылки. Каждый такой шаг это код, который выполняется над данными, которые прислал чужой человек. И если хоть один шаг доверяет этим данным чуть больше, чем стоило, письмо начинает делать то, чего вы не просили.

В июле 2026 Roundcube выкатил релиз 1.6.17, и это не одна заплатка, а целый список. Две уязвимости там с высшим баллом: NIST дал обеим 10.0 из 10, MITRE по SSRF осторожнее и ставит 7.2. Одна из них позволяет письму заставить ваш почтовый сервер самому сходить во внутреннюю сеть. Я поднял уязвимую версию на изолированном стенде и прошёл атаку целиком, до лога, который ловит запрос от самого сервера. Заодно честно покажу, где публичные данные заканчиваются и почему вторую 10.0 воспроизвести из них нельзя.

Сразу оговорюсь: Roundcube я не гоняю в проде и статья не про то, что он плохой. Я занимаюсь безопасностью серверов, и меня цепляет повторяющийся сюжет. Софт умеет рендерить чужой контент, а значит у него есть поверхность атаки ровно там, где он старается быть удобным. Roundcube тут отличная иллюстрация, потому что дыры сидят именно в фичах: в подгрузке стилей, в линковании адресов, в разборе вложений.

Как письмо заставляет сервер ходить по сети: CVE-2026-62643 (CVSS 10.0)

Начну с той, что воспроизвёл вживую.

Когда Roundcube показывает HTML-письмо, он не отдаёт стили как есть. Внешние стили он сначала тянет к себе на сервер, прогоняет через санитайзер и только потом отдаёт в браузер. Логика здравая: почистить CSS от опасных конструкций до того, как он попадёт пользователю. Проблема в том, что тянет их именно сервер.

Вот место в коде, которое принимает решение (program/actions/mail/index.php):

if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])    && !rcube_utils::is_local_url($attrib['href'])) {    // ссылку на стиль переписываем на внутренний обработчик modcss,    // который сходит по адресу САМ и вернёт очищенный CSS    $_SESSION['modcssurls'][$tempurl] = $attrib['href'];    ...}

То есть письмо с <link rel="stylesheet" href="..."> заставляет сервер сходить по этому адресу. Единственный, кто стоит на пути к внутренней сети, это проверка is_local_url. Вся защита от SSRF держится на ней. Смотрим, как она устроена в 1.6.16.

public static function is_local_url($url){    $host = parse_url($url, \PHP_URL_HOST);    ...    $host = preg_replace('/^::ffff:/i', '', $host);    if (preg_match('/([0-9a-f.-]+)\.nip\.io$/i', $host, $matches)) {        $host = trim($matches[1], '-.');    }    if ($address = Factory::parseAddressString($host, $options)) {        $nets = [            '0.0.0.0', '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', '::1/128', 'fc00::/7',        ];        foreach ($nets as $net) {            $range = Factory::parseRangeString($net);            if ($range->contains($address)) {                return true; // локальный, блокируем            }        }        return false;    }    ...}

Прямые приватные адреса функция закрывает честно: весь RFC1918, loopback, link-local с облачными метаданными на 169.254. Написать href="http://127.0.0.1" или http://169.254.169.254 не выйдет, отсекается.

Но обратите внимание на строку с nip.io. Разработчики знали про DNS-сервисы, которые резолвят имя вида 10.0.0.1.nip.io в IP 10.0.0.1. Такой сервис позволяет спрятать приватный адрес за публичным именем: is_local_url видит домен, а не IP, и пропускает. Поэтому nip.io тут разворачивают обратно в адрес перед проверкой. Логично.

Вот только nip.io не один. Есть sslip.io, который делает ровно то же самое, и в 1.6.16 его в этой строке нет. То есть 172.30.0.3.sslip.io для функции это просто внешний домен. Она его не разворачивает, parseAddressString от доменного имени не даёт IP, проверка диапазонов не срабатывает, и функция возвращает false. Не локальный. Идём.

Стенд

Всё в изолированной сети из трёх контейнеров, ящики фейковые. Жертва это Roundcube 1.6.16 (последняя версия до фикса), почтовик GreenMail, и контейнер атакующего с обычным python -m http.server, видимый внутри сети как 172.30.0.3.

services:  roundcube:    image: roundcube/roundcubemail:1.6.16-apache    environment:      ROUNDCUBEMAIL_DEFAULT_HOST: greenmail      ROUNDCUBEMAIL_SMTP_SERVER: greenmail    ports: ["127.0.0.1:8091:80"]  greenmail:    image: greenmail/standalone:latest    environment:      GREENMAIL_OPTS: "-Dgreenmail.setup.test.all -Dgreenmail.users=victim@lab.local:victimpass,attacker@lab.local:attackerpass"  attacker:    image: python:3.12-alpine    command: python -m http.server 8000

Проверил версию прямо в контейнере, чтобы не гадать:

$ docker exec rc-victim head -3 /var/www/html/CHANGELOG.md# Changelog Roundcube Webmail## Release 1.6.16

Атака

Отправляю жертве письмо с одной строкой в шапке. Хост подобран так, чтобы обойти is_local_url: публичный DNS зарезолвит 172.30.0.3.sslip.io обратно в приватный 172.30.0.3, где сидит мой listener.

html = (    '<html><head>'    '<link rel="stylesheet" href="http://172.30.0.3.sslip.io:8000/ssrf-proof.css">'    '</head><body><p>Styled newsletter.</p></body></html>')

Захожу в вебмейл жертвой, открываю письмо. По умолчанию Roundcube блокирует внешний контент и показывает плашку с кнопкой разрешить, так что это не zero-click, а один клик пользователя. Разрешаю внешний контент (в стенде я дёрнул ту же команду, что висит на кнопке). И смотрю в лог listener’а:

172.30.0.4 - - [21/Jul/2026 18:13:38] "GET /ssrf-proof.css HTTP/1.1" 404

Вот она, суть. Запрос пришёл, и пришёл он с адреса 172.30.0.4. Это не браузер жертвы. Браузер имя 172.30.0.3.sslip.io вообще снаружи бы не увидел. 172.30.0.4 это сам контейнер Roundcube, проверил через hostname -i. То есть сервер по моей команде из письма сходил на внутренний адрес, который я указал. 404 тут неважен, важно, что запрос состоялся. Это и есть SSRF: я не имею доступа к внутренней сети сервера, но письмом заставил сервер сходить туда за меня.

На реальном сервере вместо моего пустого listener’а мог быть внутренний сервис, соседний контейнер, админка без доступа снаружи, база на внутреннем порту. Особенно больно с облачным metadata-эндпоинтом на 169.254.169.254: один такой запрос от сервера отдаёт временные ключи от всего облачного аккаунта. Эта версия его уже прикрывает (169.254 в блок-листе), но сам класс атаки ровно про это, и любой недосмотр в фильтре снова открывает дорогу к кредам. Атакующий превращает почтовый сервер в прокси во внутреннюю сеть.

Как поймать в логах

SSRF в вебмейле удобно тем, что оставляет характерный след: исходящее HTTP-соединение от процесса самого веб-сервера туда, куда он ходить не должен. Не клиент, не браузер, а php-fpm или apache вашего Roundcube внезапно коннектится на внутренний адрес.

Правило для алерта простое: любой исходящий коннект от UID веб-сервера на адрес, которого нет в белом списке (ваши SMTP, IMAP, апдейт-серверы), это инцидент. На стенде это была строчка в логе listener’а с адресом 172.30.0.4, то есть с IP самого Roundcube. В проде тот же паттерн ловится на egress-фильтре или в логах reverse-proxy: сервис, который должен только принимать запросы, вдруг сам начинает их слать.

Фикс

Заплатка в одну строку. Разворот доменных DNS-сервисов теперь ловит не только nip.io:

- if (preg_match('/([0-9a-f.-]+)\.nip\.io$/i', $host, $matches)) {+ if (preg_match('/([0-9a-f.-]+)\.(nip|sslip)\.io$/i', $host, $matches)) {

Обновил образ до 1.6.17, пересоздал контейнер, повторил ровно то же письмо из того же ящика. Проверил, что заплатка на месте:

$ docker exec rc-victim grep -n "sslip" .../rcube_utils.php447: if (preg_match('/([0-9a-f.-]+)\.(nip|sslip)\.io$/i', $host, $matches)) {

Открыл письмо, разрешил внешний контент. Лог listener’а после этого не изменился, там по-прежнему единственный запрос от 1.6.16. Нового обращения нет. Теперь is_local_url разворачивает 172.30.0.3.sslip.io в 172.30.0.3, видит попадание в 172.16.0.0/12 и возвращает true. Сервер никуда не идёт. Дыра закрыта.

Заодно фикс подтянул и IPv6-обход: было preg_replace('/^::ffff:/i', ...), стало /^[0:]*:ffff:/i, то есть теперь ловятся и записи вида 0:0:0:0:0:ffff:127.0.0.1.

Вторая 10.0, которую из открытых данных не поджечь: CVE-2026-54433

Здесь будет честно. По второй уязвимости живого эксплойта у меня нет, и я объясню почему, потому что сам разбор поучительнее готового результата.

CVE-2026-54433 это, по описанию, zero-click stored XSS в рендере plain-text писем. Звучит эффектно: даже простой текст, где по определению нет HTML, при открытии выполняет чужой JavaScript. Причина в линковании. Когда Roundcube показывает текстовое письмо, он находит в нём email-адреса и ссылки и оборачивает их в кликабельные теги. Вот на этом шаге генерации HTML и живёт баг.

Смотрю фикс-коммит. Он ровно один и меняет одну строку, регулярку для query-части почтовой ссылки в rcube_string_replacer.php:

- . "(\?[$url1$url2]+)?"        // было: разрешённый набор символов после ?+ . '(\?[^<>\s]+)?'             // стало: запрещаем < > и пробелы

Смысл понятен: раньше в хвосте mailto-ссылки допускались символы, которыми можно вырваться в разметку, а после фикса угловые скобки и пробелы там запрещены. К коммиту приложен тест с вредным входом:

a@a.co?]<img/src="x"/onerror=alert(document.domain)>

Я прогнал этот вход на живом 1.6.16 через браузер и получил вот такой безопасный HTML:

<a href="mailto:a@a.co?">a@a.co?</a>]&lt;img/src="x"/onerror=alert(document.domain)&gt;

То есть <img> заэкранирован в сущности, скрипт не выполняется даже на уязвимой версии. Разобрался почему. За ссылку отвечает не только регулярка, но и функция parse_url_brackets, которая аккуратно отрезает от адреса непарные скобки, включая ]. В итоге ] уходит в хвост, а <img> попадает в обычный текст, который экранируется. Тестовый вход из коммита показывает границу фикса, но сам по себе не является рабочим эксплойтом.

Это нормальная ситуация. Публичный тест к фиксу редко совпадает с боевым payload. Реальный вектор держит нашедший исследователь (в credits это Bohdan Kurinnoy из Samsung R&D Institute Ukraine), и до широкого патча его не раскрывают, чтобы не вооружать атакующих раньше времени. Собрать рабочий эксплойт из одной строки диффа можно, но это отдельная кропотливая работа с линковщиком, и подавать догадку под видом воспроизведения я не буду. Что можно утверждать твёрдо: баг в линковщике plain-text реален, класс у него XSS, чинится сужением набора символов. Что нельзя утверждать: что вот эта конкретная строка из advisory сама по себе кого-то взломает.

Это не одна дыра, это весь класс проблем

Если посмотреть на релиз 1.6.17 целиком, становится видно, что чинили не случайную опечатку, а перебирали поверхность атаки почтового клиента по кусочкам.

  • CVE-2026-54432, ещё один stored XSS, но уже через MIME-тип вложения. На странице предупреждения о проверке вложения тип подставлялся без экранирования.

  • Уязвимости в плагине смены пароля через подставленное в сессию имя пользователя.

  • Ещё два обхода SSRF через специфичные локальные адреса, помимо разобранного sslip.io.

  • Два отказа в обслуживании в декодере TNEF (это тот самый winmail.dat от Outlook): бесконечный цикл и обвал через хитрый размер в compressed-RTF.

Разные модули, разные типы багов, а корень один. Клиент разбирает и рендерит данные из письма: текст, стили, вложения, проприетарный формат Microsoft. Каждый разборщик это доверие к чужому вводу. Чем богаче почтовый клиент, тем больше в нём мест, где чужое письмо получает над ним чуть больше власти, чем вы ожидали. Стили дали SSRF, линковщик дал XSS, декодер TNEF дал DoS.

Если у вас крутится Roundcube, проверьте сегодня

  1. Версия. 1.6.17 или новее (для ветки 1.7 это 1.7.2). Все перечисленное закрыто там.

  2. Внешний контент. Держите блокировку внешних ресурсов включённой по умолчанию. Это не только приватность, это ещё и то, что не даёт SSRF выстрелить без действия пользователя.

  3. Сетевая изоляция. Почтовому фронтенду нечего делать в одной плоской сети с внутренними сервисами и облачным metadata-эндпоинтом. Ограничьте исходящие соединения самого веб-сервера, а не только входящие.

  4. Плагины. Если используете плагин смены пароля, обновляйтесь обязательно, там своя порция дыр.

  5. Мониторинг. Исходящие HTTP-запросы от процесса вашего вебмейла на неожиданные адреса это сигнал. На стенде SSRF виден как запрос от IP самого сервера туда, куда он ходить не должен.

Что забрать из этого

Одна строчка регулярки, где забыли про второй DNS-сервис, и письмо гоняет ваш сервер по внутренней сети. Другая строчка, где недосузили набор символов, и текстовое письмо цепляет XSS. Дело не в Roundcube. Дело в том, что любой софт, который рендерит присланный вам контент, по умолчанию доверяет этому контенту, и безопасность тут это длинный список мест, где доверие пришлось отозвать вручную. Между удобной фичей и дырой иногда ровно один пропущенный случай в фильтре. Здесь их набралось на целый релиз.


Стенд поднимался в изолированном окружении, все адреса и ящики фейковые. Материал для защиты своих систем.

ссылка на оригинал статьи https://habr.com/ru/articles/1061638/