Все технические проверки письма — подпись, политика домена, маршрут доставки — ловят подделку. Против захвата они не работают вообще. Если атакующий получил доступ к настоящему ящику вашего подрядчика, письмо приходит с настоящего сервера, с валидной подписью, внутри настоящей переписки. Проверять в нём нечего: оно подлинное. Поддельно только намерение.

Это тот самый сценарий, на котором компании теряют деньги: приходит письмо от знакомого человека, из знакомого треда, с новыми банковскими реквизитами. Формально не подкопаться.

Работает в такой ситуации ровно одно: оценивать письмо не само по себе, а на фоне того, как эти двое переписывались раньше. Нового продукта и бюджета для этого не нужно — нужны логи почтового шлюза, которые у вас уже лежат.

Что вообще есть в логах шлюза

Нужно немного: кто, кому, когда. Ни темы, ни тела, ни вложений — только метаданные доставки. Это важно, потому что снимает половину вопросов от юристов и от службы персонала: мы не читаем переписку, мы считаем, кто с кем обменивался письмами.

Из 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.rugmail.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@ переписываются со всем миром, и профиль пары там бессмысленный: первый контакт у них каждый день. Такие адреса надо выносить в отдельную категорию и оценивать иначе — по домену, а не по адресу.

Малый объём. Если компания получает двести писем в день, статистики на пару почти не набирается, и большинство признаков не работают. Метод по-настоящему оживает где-то от нескольких тысяч писем в сутки.

Захват без смены адреса. Если атакующий сидит в ящике контрагента и пишет из существующего треда в обычное рабочее время — все признаки молчат. Останется разве что «смена часов», и то не всегда. Против этого сценария работает только процедура: смена платёжных реквизитов подтверждается звонком по номеру из договора, а не по номеру из письма. Скучная нетехническая мера, которая спасает деньги чаще, чем весь скоринг вместе взятый.

Юридическая сторона. Прежде чем строить графы переписки сотрудников, стоит сходить к тем, кто в компании отвечает за персональные данные, и проговорить, что именно вы обрабатываете и зачем. Метаданные доставки — это не содержимое, и обычно вопрос решается быстро, но задним числом такие разговоры проходят тяжелее.

Что я бы сделал в первую очередь

Если возиться со всем этим не хочется, есть версия для одного вечера, которая даёт большую часть пользы.

Возьмите выгрузку за три месяца. Постройте множество внешних адресов, с которыми компания реально переписывалась в обе стороны. Дальше каждое входящее письмо от адреса, которого в этом множестве нет, проверяйте функцией похожести против него же.

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

Комментарии (1)


  1. mtumanov
    27.08.2026 20:00

    Захват без смены адреса.
    Если атакующий сидит в ящике контрагента и пишет из существующего треда в обычное рабочее время — все признаки молчат. Останется разве что «смена часов», и то не всегда. Против этого сценария работает только процедура: смена платёжных реквизитов подтверждается звонком по номеру из договора, а не по номеру из письма. Скучная нетехническая мера, которая спасает деньги чаще, чем весь скоринг вместе взятый.

    И это единственное что действительно работает - подтверждение смены реквизитов получателя звонком по номеру из договора.