Все технические проверки письма — подпись, политика домена, маршрут доставки — ловят подделку. Против захвата они не работают вообще. Если атакующий получил доступ к настоящему ящику вашего подрядчика, письмо приходит с настоящего сервера, с валидной подписью, внутри настоящей переписки. Проверять в нём нечего: оно подлинное. Поддельно только намерение.
Это тот самый сценарий, на котором компании теряют деньги: приходит письмо от знакомого человека, из знакомого треда, с новыми банковскими реквизитами. Формально не подкопаться.
Работает в такой ситуации ровно одно: оценивать письмо не само по себе, а на фоне того, как эти двое переписывались раньше. Нового продукта и бюджета для этого не нужно — нужны логи почтового шлюза, которые у вас уже лежат.
Что вообще есть в логах шлюза
Нужно немного: кто, кому, когда. Ни темы, ни тела, ни вложений — только метаданные доставки. Это важно, потому что снимает половину вопросов от юристов и от службы персонала: мы не читаем переписку, мы считаем, кто с кем обменивался письмами.
Из postfix это вытаскивается парой строк:
grep -h ' from=<' /var/log/mail.log* \ | sed -E 's/.* ([A-F0-9]+): from=<([^>]*)>.*/\1 \2/' > from.txt grep -h ' to=<' /var/log/mail.log* \ | sed -E 's/.* ([A-F0-9]+): to=<([^>]*)>.*status=([a-z]+).*/\1 \2 \3/' > to.txt
Postfix пишет отправителя и получателя разными строками, связанными идентификатором очереди, поэтому их приходится склеивать по нему:
join -j 1 <(sort from.txt) <(sort to.txt) > pairs.txt
В Exchange проще — там есть готовая выгрузка:
Get-MessageTrace -StartDate (Get-Date).AddDays(-10) -EndDate (Get-Date) -PageSize 5000 | Select-Object Received, SenderAddress, RecipientAddress, Status | Export-Csv trace.csv -NoTypeInformation
Оговорка: Get-MessageTrace отдаёт ограниченную глубину истории, за более старым надо идти в отчёты или выгружать регулярно. Поэтому, если вы вообще собираетесь этим заниматься, начните с того, чтобы складывать выгрузку куда-нибудь ежедневно — через три месяца у вас будет то, на чём всё это работает.
Профиль пары
Основная структура — не список писем, а словарь пар «внешний адрес → внутренний адрес» с несколькими числами по каждой.
import csv from collections import defaultdict from datetime import datetime, timedelta INTERNAL = ('corp-holding.ru',) def is_internal(addr): return addr.lower().endswith(tuple('@' + d for d in INTERNAL)) def build_profiles(csv_path): pairs = defaultdict(lambda: { 'count': 0, 'first': None, 'last': None, 'inbound': 0, 'outbound': 0, 'hours': defaultdict(int), }) with open(csv_path, encoding='utf-8-sig') as f: for row in csv.DictReader(f): sender = row['SenderAddress'].lower() rcpt = row['RecipientAddress'].lower() ts = datetime.fromisoformat(row['Received'].replace('Z', '+00:00')) if is_internal(sender) == is_internal(rcpt): continue # внутренние и транзитные пропускаем external, internal_addr = (rcpt, sender) if is_internal(sender) else (sender, rcpt) p = pairs[(external, internal_addr)] p['count'] += 1 p['first'] = min(p['first'] or ts, ts) p['last'] = max(p['last'] or ts, ts) p['hours'][ts.hour] += 1 if is_internal(sender): p['outbound'] += 1 else: p['inbound'] += 1 return pairs
Дальше на этих числах строятся признаки. Их немного, и каждый отвечает на свой вопрос.
Первый контакт. Пары нет в истории вообще. Само по себе это норма — компании каждый день начинают переписываться с новыми людьми. Но в сочетании с просьбой что-то оплатить это совсем другой уровень риска, чем такая же просьба от адреса, с которым переписываются два года.
Односторонность. Писем сорок, все входящие, ни одного ответа. Обычная деловая переписка двусторонняя. Односторонний поток — это либо рассылка, либо кто-то пытается завязать разговор, а ему не отвечают.
Разрыв. Пара активно переписывалась год назад, потом тишина восемь месяцев, и вдруг новое письмо в существующем треде. Классический рисунок для захваченного ящика: атакующий поднимает старую переписку, потому что она даёт достоверность.
Смена часов. Контрагент два года писал с девяти до шести по будням, а теперь пишет в три ночи и по выходным. Здесь надо быть аккуратным — часовые пояса, авралы, отпуска, — но само распределение по часам считается дёшево и в связке с остальным работает.
Соседний адрес. Есть давняя пара с ivanov@postavshchik.ru, а сегодня пишет ivanov@postavshchik-ru.com или i.ivanov@postavshchik.ru. Вот это, по моему опыту, самый результативный признак из всех: адрес похож на знакомый, но в истории его нет.
Последнее считается сравнением похожести доменов. Наивный вариант — прогнать домены целиком через SequenceMatcher — работает плохо, и это стоит показать на числах: postavshchik-ru.com против postavshchik.ru даёт всего 0.82, то есть при разумном пороге подмена не находится. Мешает зона: она короткая, но при сравнении целых строк весит наравне с именем.
Поэтому имя и зону надо разделять. Дефисы при сравнении игнорируем: их вставка или удаление — один из самых частых приёмов подмены.
from difflib import SequenceMatcher def split_domain(d): parts = d.split('.') return '.'.join(parts[:-1]) or d, parts[-1] def norm(name): return name.replace('-', '').replace('.', '') def domain_similarity(a, b): if a == b: return 1.0 # тот же самый домен, не подмена a_name, a_zone = split_domain(a) b_name, b_zone = split_domain(b) na, nb = norm(a_name), norm(b_name) if na == nb: return 0.95 # то же имя в другой зоне или с дефисом if min(len(na), len(nb)) < 7: return 0.0 # короткие имена сравнивать бессмысленно return SequenceMatcher(None, na, nb).ratio()
Что даёт эта функция на живых примерах:
0.950 postavshchik.com postavshchik.ru 0.923 postavshchik-ru.com postavshchik.ru 0.950 sber-bank.ru sberbank.ru 0.909 corp-hoiding.ru corp-holding.ru 0.870 stroymontazh.ru stroymontaj.ru 0.000 mail.ru gmail.com 0.000 yandex.ru ya.ru
Отдельно поясню отсечку по длине. Без неё пара mail.ru — gmail.com даёт 0.89 и попадает в подозрительные. Коротких имён в любом списке контрагентов много, и похожими они оказываются просто из-за нехватки букв. Семь символов — эмпирическая граница, при которой этот шум пропадает, а осмысленные подмены остаются.
Дальше домен сравнивается со всеми знакомыми, а к результату добавляется проверка левой части адреса:
def close_known(addr, known_addrs, threshold=0.88): local, _, domain = addr.partition('@') hits = [] for k in known_addrs: if k == addr: return [] # адрес знаком, дальше не смотрим k_local, _, k_domain = k.partition('@') d = domain_similarity(domain, k_domain) if d >= threshold: l = SequenceMatcher(None, local, k_local).ratio() if l > 0.6: hits.append((k, round(d, 2), round(l, 2))) return hits
Порог 0.88 у меня получился подбором, и подбирать его придётся и вам. Способ такой: берёте сотню самых частых внешних доменов и прогоняете функцию внутри этого списка. Если она находит пары, которые вы сами не различаете с первого взгляда, — порог правильный. Если начинает считать похожими соседей по алфавиту, порог низкий.
Собираем оценку
WEIGHTS = { 'first_contact': 2, 'lookalike_known': 6, 'dormant_revived': 3, 'one_way': 2, 'unusual_hours': 1, } def score_message(sender, recipient, ts, pairs, known_external): hits = [] p = pairs.get((sender, recipient)) if p is None: hits.append('first_contact') if close_known(sender, known_external): hits.append('lookalike_known') else: if p['last'] and ts - p['last'] > timedelta(days=180): hits.append('dormant_revived') if p['outbound'] == 0 and p['inbound'] >= 5: hits.append('one_way') busy = {h for h, c in p['hours'].items() if c >= max(2, p['count'] * 0.05)} if busy and ts.hour not in busy: hits.append('unusual_hours') return sum(WEIGHTS[h] for h in hits), hits
Веса тут нужны для одного: ни один признак не должен решать сам. first_contact весит два — это просто «обратите внимание». А first_contact вместе с lookalike_known даёт восемь, и это уже адрес, который притворяется знакомым, — сюда стоит смотреть человеку, независимо от содержания письма.
Считать всё это в реальном времени не обязательно. Профили пересчитываются ночью по накопленной выгрузке, а оценка живого письма — это один поиск в словаре.
Куда девать результат
Идеальный вариант — плашка в теле письма для получателя: «первое письмо с этого адреса» или, что гораздо ценнее, «адрес похож на знакомый вам ivanov@postavshchik.ru, но это другой адрес». Такую строку человек читает раньше, чем текст, и она работает именно в тот момент, когда работать больше нечему.
Плашки — вообще недооценённый инструмент. Они не требуют от сотрудника ничего знать и ничего вспоминать, и в этом их преимущество перед любым обучением.
Второй вариант — тихая карточка в SOC без уведомления пользователя, для высоких оценок. Здесь стоит сразу договориться, кто и как быстро это разбирает: детект, карточки которого копятся неразобранными, хуже отсутствия детекта, потому что создаёт ложное ощущение контроля.
И третий, самый дешёвый: выгрузка раз в неделю для бухгалтерии — список адресов, впервые написавших в компанию и упомянувших банковские реквизиты. Тут даже автоматизация не нужна, достаточно фильтра.
Где ломается
Общие ящики. info@, sales@, zakupki@ переписываются со всем миром, и профиль пары там бессмысленный: первый контакт у них каждый день. Такие адреса надо выносить в отдельную категорию и оценивать иначе — по домену, а не по адресу.
Малый объём. Если компания получает двести писем в день, статистики на пару почти не набирается, и большинство признаков не работают. Метод по-настоящему оживает где-то от нескольких тысяч писем в сутки.
Захват без смены адреса. Если атакующий сидит в ящике контрагента и пишет из существующего треда в обычное рабочее время — все признаки молчат. Останется разве что «смена часов», и то не всегда. Против этого сценария работает только процедура: смена платёжных реквизитов подтверждается звонком по номеру из договора, а не по номеру из письма. Скучная нетехническая мера, которая спасает деньги чаще, чем весь скоринг вместе взятый.
Юридическая сторона. Прежде чем строить графы переписки сотрудников, стоит сходить к тем, кто в компании отвечает за персональные данные, и проговорить, что именно вы обрабатываете и зачем. Метаданные доставки — это не содержимое, и обычно вопрос решается быстро, но задним числом такие разговоры проходят тяжелее.
Что я бы сделал в первую очередь
Если возиться со всем этим не хочется, есть версия для одного вечера, которая даёт большую часть пользы.
Возьмите выгрузку за три месяца. Постройте множество внешних адресов, с которыми компания реально переписывалась в обе стороны. Дальше каждое входящее письмо от адреса, которого в этом множестве нет, проверяйте функцией похожести против него же.
Всё. Никаких весов, часов и разрывов — только «этот адрес похож на знакомый, но знакомым не является». По моим наблюдениям, именно этот один признак вылавливает большую часть попыток притвориться существующим контрагентом, и написать его — час работы.
mtumanov
И это единственное что действительно работает - подтверждение смены реквизитов получателя звонком по номеру из договора.