Строительная компания на Авиамоторной, 33 рабочих места. В декабре 2025-го у них утекла переписка с подрядчиками — без вложений, без ссылок, без единого клика по чему-либо. Разбираю, как директива оформления CSS доехала до исполнения скрипта в веб-почте, что при этом уносят и почему смена паролей саму дыру не закрывает.
CVE-2025-66376: XSS через CSS @import и кража архива почты
Учили не открывать вложения. А вложений не было
В компании на Авиамоторной пять лет подряд проводили инструктаж: не открывай вложения от незнакомых, не переходи по ссылкам, звони в ИТ, если письмо кажется странным. Люди слушались. Сметчица однажды даже не открыла архив от собственного подрядчика, пока ей не перезвонили и не подтвердили.
В декабре 2025-го у них утёк квартал переписки с подрядчиками. Никто ничего не открывал. Ни одного вложения, ни одной ссылки, ни одного макроса. Письмо просто пришло и попало в панель предпросмотра — ту самую полосу справа, где показывается содержимое выделенного письма.
Если вы сейчас думаете «ну у нас-то люди обучены» — я об этом и говорю. Обучение здесь не работает вообще. Оно рассчитано на модель «пользователь совершает опасное действие», а тут опасного действия нет. Есть письмо в списке и курсор, который по нему проехал.
Как вообще заметили
Позвонили мне не из-за почты. Позвонили из-за счёта.
Подрядчик по фасадным работам прислал в бухгалтерию вопрос: приходил ли им счёт с новыми реквизитами. Счёт не приходил — от компании клиента такого письма не отправляли. Но человек на той стороне явно видел переписку целиком: цитировал номера договоров, суммы, имена, даже внутреннюю шутку из письма месячной давности. То есть у кого-то на руках был архив, а не отдельное письмо.
Первое, что я снял на сервере:
su - zimbra
zmcontrol -v
# Release 10.1.9_GA_5000.UBUNTU22_64 20250918
zmprov -l gaa | wc -l
# 41
zmprov ga buh@stroy-avia.ru zimbraLastLogonTimestamp
zmprov ga buh@stroy-avia.ru zimbraAuthTokenValidityValue
Версия 10.1.9. Тут важная оговорка, без которой дальше будет непонятно, почему я так долго ходил кругами. В декабре у этой дыры не было номера. Публичную запись CVE-2025-66376 открыли только 5 января 2026 года — то есть уже после того, как мы всё разгребли, и статью я пишу, зная то, чего в декабре не знал никто.
В декабре я оперировал не номером, а строчкой в примечаниях к выпускам: 10.1.13 для ветки 10.1 и 10.0.18 для ветки 10.0 закрывали хранимый XSS в классическом веб-интерфейсе через CSS-директивы. Обе версии к тому моменту уже лежали в открытом доступе. То есть сервер стоял ниже границы на четыре минорных выпуска — и это при том, что искать по номеру CVE было ещё нечего. Обновлялись они раз в год «когда есть окно», и окно всё не находилось.
Версия, которую я проверял два дня
Моя первая гипотеза была простая: у бухгалтера увели пароль. В той же кампании, из которой пришла эта атака, действительно применялись AiTM-фишинговые наборы — поддельные страницы входа Zimbra, через которые крадут сессионные cookie и обходят повторную аутентификацию. Схема известная, встречается часто, и она отлично объясняла бы доступ к архиву.
Я честно её отрабатывал. Смотрел входы:
grep -h "buh@stroy-avia.ru" /opt/zimbra/log/mailbox.log* \
| grep -oP 'oip=\K[0-9.]+' | sort | uniq -c | sort -rn
# 1841 91.211.x.x (офис)
# 92 176.59.x.x (мобильный оператор)
# 4 5.188.x.x
Четыре входа с чужого адреса нашлись, и я обрадовался. А потом посмотрел даты — все четыре легли ПОСЛЕ первой утечки, а не до. То есть это был уже результат, а не причина. Дальше я два дня искал поддельную страницу входа: перебирал письма за квартал, смотрел домены, проверял историю браузеров на трёх машинах. Ничего похожего.
Развернуло меня банальное наблюдение. Пострадавших ящиков было четыре, а не один. И у всех четверых общее только одно — они сидели в веб-почте с включённой панелью предпросмотра. Люди, работавшие через Outlook по IMAP, не пострадали ни один.
CSS @import: как оформление становится кодом
HTML-письмо — это веб-страница, которую ваш почтовый клиент рисует у себя. Чтобы это было безопасно, клиент обязан вырезать из письма всё исполняемое: скрипты, обработчики событий, опасные атрибуты. Этим занимается санитайзер.
Санитайзер классического веб-интерфейса Zimbra некорректно обрабатывал директивы CSS @import внутри HTML-письма. Директива @import в нормальной жизни делает безобидную вещь — подтягивает дополнительную таблицу стилей. Она про оформление. Именно поэтому её и пропускали мимо строгих проверок: ну стиль и стиль.
Результат — хранимый межсайтовый скриптинг. Хранимый, потому что вредонос лежит в самом письме, в вашем же ящике, на вашем же сервере. Он срабатывает не один раз в момент доставки, а каждый раз, когда письмо отрисовывается.
Дальше начинается интересное. Скрипт исполняется в контексте вашего домена веб-почты и с правами вашей уже открытой сессии. Ему не надо ничего взламывать. Он уже внутри, уже авторизован, уже видит то же самое, что видите вы.
Half-click вместо клика
Атаку описывают как «half-click» — жертве достаточно открыть письмо или просто увидеть его в панели предпросмотра. Никаких действий, требующих осознанного согласия, не нужно. В интерфейсе, где панель предпросмотра включена по умолчанию, а список писем листают колёсиком мыши, это означает срабатывание при обычном разборе входящих.
Инструментом атакующих был ZimReaper — он встраивает обфусцированный JavaScript прямо в тело письма. Ни вложений, ни ссылок, ни макросов. Именно поэтому весь ваш многолетний инструктаж «не открывай вложения» здесь бесполезен.
Что именно уносят
Список того, что забирает этот класс атак, стоит прочитать целиком, потому что он объясняет, почему смены пароля недостаточно.
- Сессионные токены. Ваш пароль не нужен — нужен активный токен, и он у скрипта под рукой.
- Логины и пароли — те, что вводятся в интерфейс.
- Резервные коды двухфакторной аутентификации. Те самые, которые вы распечатали и убрали в сейф, а копия осталась в профиле.
- Пароли, сохранённые в браузере — не только от почты.
- До 90 дней архива переписки жертвы.
Девяносто дней. У главного бухгалтера этой компании за квартал накопилось 6 840 писем — вся тендерная переписка, сканы актов, реквизиты, согласования смет. Всё это уехало одним куском.
Кампания, в рамках которой применялась эта техника, известна как Operation GhostMail. Фишинговое письмо было написано на украинском языке от имени «студента 4 курса Национальной академии внутренних дел» — без вложений, без ссылок, без макросов. Цель — Государственная гидрографическая служба Украины, объект критической инфраструктуры при Минтрансе. Атрибуция с умеренной степенью уверенности — APT28. В более широкой кампании фигурирует группировка Laundry Bear (она же Void Blizzard) — её в мае 2025 года описали нидерландские спецслужбы.
Строительная фирма на 33 рабочих места в этот список целей, разумеется, не входит. Но инструмент, написанный под госсектор, дальше расходится по рынку и применяется по площадям — просто потому, что сканеру всё равно, чей сервер отвечает уязвимой версией.
Проверьте у себя за 2 минуты
Две команды на сервере плюс один взгляд в интерфейс.
su - zimbra
zmcontrol -v
zmprov gc default | grep -iE "ReadingPane|TwoFactor|HtmlPreferred"
| Что видите | Как читать |
|---|---|
| Ветка 10.0 ниже 10.0.18 | Уязвимы. Обновление в пределах ветки |
| Ветка 10.1 ниже 10.1.13 | Уязвимы. Обновление в пределах ветки |
| 10.0.18 и выше либо 10.1.13 и выше | Эта конкретная дыра закрыта |
| Версия 8.8.15 или 9.x | Данная CVE вас не касается, но у вас свой набор проблем и версия без поддержки |
zimbraPrefReadingPaneLocation не равен off | Панель предпросмотра включена по умолчанию для всех — площадь поражения максимальная |
| Люди работают в классическом веб-интерфейсе | Именно он затронут. Пользователи почтовых клиентов по IMAP в стороне |
Отдельно посмотрите на календарь обновлений. Если между вашей версией и последней в ветке больше трёх выпусков, дело не в этой CVE — дело в том, что у вас нет процесса обновления. Он и есть настоящая проблема.
Что мы делали и в каком порядке
Обновление до 10.1.13 в ту же ночь мы поставить не могли — окна не было, стройка сдавала объект и переписка шла до одиннадцати вечера. Поэтому сначала пошли компенсирующие меры, а обновление легло на выходные.
- Выключили панель предпросмотра всем сразу, через класс обслуживания. Одна команда — и площадь поражения схлопывается до тех, кто письмо реально открыл.
- Нашли и удалили вредоносные письма из ящиков.
- Инвалидировали сессии у всех, а не только у четверых пострадавших.
- Сменили пароли и перевыпустили резервные коды двухфакторки.
- В субботу обновились до 10.1.13 — по строчке в примечаниях к выпуску, ещё без всякого номера CVE.
# снять предпросмотр для всех
zmprov mc default zimbraPrefReadingPaneLocation off
# поиск писем с подозрительной директивой в теле
grep -rl --include='*.msg' -iE 'style[^>]*@import|@import[[:space:]]*url' \
/opt/zimbra/store/0/*/msg/ 2>/dev/null | tee /root/ioc-msgs.txt | wc -l
# 27
# путь к каталогу store подставьте из вывода zmvolume -l:
# на серверах с несколькими томами шаблон /opt/zimbra/store/0/*/msg/ промахнётся
# инвалидация всех активных токенов конкретного ящика
zmprov ma buh@stroy-avia.ru zimbraAuthTokenValidityValue 2
zmprov ga buh@stroy-avia.ru zimbraTwoFactorAuthEnabled
Про атрибут zimbraAuthTokenValidityValue стоит сказать отдельно, потому что о нём мало кто помнит. Увеличение этого числа делает недействительными все ранее выданные токены аутентификации ящика. Без него смена пароля не выкидывает того, кто уже сидит внутри с украденным токеном: пароль новый, а сессия старая и живая.
Из двадцати семи найденных писем реально вредоносными оказались три. Остальные двадцать четыре — обычные рассылки, где @import использован по прямому назначению, для подгрузки шрифтов. Это к вопросу о том, почему нельзя просто «забанить @import и жить дальше».
Где я чуть не закрыл инцидент рано
В первый вечер я сделал ровно то, что делает большинство: сменил пароль пострадавшему бухгалтеру и успокоился. Логика понятная — пароль скомпрометирован, пароль сменили, дальше закрыто.
На следующее утро в журнале снова были обращения к её ящику с адреса, который мы уже видели. Пароль новый, а сессия старая — токен, выданный до смены пароля, продолжал работать. Именно тогда я и полез за zimbraAuthTokenValidityValue.
Второй момент, на котором я тормознул, — резервные коды двухфакторной аутентификации. У четверых пострадавших двухфакторка была включена, и первая реакция была «ну хорошо, значит защищены». Ошибка. Резервные коды входят в список того, что уносит этот скрипт. Двухфакторка с утёкшими резервными кодами защищает ровно ни от чего — по ним заходят так же, как по коду из приложения. Мы перевыпускали их всем четверым руками.
И третье, чего я не сделал, а надо было. Скрипт исполнялся в браузере пользователя, с полными правами его сессии. Значит, он мог видеть не только почту. Пароли, сохранённые в том же браузере, — банк-клиент, кабинет госзакупок, СБИС, что угодно. Я поднял этот вопрос только на третий день, и к тому моменту сотрудники уже неделю ходили с теми же паролями по всем остальным сервисам. Обошлось. Но я до сих пор не уверен, что мы увидели все последствия.
Счёт и вывод
Считаем. Работа по инциденту — 27 часов, из них 11 ушло на ту самую тупиковую версию с фишингом. Обновление сервера с 10.1.9 до 10.1.13 заняло 2 часа 40 минут в субботу, почта не ходила 34 минуты. Сумма по инциденту — 96 000 рублей.
Плановое обновление, которое всё это снимало, стоило бы одно окно в три часа и ноль рублей сверх абонентской платы. Разница — не в деньгах даже. Разница в том, что 6 840 писем тендерной переписки теперь у постороннего, и вернуть их нельзя никаким бюджетом.
Что до сложности. Само обновление в пределах ветки 10.1 — обычная процедура, документированная, воспроизводимая. Тяжело не это.
- Тяжело определить, был ли ты целью. Письмо не выглядит как атака — оно выглядит как письмо, часто вообще пустое на вид.
- Тяжело отличить вредоносный
@importот рассылки с подгрузкой шрифтов. У меня из 27 совпадений вредоносными были 3. - Тяжело понять границы утечки. Девяносто дней архива — это верхняя оценка, а сколько ушло реально, по логам не восстанавливается.
- Тяжело удержаться и не закрыть инцидент после смены пароля. Я не удержался.
- И отдельно: тяжело принимать решения, когда у дыры ещё нет номера. Мы обновлялись по строчке в примечаниях к выпуску, а публичная запись CVE появилась только через месяц. Если ваш процесс обновления запускается словом «CVE» в новостях — вы будете стабильно опаздывать на этот самый месяц.
Если у вас десятая ветка Zimbra — выполните две команды из блока самодиагностики и пришлите мне вывод, вместе с ответом на вопрос, работают ли люди в веб-почте или в почтовых клиентах. За день скажу, попадаете ли вы под эту CVE, что смотреть в хранилище на предмет следов и сколько займёт обновление именно у вас.
Частые вопросы
Мы обновились. Значит, всё закончилось?
Обновление закрывает возможность новой эксплуатации, но не отменяет того, что уже произошло. Если уязвимая версия стояла хоть сколько-то долго, надо отдельно проверить: нет ли в хранилище писем с подозрительными директивами, не появились ли у ящиков лишние правила пересылки, не менялись ли адреса восстановления. И обязательно инвалидировать сессионные токены командой zmprov ma ящик zimbraAuthTokenValidityValue с увеличенным числом — иначе тот, кто уже внутри, останется внутри и после патча.
Почему смена пароля не выкидывает злоумышленника?
Потому что пароль и сессионный токен — разные вещи. Пароль нужен, чтобы получить токен. Дальше клиент ходит с токеном, и пароль ему больше не требуется до истечения срока действия. Украли токен — украли доступ, пароль вообще не участвует. В Zimbra за принудительное обнуление всех выданных токенов отвечает атрибут zimbraAuthTokenValidityValue: увеличиваете значение на единицу, и все старые токены этого ящика становятся недействительными. Я об этом вспомнил только на второе утро, когда увидел в логе обращения с уже известного адреса при новом пароле.
Нас защитит двухфакторная аутентификация?
От перебора паролей — да. От этой атаки — нет, и вот почему. В списке того, что забирает встроенный в письмо скрипт, есть резервные коды двухфакторной аутентификации. Это те самые одноразовые коды на случай потери телефона. Они равносильны второму фактору, и утёкший комплект отдаёт злоумышленнику вход мимо всей вашей защиты. Поэтому после инцидента резервные коды перевыпускаются в обязательном порядке всем, у кого двухфакторка включена, а не только очевидно пострадавшим.
Можно ли защититься, не обновляясь прямо сейчас?
Частично. Отключение панели предпросмотра через класс обслуживания командой zmprov mc default zimbraPrefReadingPaneLocation off резко сокращает площадь поражения: срабатывание требует, чтобы человек открыл письмо целиком, а не просто проехал по нему в списке. Плюс имеет смысл на время увести людей в почтовые клиенты по IMAP — уязвим именно классический веб-интерфейс. Но это отсрочка на неделю-две, а не решение. Единственное решение — версия 10.0.18 или 10.1.13 и выше.
Как найти вредоносные письма в хранилище, если их уже прочитали?
Файлы писем лежат в /opt/zimbra/store, каждое сообщение — отдельный файл с расширением .msg. Их можно прочесать обычным grep по признаку директивы @import в стилях. Дальше начинается ручная работа: в моём случае из 27 найденных писем вредоносными были три, а остальные 24 оказались обычными маркетинговыми рассылками, где @import подтягивает шрифты. Отличать приходится глазами по содержимому — смотреть, что именно импортируется, откуда и есть ли рядом обфусцированный скрипт.
Оставить комментарий