Граф переписки: как поймать поддельного контрагента, когда все проверки письма проходят

от автора

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

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

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

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

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

Из postfix это вытаскивается парой строк:

grep -h ' from=<' /var/log/mail.log* \  | sed -E 's/.* ([A-F0-9]+): from=<([^>]*)>.*/\1 \2/' > from.txtgrep -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 csvfrom collections import defaultdictfrom datetime import datetime, timedeltaINTERNAL = ('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 SequenceMatcherdef 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.ru0.923  postavshchik-ru.com    postavshchik.ru0.950  sber-bank.ru           sberbank.ru0.909  corp-hoiding.ru        corp-holding.ru0.870  stroymontazh.ru        stroymontaj.ru0.000  mail.ru                gmail.com0.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@ переписываются со всем миром, и профиль пары там бессмысленный: первый контакт у них каждый день. Такие адреса надо выносить в отдельную категорию и оценивать иначе — по домену, а не по адресу.

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

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

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

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

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

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

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

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