Fuzzy-хеши и нейромодуль rspamd: как ловить массовые волны и точечный спам, который проходит сквозь Байес
Байес и таблица весов ломаются на двух классах писем: мутирующих массовых рассылках и точечном фишинге, где каждый символ по отдельности ниже порога. Это две разные задачи для двух разных инструментов rspamd. Разбираем, как поднять своё зашифрованное fuzzy-хранилище на шинглах и обучить нейросеть так, чтобы она сходилась, а не отравляла сама себя.
EvilMail Team22 июля 2026 г.12 мин чтения
Почему Байес и статические правила проседают
Два письма из вчерашнего лога rspamd. Оба прошли мимо байесовского классификатора — и по совершенно разным причинам.
Первое — волна на 40 тысяч сообщений с темой «Ваш инвойс №…». Тело у каждого получателя своё: рандомизированный порядок абзацев, подмена синонимов («оплатите» → «внесите платёж»), плюс невидимый мусор из zero-width пробелов. Байес честно токенизирует каждое письмо и видит уникальный набор токенов. BAYES_SPAM не срабатывает, потому что статистически это «новое» письмо, которого он раньше не встречал. Классификатор не для того устроен, чтобы ловить дубликаты — он ловит характерный словарь, а словарь здесь размазан по синонимам.
Второе письмо — аккуратный фишинг под банк, отправленный на три адреса. SPF даёт softfail (+1.0), домен зарегистрирован пять дней назад (+1.5), в теле один URL с редиректом (+1.5), отправитель не в адресной книге (+0.5). Метабалл — 4.2 при пороге reject 6.0
. Каждый символ по отдельности «ну, бывает». Их комбинация — очевидный фишинг для любого человека, но линейная сумма весов до порога не дотягивает. И руками эту комбинацию в таблицу не впишешь: правил вида «softfail И свежий домен И редирект → спам» будут сотни, и они начнут задевать легитимную почту.
Это две ортогональные проблемы. Первая — near-duplicate detection: «я уже видел почти такое же письмо». Вторая — нелинейная комбинация слабых сигналов: «по отдельности терпимо, вместе — нет». В rspamd под них есть два разных инструмента, и путать их — прямой путь к ложным срабатываниям.
Fuzzy-хеши — это шинглы, а не MD5
Главное заблуждение: «fuzzy — это контрольная сумма письма». Нет. Если бы rspamd считал MD5 от тела, мутация одного байта ломала бы совпадение полностью — ровно то, от чего злоумышленник и защищается рандомизацией.
На деле fuzzy_check работает на шинглах (shingles). rspamd нормализует текст — приводит к нижнему регистру, выкидывает стоп-слова, применяет стемминг — а затем режет его скользящим окном по словам. По умолчанию берётся 32 шингла: перекрывающихся окон из подряд идущих слов. Каждое окно хешируется, и итоговый «отпечаток» письма — это набор из 32 хешей, а не один.
Совпадение считается по доле совпавших шинглов, а не по идентичности всего письма. Изменил спамер 15% текста — часть окон, задевших изменённые слова, дадут другие хеши, но остальные останутся прежними. Если совпало достаточно шинглов, письмо ловится. Именно поэтому fuzzy устойчив к мутации в 10–20%, на которой ломается любая честная контрольная сумма.
Пара параметров, о которые все спотыкаются. min_bytes и min_length отсекают слишком короткие письма — на «Спасибо, оплачено» нормальный набор шинглов не построишь, и такое сообщение лучше не хешировать, иначе рискуете ловить каждое короткое ham-письмо. Картинки хешируются отдельным механизмом (image hashing), а не через текст. И самое коварное: algorithm. Сейчас по умолчанию mumhash (раньше был siphash, ещё раньше xxhash). Он обязан совпадать между storage и check — иначе хеши считаются по-разному, и вы получаете тихие ноль совпадений без единой ошибки в логе.
Поднимаем своё fuzzy-хранилище
Дефолтный конфиг тянет чужой фид fuzzy.rspamd.com в режиме read_only. Как источник это нормально, но жить только на нём — значит зависеть от чужих порогов и ничего не уметь про ваши волны. Первую жалобу на инвойс-рассылку вы хотите превратить в блокировку остальных 39 999 писем за секунды — а для этого нужно своё хранилище, в которое вы пишете.
Для одиночного сервера годится встроенный sqlite-backend, но для нескольких нод берите Redis — он общий и не блокируется на запись.
hocon
# /etc/rspamd/local.d/fuzzy_storage.conf
backend = "redis";
servers = "127.0.0.1:6379";
expire = 90d;
allow_update = "127.0.0.1"; # кто имеет право писать хеши
Демон fuzzy_storage слушает UDP 11335. Это первое, что нужно закрыть фаерволом: снаружи туда никто ходить не должен.
Теперь шифрование. Без него любой, кто достучится до порта, может читать ваши хеши и — что хуже — отравлять хранилище, подсовывая туда хеши легитимных писем. Генерируем ключевую пару:
bash
rspamadm keypair -u
Публичный ключ из вывода прописываем в правило проверки. Важный нюанс: своё правило работает с read_only = false (мы и пишем, и читаем), а публичный фид rspamd.com оставляем отдельным правилом с read_only = true.
hocon
# /etc/rspamd/local.d/fuzzy_check.conf
rule "local" {
algorithm = "mumhash"; # ДОЛЖЕН совпадать со storage
servers = "127.0.0.1:11335";
encryption_key = "<pk из rspamadm keypair>";
symbol = "LOCAL_FUZZY_UNKNOWN";
mime_types = ["*"];
min_bytes = 1024;
read_only = false;
skip_unknown = true;
fuzzy_map = {
LOCAL_FUZZY_DENIED { max_score = 20.0; flag = 1; }
LOCAL_FUZZY_PROB { max_score = 12.0; flag = 2; }
}
}
flag — это метка «полки» в хранилище. Flag 1 (FUZZY_DENIED, max_score 20) — то, что мы уверенно считаем спамом; flag 2 (FUZZY_PROB, max_score 12) — «похоже, но мягче». Одно письмо можно класть на разные полки в зависимости от уверенности источника.
Обучение fuzzy: flag, weight и автоматизация
Добавляем спам-письмо в хранилище:
bash
rspamc -f 1 -w 10 fuzzy_add msg.eml # flag 1, weight 10
rspamc -f 1 fuzzy_del msg.eml # удалить хеш
rspamc -c local fuzzy_add msg.eml # явно указать правило "local"
Ключевой момент, который понимают неправильно: weight накапливается, а не суммируется линейно. Учите один и тот же хеш многократно — вес растёт и масштабируется к max_score правила, асимптотически приближаясь к нему, а не улетая в бесконечность. Логика простая: чем больше независимых подтверждений «это спам», тем ближе символ к своему потолку. Отсюда практическое следствие — дедуп по Message-ID в конвейере обучения, иначе один и тот же тикет, обработанный трижды, искусственно раздует вес.
Конвейер, который реально работает в проде:
Пользователь кидает письмо в папку Junk (или в служебный учебный ящик).
Sieve/imap-скрипт по событию на новое письмо дёргает rspamc -f 1 -w 10 fuzzy_add на каждый новый файл.
Перед добавлением скрипт проверяет Message-ID по локальному кэшу — уже учили, пропускаем.
Два правила техники безопасности. Ham во fuzzy не учат — это не Байес, здесь нет «отрицательного класса», хранилище только для спама. И не кладите туда собственные транзакционные и маркетинговые шаблоны: ваша же рассылка «Подтвердите email» — идеальный near-duplicate сам себе, и однажды научив её как спам, вы прибьёте всю легитимную серию.
Где fuzzy бессилен
Вернёмся ко второму письму — таргетированному фишингу на три адреса. У него нет near-duplicate в хранилище. Шинглы не совпадут ни с чем, потому что такого письма никто раньше не видел и не пожаловался. Fuzzy отвечает на вопрос «видел ли я похожее?» — а здесь ответ честное «нет».
Проблема не в дубликатах. Проблема в том, что комбинация слабых сигналов — softfail + свежий домен + редирект — должна весить больше суммы своих частей. Это ровно та задача, под которую в rspamd есть нейромодуль.
Как работает neural: вектор символов → Redis → MLP
Первое, что ломает интуицию: нейромодуль rspamd не смотрит на текст письма. Совсем. Его признаки — это вектор сработавших символов и их баллов, то есть выход всех остальных модулей: SPF, DKIM, RBL, URL-проверки, Байес, fuzzy и так далее. Для сети письмо — это не буквы, а профиль «какие правила сработали и насколько».
Дальше — обратная связь, и это сердце модуля. Каждый обработанный вектор нейромодуль складывает в Redis как обучающий пример, размечая его спам/хам по итоговому метабаллу: выше spam_score — метка «спам», ниже ham_score — «хам», между ними — в обучение не идёт. Когда накопилось max_trains примеров, модуль обучает MLP на встроенной библиотеке kann, сохраняет готовую ANN обратно в Redis (ключи вида rn_<rule>_<digest>) и начинает подмешивать символы NEURAL_SPAM / NEURAL_HAM в метабалл новых писем.
Обратите внимание на пунктирную петлю: символы NEURAL_* возвращаются в тот же метабалл, по которому размечается следующий train-set. Нейросеть учится на решениях других правил — и это одновременно её сила и главная опасность.
Настройка нейросети без самоотравления
Если дать нейромодулю большой вес слишком рано, он замкнётся сам на себя: разметит свои же ошибки как истину и начнёт усиливать собственные заблуждения. Вот рабочая отправная точка.
hocon
# /etc/rspamd/local.d/neural.conf
neural {
rules {
SHORT {
train {
max_trains = 5000; # сколько примеров до обучения
max_usages = 20; # против переобучения на повторах
max_iterations = 25;
learning_rate = 0.01;
spam_score = 6; # порог метки "спам"
ham_score = -2; # порог метки "хам"
train_prob = 1.0; # доля писем, идущих в обучение
learn_threshold = 0.1;
}
symbol_spam = "NEURAL_SPAM_SHORT";
symbol_ham = "NEURAL_HAM_SHORT";
ann_expire = 100d;
watch_interval = 60s;
}
}
allow_local = true;
roc_enabled = true;
}
Что здесь важно и почему:
`max_usages` и `train_prob < 1` защищают от переобучения на повторяющихся волнах. Если одна рассылка забьёт весь train-set, сеть выучит именно её, а не обобщённый признак спама.
`roc_enabled = true` — порог срабатывания выбирается по ROC-кривой на реальных данных, а не задаётся наугад. Без этого вы гадаете, где отрезать.
`max_inputs` / PCA снижают размерность, когда сработавших символов много: MLP стабильнее учится на компактном входе.
Профили по `settings` — разные потоки почты (транзакционка vs холодный входящий поток) должны иметь разные сети. Смешивать их в одной ANN — верный способ размыть оба.
`ann_expire` заставляет старые сети умирать, чтобы модель не жила на данных полугодовой давности.
Правило номер один: не поднимайте вес `NEURAL_SPAM` в метабалле, пока не сменилось несколько поколений ANN и вы не проверили статистику ложных срабатываний. Первые недели нейромодуль работает «в тени» — символ считается, но в вердикт почти не вносит.
Диагностика и грабли
Проверяем, что storage реально принимает хеши — по росту ключей в Redis и по счётчику:
bash
rspamc stat | grep -i fuzzy
redis-cli dbsize # число ключей должно расти после fuzzy_add
Веб-UI на порту 11334 показывает кнопку Learn, счётчики fuzzy-hits и историю. Если после fuzzy_add совпадений ноль — почти всегда это одна из двух вещей: рассинхрон `algorithm` между storage и check (mumhash против siphash — молча ноль) или забытый `encryption_key`.
Типичные грабли, каждая из которых стоила кому-то боевого инцидента:
Открытое хранилище. Нет encryption_key — любой в сети читает и травит fuzzy. Плюс порт 11335 наружу.
Свои рассылки как спам. Учли транзакционный или маркетинговый шаблон во fuzzy — прибили легитимную серию целиком.
«Нейросеть молчит». Символ NEURAL_* не появляется, пока не набрано max_trains. Это норма, а не ошибка — не трогайте конфиг, дайте накопиться.
Раздутый weight. Нет дедупа по Message-ID — один тикет, обработанный многократно, искусственно завышает вес хеша.
Чеклист внедрения
Ключевая пара сгенерирована (rspamadm keypair -u), encryption_key прописан в fuzzy_check.
algorithm (mumhash) идентичен в fuzzy_storage.conf и fuzzy_check.conf.
Своё хранилище на UDP 11335 закрыто фаерволом, allow_update ограничен локалхостом.
fuzzy_add встроен в конвейер с дедупом по Message-ID; ham во fuzzy не попадает.
Публичный фид rspamd.com остаётся отдельным правилом с read_only = true.
Neural и fuzzy backend работают на Redis; roc_enabled = true.
Вес NEURAL_SPAM поднимается постепенно, после нескольких поколений ANN.
Мониторинг ложных срабатываний по FUZZY_DENIED и NEURAL_SPAM включён с первого дня.
Fuzzy закрывает «я это уже видел» за секунды после первой жалобы. Нейросеть закрывает «по отдельности терпимо, вместе — нет». Байес и весовые правила остаются фундаментом, но два этих слоя — то, что превращает фильтр из статичной таблицы в систему, которая учится на вашем собственном потоке почты, а не на чужом.