Использование fuzzy хешей

English version

Введение

Нечеткие хеши (fuzzy hashes) используются для поиска похожих писем - то есть, при помощи такого метода можно найти сообщения, которые содержат такой же или незначительно измененный текст. Данная технология хорошо подходит для блокировки спам-рассылок, которые отправляются сразу многим пользователям. Так как хеш функция однонаправлена, то восстановить исходный текст, имея только хеш, невозможно, что позволяет направлять запросы сторонним хранилищам хешей без риска раскрытия информации.

Следует отметить, что rspamd использует нечеткие хеши не только для сравнения текстовых данных, но и изображений и других вложений в письмах. Однако в этом случае отсутсвует элемент нечеткой логики, и rspamd ищет точные соответствия для одинаковых объектов.

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

Шаг 1: выбор источников хешей

В первую очередь необходимо выбрать источники образцов спама для обучения. Основной принцип - использование для обучения спам-рассылок, которые приходят многим пользователям. Существует два основных подхода к решению подобной задачи:

  • использование жалоб пользователей;
  • создание ловушек для спама (honeypot).

Использование жалоб пользователей

Пользователи являются одним из ресурсов, позволяющих оценивать качество работы системы фильтрации спама, поэтому желательно предусмотреть механизм использования жалоб пользователей для обучения хранилища хешей. К сожалению, пользователи часто жалуются и на легитимные письма, на которые они сами же и подписались: рассылки магазинов, уведомления от систем бронирования билетов и даже на личные письма, которые им по каким-то причинам не нравятся. Многие пользователи просто не видят разницу между кнопками “удалить” и “пожаловаться на спам”. Возможно, хорошей идеей будет запросить у пользователя дополнительную информацию о жалобе, например, почему он считает, что это письмо спам, а так же обратить внимание пользователя на то, что он может отписаться от получения рассылок.

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

В rspamd также есть две возможности, позволяющие отсеивать некоторые “ложные” срабатывания:

  1. вес хеша;
  2. фильтры обучения.

Первый метод довольно прост: давайте назначим каждой жалобе некоторый вес и при каждом обучении будем прибавлять его к сохраненному значению соответствующего хеша. При проверке мы не будем учитывать хеши, вес которых меньше заданного порога. Например, если вес жалобы w=1, а порог срабатывания t=20, то для начала срабатывания хеша необходимо как минимум 20 жалоб пользователей. Кроме этого, при проверке для хеша, превышающего пороговое значение, rspamd назначает не максимальные очки, а их величина плавно растет от нуля до максимума (до значения метрики) при изменении веса хеша от порогового до удвоенного порогового значения (t .. 2*t).

Второй метод - фильтры обучения - позволяет написать некоторые условия на языке Lua, которые запрещают обучение, скажем, для писем с определенного домена (например, facebook.com). Возможности фильтров достаточно обширны, но требуют ручной работы по их настройке.

Настройка ловушек спама

Для данного метода необходимо иметь такие почтовые адреса, на которые не приходит легитимная почта, зато приходит много спама. Основная идея заключается в том, чтобы “засветить” адрес в базах спамеров, не показывая его легитимным пользователям. Для этого можно, например, разместить на достаточно популярном веб-сайте элемент iframe, который не отображается пользователям (имеет свойство hidden или нулевой видимый размер), но содержит доступные ботам email-адреса ловушек. В последнее время этот метод стал не очень эффективен, так как спамеры научились обходить такие приемы.

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

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

Шаг 2: Настройка хранилища

В данной главе я рассмотрю основные настройки хранилища и как оптимизировать его работу.

Важное замечание: хранилище работает не с письмами, а с готовыми хешами. То есть, чтобы преобразовать письмо в его хеш нужен отдельный процесс сканера или контроллера:

Функции хранилища:

  1. Хранение и проверка хешей
  2. Транспортное шифрование протокола
  3. Удаление устаревших хешей
  4. Контроль доступа (как на запись, так и на чтение)
  5. Репликация (с версии 1.3)

Архитектура хранилища

Для хранения данных используется sqlite3, и это накладывает некоторые ограничения на архитектуру хранилища.

Во-первых, sqlite крайне плохо работает при конкурентных запросах на запись: в этом случае производительность СУБД падает на несколько порядков. Во-вторых, довольно затруднительно обеспечивать репликацию и масштабирование базы данных, так как для этого нужны сторонние инструменты.

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

Устаревание хешей

Другой важной задачей хранилища является удаление устаревших хешей из базы. Так как обычно пордолжительность рассылок спама ограничена, то нет смысла хранить хеши постоянно. Разумным будет сопоставить количество хешей, которые приходят на обучение в течение определенного времени, с достаточным для их хранения размером оперативной памяти. Например, 400 тысяч хешей занимают около 100 мегабайт, а полтора миллиона хешей занимают уже полгигабайта.

Не рекомендуется допускать увеличение базы до объема, который превышает размер доступной оперативной памяти, из-за значительного ухудшения производительности. Кроме того, нет смысла хранить хеши дольше, чем приблизительно три месяца. Поэтому, если у вас небольшое количество хешей, пригодных для обучения, то лучше установить устаревание в 90 дней. В противном случае лучше установить более короткий период устаревания.

Пример настройки

За хранение нечетких хешей отвечает процесс rspamd под названием fuzzy_storage. Для включения и настройки этого процесса можно использовать локальный файл конфигурации rspamd: etc/rspamd/rspamd.conf.local:

worker "fuzzy" {
  # Socket to listen on (UDP and TCP from rspamd 1.3)
  bind_socket = "*:11335";

  # Number of processes to serve this storage (useful for read scaling)
  count = 4;

  # Backend ("sqlite" or "redis" - default "sqlite")
  backend = "sqlite";

  # sqlite: Where data file is stored (must be owned by rspamd user)
  database = "${DBDIR}/fuzzy.db";

  # Hashes storage time (3 months)
  expire = 90d;

  # Synchronize updates to the storage each minute
  sync = 1min;
}

Настройки доступа

По умолчанию rspamd не разрешает модифицировать данные в хранилище. Для того чтобы обучение было возможно, необходимо задать список доверенных IP-адресов и/или сетей. Как правило, лучше всего разрешить запись только с локальных адресов (127.0.0.1 и ::1), так как fuzzy storage использует портокол UDP, который не имеет защиты от подделки IP-адреса источника (что можно исправить путем настройки верификации обратного маршрута на маршрутизаторе, но это зачастую игнорируется системными администраторами):

worker "fuzzy" {
  # Same options as before ...

  allow_update = ["127.0.0.1", "::1"];

  # or 10.0.0.0/8, for internal network
}

Важное замечание: Если в списке серверов в конфигурации плагина fuzzy_check (см. далее) присутствует имя localhost, необходимо разрешить доступ как с адреса 127.0.0.1, так и с адреса ::1, так как резолвер возвращает поочередно все известные для имени хоста адреса.

Также для разграничения доступа может использоваться транспортное шифрование, которое мы рассмотрим далее.

Транспортное шифрование

Протокол работы с хранилищем хешей позволяет включать необязательное (opportunistic) или обязательное шифрование, основанное на криптосистеме с открытым ключом. Архитектура шифрования основана на конструкции cryptobox: https://nacl.cr.yp.to/box.html и похожа на алгоритм end-to-end шифрования DNSCurve: https://dnscurve.org/.

Чтобы настроить транспортное шифрование, в первую очередь нужно создать пару ключей для сервера хранилища с помощью команды rspamadm keypair -u:

keypair {
    pubkey = "og3snn8s37znxz53mr5yyyzktt3d5uczxecsp3kkrs495p4iaxzy";
    privkey = "o6wnij9r4wegqjnd46dyifwgf5gwuqguqxzntseectroq7b3gwty";
    id = "f5yior1ag3csbzjiuuynff9tczknoj9s9b454kuonqknthrdbwbqj63h3g9dht97fhp4a5jgof1eiifshcsnnrbj73ak8hkq6sbrhed";
    encoding = "base32";
    algorithm = "curve25519";
    type = "kex";
}

Данная команда создает уникальную пару ключей, в которой открытый ключ (public key) может быть скопирован вручную на хост клиента (например, через ssh) или же опубликован каким-либо способом, гарантирующим достоверность (например, заверен цифровой подписью или размещен на HTTPS-сайте). Закрытый ключ (private key) должен храниться в секрете.

Каждое хранилище может работать одновременно с произвольным числом ключей:

worker "fuzzy" {
  # Same options as before ...
  keypair {
    pubkey = ...
    privkey = ...
  }
  keypair {
    pubkey = ...
    privkey = ...
  }
  keypair {
    pubkey = ...
    privkey = ...
  }
}

Эта возможность полезна для создания закрытых хранилищ, где доступ разрешен только тем клиентам, которым известен один из открытых ключей:

Для включения режима обязательного шифрования используется опция encrypted_only:

worker "fuzzy" {
  # Same options as before ...
  encrypted_only = true;

  keypair {
    ...
  }
  ...
}

В этом режиме клиенты, у которых нет действительного открытого ключа, не смогут обращаться к хранилищу.

Проверка работы хранилища

Для проверки работы хранилища можно использовать команду rspamadm control fuzzystat:

Statistics for storage 73ee122ac2cfe0c4f12
invalid_requests: 6.69M
fuzzy_expired: 35.57k
fuzzy_found: (v0.6: 0), (v0.8: 0), (v0.9: 0), (v1.0+: 20.10M)
fuzzy_stored: 425.46k
fuzzy_shingles: (v0.6: 0), (v0.8: 41.78k), (v0.9: 23.60M), (v1.0+: 380.87M)
fuzzy_checked: (v0.6: 0), (v0.8: 95.29k), (v0.9: 55.47M), (v1.0+: 1.01G)

Keys statistics:
Key id: icy63itbhhni8
        Checked: 1.00G
        Matched: 18.29M
        Errors: 0
        Added: 1.81M
        Deleted: 0

        IPs stat:
        x.x.x.x
                Checked: 131.23M
                Matched: 1.85M
                Errors: 0
                Added: 0
                Deleted: 0

        x.x.x.x
                Checked: 119.86M
                ...

Сначала отображается общая статистика хранилища: количество сохраненных и устаревших хешей, а также распределение запросов по версиям протокола клиента:

  • v0.6 - запросы от rspamd 0.6 - 0.8 (устаревшие версии, ограниченно совместимы)
  • v0.8 - запросы от rspamd 0.8 - 0.9 (частично совместимы)
  • v0.9 - незашифрованные запросы от rspamd 0.9+ (полностью совместимы)
  • v1.1 - зашифрованные запросы от rspamd 1.1+ (полностью совместимы)

Далее по каждому из ключей, сконфигурированных в хранилище, отображается подробная статистика по последним IP-адресам клиентов, от которых были получены запросы. И в заключение можно увидеть общую статистику по IP-адресам.

для изменения вывода этой команды можно использовать следующие опции (например, rspamadm control fuzzystat -n):

  • -n: выводить “сырые” числа без сокращения
  • --short: не выводить подробную статистику по ключам и IP-адресам
  • --no-keys: не выводить статистику по ключам
  • --no-ips: не выводить статистику по IP-адресам
  • --sort: сортировать:
    • checked: по числу проверенных хешей (по умолчанию)
    • matched: по числу найденных хешей
    • errors: по числу ошибочных запросов
    • ip: по IP-адресу лексикографически

Шаг 3: Настройка плагина fuzzy_check

Плагин fuzzy_check используется процессами-сканерами для проверки писем и процессами-контроллерами для обучения хранилища.

Функции плагина:

  1. Обработка писем и создание хешей их отдельных частей
  2. Выполнение запросов к хранилищу
  3. Транспортное шифрование

Обучение поизводится командой rspamc fuzzy_add:

$ rspamc -f 1 -w 10 fuzzy_add <message|directory|stdin>

Где параметр -w задает вес хеша, который мы обсуждали ранее, а -f указывает номер флага.

Флаги позволяют хранить хеши различного происхождения в хранилище. Например, хеши спам-ловушек, хеши жалоб пользователей и хеши писем из “белого” списка. Каждый флаг может быть ассоциирован с собственным символом и, соответственно, иметь свой вес при проверке письма:

Флаг можно указывать как по номеру:

$ rspamc -f 1 -w 10 fuzzy_add <message|directory|stdin>

так и по символу:

$ rspamc -S FUZZY_DENIED -w 10 fuzzy_add <message|directory|stdin>

Соответсвие символов флагам нужно настроить в секции rule.

Пример local.d/fuzzy_check.conf:

rule "local" {
    # Fuzzy storage server list
    servers = "localhost:11335";
    # Default symbol for unknown flags
    symbol = "LOCAL_FUZZY_UNKNOWN";
    # Additional mime types to store/check
    mime_types = ["application/*"];
    # Hash weight threshold for all maps
    max_score = 20.0;
    # Whether we can learn this storage
    read_only = no;
    # Ignore unknown flags
    skip_unknown = yes;
    # Hash generation algorithm
    algorithm = "siphash";

    # Map flags to symbols
    fuzzy_map = {
        LOCAL_FUZZY_DENIED {
            # Local threshold
            max_score = 20.0;
            # Flag to match
            flag = 11;
        }
        LOCAL_FUZZY_PROB {
            max_score = 10.0;
            flag = 12;
        }
        LOCAL_FUZZY_WHITE {
            max_score = 2.0;
            flag = 13;
        }
    }
}

Пример local.d/metrics.conf:

group "fuzzy" {
    max_score = 12.0;
    symbol "LOCAL_FUZZY_UNKNOWN" {
        weight = 5.0;
        description = "Generic fuzzy hash match";
    }
    symbol "LOCAL_FUZZY_DENIED" {
        weight = 12.0;
        description = "Denied fuzzy hash";
    }
    symbol "LOCAL_FUZZY_PROB" {
        weight = 5.0;
        description = "Probable fuzzy hash";
    }
    symbol "LOCAL_FUZZY_WHITE" {
        weight = -2.1;
        description = "Whitelisted fuzzy hash";
    }
}

Рассмотрим некоторые полезные опции, которые можно настраивать в модуле.

Во-первых, max_score полезен для задания веса порога срабатывания данного хеша:

Вторым полезным параметром является mime_types, который определяет, какие типы вложений проверять (и обучать!) в данном хранилище. Этот параметр представляет собой список допустимых типов в формате ["type/subtype", "*/subtype", "type/*", "*"], где * заменяет любой допустимый тип. Как правило, достаточно сохранять хеши всех вложений типа application/*. Текстовые части и вложенные изображения автоматически проверяются плагином fuzzy_check (то есть, нет нужды добавлять image/* в список проверяемых вложений). Стоит иметь в виду, что вложения и изображения проверяются на точное соответствие, в отличие от текстов, которые могут немного отличаться.

Очень важной является опция read_only, если вы хотите обучать ваше хранилище. По умолчанию read_only=true, что означает невозможность обучения хранилища:

read_only = true; # disallow learning
read_only = false; # allow learning

Параметр encryption_key задает открытый ключ хранилища и включает шифрование запросов.

Параметр algorithm определяет алгоритм генерации хешей из текстовых частей писем (для вложений и изображений всегда используется blake2b). Исторически в rspamd использовался алгоритм siphash. Однако он имеет определенные проблемы с производительностью, особенно на устаревшем “железе” (CPU до Intel Haswell). Поэтому при создании собственного хранилища стоит рассмотреть другие, более быстрые алгоритмы:

  • xxhash
  • mumhash
  • fasthash

Для большинства задач я рекомендую использовать mumhash или fasthash, которые демонстрируют отличную производительность на множестве платформ. Для оценки поризводительности вы можете скомпилировать набор тестов из исходного кода rspamd:

$ make rspamd-test

и запустить тест различных вариантов алгоритмов вычисления хешей на вашей платформе:

test/rspamd-test -p /rspamd/shingles

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

Написание скриптов-условий для обучения

Так как плагин fuzzy_check отвечает в том числе и за обучение, то именно в его конфигурации задается скрипт, определяющий, какие сообщения будут поступать на обучение, а какие нет. Этот скрипт представляет собой функцию Lua с одним аргументом типа rspamd_task и возвращает либо булево значение: true - обучать или false - не обучать, либо пару - булево значение и новый флаг, если в ходе выполнения скрипта выяснилось, что необходимо изменить исходный флаг обучения. Для задания условного скрипта служит параметр learn_condition, а для записи скрипта удобнее всего использовать многострочный синтаксис heredoc, который поддерживается UCL:

# Fuzzy check plugin configuration snippet
learn_condition = <<EOD
return function(task)
  return true -- Always learn
end
EOD;

Приведем практические примеры полезных скриптов. Например, часто бывает нужно запретить использовать для обучения письма, отправленные с некоторых доменов. Для этого можно задать такой скрипт:

return function(task)
  local skip_domains = {
    'example.com',
    'google.com',
  }

  local from = task:get_from()

  if from and from[1] and from[1]['addr'] then
    for i,d in ipairs(skip_domains) do
      if string.find(from[1]['addr'], d) then
        return false
      end
    end
  end


end

В некоторых случаях полезно распределять обучение хешей по различным флагам в соответствии с их источником, который можно закодировать, например, в заголовке X-Source. Допустим, мы хотим иметь такое соответствие флагов и источников:

  • honeypot - “черный” список: 1
  • users_unfiltered - “серый” список: 2
  • users_filtered - “черный” список: 1
  • FP - “белый” список: 3

Тогда скрипт, осуществляющий такое распределение, может выглядеть следующим образом:

return function(task)
  local skip_headers = {
    ['X-Source'] = function(hdr)
      local sources = {
        honeypot = 1,
        users_unfiltered = 2,
        users_filtered = 1,
        FP = 3
      }
      local fl = sources[hdr]

      if fl then return true,fl end -- Return true + new flag
      return false
    end
  }

  for h,f in pairs(skip_headers) do
    local hdr = task:get_header(h) -- Check for interesting header
    if h then
      return f(hdr) -- Call its handler and return result
    end
  end

  return false -- Do not learn if specified header is missing
end

Шаг 4: Настройка репликации хешей

Зачастую бывает нужно собирать хеши из различных источников или иметь локальную копию удаленного хранилища. Начиная с версии 1.3 в rspamd для решения этой задачи реализована поддержка репликации на уровне хранилища хешей:

Передача хешей инициируется мастером репликации, который отправляет команды обновления хешей (добавление, модификацию или удаление) всем заданным слейвам. Таким образом, слейвы должны иметь возможность принимать соединение от мастера, что нужно учитывать при настройке межсетевых экранов.

Для установки соединения слейв должен прослушивать тот же порт 11335 (по умолчанию), но по протоколу TCP. Синхронизация мастера и слейва осуществляется по протоколу HTTP с транспортным шифрованием HTTPCrypt. Для предотвращения повторных или неверных обновлений слейв проверяет версию обновления, и, если она меньше или равна локальной, то обновление отвергается. В случае же, если мастер опережает слейв более чем на одну версию, в лог файле слейва выводится сообщение вида:

rspamd_fuzzy_mirror_process_update: remote revision: XX is newer more than 1 revision than ours: YY, cold sync is recommended

В таком случае рекомендуется заново клонировать базу данных путем “холодной” синхронизации.

Холодная синхронизация

Данная процедура служит для инициализации нового слейва или восстановления репликации после потери связи мастера со слейвом.

Для выполнения синхронизации на хосте мастера нужно остановить сервис rspamd и сделать дамп базы данных хешей (rspamd можно и не останавливать, но если за время клонирования базы данных версия мастера увеличится более чем на 1, процедуру придется повторить):

sqlite3 /var/lib/rspamd/fuzzy.db ".backup fuzzy.sql"

далее полученный файл fuzzy.sql копируется на все слейвы (rspamd на слейвах можно не останавливать):

sqlite3 /var/lib/rspamd/fuzzy.db ".restore fuzzy.sql"

После чего можно запускать rspamd сначала на слейвах, а затем на мастере.

Конфигурирование репликации

Репликация настраивается в конфигурационном файле хранилища хешей - worker-fuzzy.inc. Мастер репликации настраивается следующим образом:

# Fuzzy storage worker configuration snippet
# Local keypair (rspamadm keypair -u)
sync_keypair {
    pubkey = "xxx";
    privkey = "ppp";
    encoding = "base32";
    algorithm = "curve25519";
    type = "kex";
}
# Remote slave
slave {
        name = "slave1";
        hosts = "slave1.example.com";
        key = "yyy";
}
slave {
        name = "slave2";
        hosts = "slave2.example.com";
        key = "zzz";
}

Немного остановлюсь на настройке ключей для шифрования. Обычно для установления шифрованных соединений rspamd не требует конфигурирования ключа на обеих сторонах - ключ клиента генерируется автоматически. Однако в данном случае клиентом выступает мастер, поэтому на слейвах можно задать конкретный (открытый) ключ, по которому они будут проверять команды обновления. Также можно задать допустимые IP-адреса мастера, но защита по ключу является более надежной (кроме того, эти методы можно комбинировать).

Настройка слейва выглядит похоже:

# Fuzzy storage worker configuration snippet
# We assume it is slave1 with pubkey 'yyy'
sync_keypair {
    pubkey = "yyy";
    privkey = "PPP";
    encoding = "base32";
    algorithm = "curve25519";
    type = "kex";
}

# Allow update from these hosts only
masters = "master.example.com";
# Also limit updates to this specific public key
master_key = "xxx";

Также на слейве можно настроить трансляцию флагов, получаемых с мастера, чтобы избежать коллизий с локальными хешами. Например, если мы хотим транслировать флаги 1, 2 и 3 во флаги 10, 20 и 30 соответственно, то можно написать такую конфигурацию:

# Fuzzy storage worker configuration snippet
master_flags {
  "1" = 10;
  "2" = 20;
  "3" = 30;
};