Как стать автором
Обновить

Боремся со спамом стандартными средствами почтовика (на примере Exim)

Время на прочтение7 мин
Количество просмотров18K
Регулярно натыкаюсь на статьи про прикручивание к почтовикам антиспамовских систем (например spamassassin и подобных). Каждый раз, смотря на эти связки и кучу проблем которые они приносят, «пожимаю плечами» и искренно не понимаю зачем все это? Спам можно очень эффективно рубить непосредственно силами почтовика, без привлечения сторонних программ, некоторые из которых еще и требуют длительного обучения (насколько я знаю, но могу и ошибаться — не использую я их).

Метод отлова спама, который я опишу в этой статье, дает эффективность, примерно 97%. Он опробован на 10-ке серверов, и работает уже как минимум лет семь.

Все примеры конфигурации буду приводить для почтового сервера exim, собранного с поддержкой mysql. Но перевести их на тот-же postfix не составит особого труда.

Пользователи храняться в БД. Дабы не загромождать статью структура БД в приложенном файле (ссылка). Структура взята от ранних версий postfixadmin. Дабы было удобно админить пользователей.

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

domainlist local_domains = ${lookup mysql{SELECT `domain` FROM `domain` WHERE `domain`='${domain}' AND `active`='1'}}
domainlist relay_to_domains = ${lookup mysql{SELECT `domain` FROM `domain` WHERE `domain`='${domain}' AND `active`='1'}}


Указываем почтовику правила по которым будет проверяться почта
acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data


Заголовки и текст письма непосредственно.

Ну а теперь начнем проверять нашу почту. Начинаем с заголовков.

acl_check_rcpt:

  # принимать сообщения которые пришли с локалхоста, не по TCP/IP
  accept  hosts = :

  # Запрещаем письма содержащие в локальной части символы @; %; !; /; |. 
  deny    message       = "Incorrect symbol in address"
          domains       = +local_domains
          local_parts   = ^[.] : ^.*[@%!/|]

  # Проверяем недопустимые символы для нелокальных получателей:
  deny    message       = "Incorrect symbol in address"
          domains       = !+local_domains
          local_parts   = ^[./|] : ^.*[@%!] : ^.*/\\.\\./

  # Принимаем почту для постмастеров локальных доменов без проверки отправителя 
  accept  local_parts   = postmaster
          domains       = +local_domains

  # Запрещщаем, если невозможно проверить отправителя (отсутствует в списке локальных пользователей)
  require verify        = sender

  # Запрещщаем тех, кто не обменивается приветственными сообщениями (HELO/EHLO)
  deny    message       = "HELO/EHLO require by SMTP RFC"
          condition     = ${if eq{$sender_helo_name}{}{yes}{no}}

  # Принимаем сообщения от тех, кто аутентифицировался
  accept  authenticated = *

  # Отрубаем тех, кто подставляет свой IP в HELO
  deny    message       = "Your IP in HELO - access denied!"
          # все хосты кроме тех, что в relay_from_hosts
          hosts         =  * : !+relay_from_hosts
          condition     = ${if eq{$sender_helo_name}{$sender_host_address} \
                          {true}{false}}

  # Рубаем хосты типа *adsl*; *dialup*; *pool*;....
    deny    message       = "your hostname is bad (adsl, poll, ppp & etc)."
            condition     = ${if match{$sender_host_name} \
                                 {adsl|dialup|pool|peer|dhcp} \
                                 {yes}{no}}

  # Рубаем тех, кто в блэк-листах. 
  deny    message       = "you in blacklist - $dnslist_domain"
          hosts         = !+relay_from_hosts
          dnslists      = dul.dnsbl.sorbs.net : \
                          sbl-xbl.spamhaus.org

# Начинаем подсчет очков спама для письма. Вес каждого параметра подбирался экспериментально.
  warn  set acl_m0      = 0
        logwrite        = "ACL m0 set default as $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           HELO=$sender_helo_name (domain in e-mail = $sender_address_domain)"

  # наличие в теме слова Vigra, ранее было очень актуально, сейчас данное правило уже устарело
  # 200 очков
  warn  condition       = ${if match{$h_subject} \
                               {viagra} \
                               {yes}{no}}
        set acl_m0      = ${eval:$acl_m0+200}
        logwrite        = "STAGE0: ACL m0 set = $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           HELO=$sender_helo_name - VIAGRA!!!!"

  # Проверяем соответствие HELO и обратной записи DNS - на правильном почтовике они должны совпадать
  # 30 очков
  warn  condition       = ${if !eq{$sender_helo_name}{$sender_host_name}{yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+30}
        logwrite        = "STAGE1: ACL m0 set = $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           HELO=$sender_helo_name - reverse zone not match with HELO"

  # Проверяем наличие обратной зоны для хоста
  # 30 очков
  warn  condition       = ${if eq{$host_lookup_failed}{1}{yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+30}
        logwrite        = "STAGE2: ACL m0 set = $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           HELO=$sender_helo_name - no reverse zone for host"

  # Проверяем число точек и дефисов в доменном имени. Если больше 4-х уже такой домен вызывает подозрение
  # 40 очков
  warn  condition       = ${if match{$sender_host_name} \
                                {\N((?>\w+[\.|\-]){4,})\N}{yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+40}
        logwrite        = "STAGE3: ACL m0 set = $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           HELO=$sender_helo_name - more dots or defice in name"

  # Проверяем общую длину адреса отправителя. Больше 30 символов вызывает подозрение
  # 10 очков
  warn  condition       = ${if >{${strlen:$sender_address}}{30}{yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+10}
        logwrite        = STAGE4: ACL m0 set = $acl_m0 for \
                          host=$sender_host_name [$sender_host_address] with HELO=$sender_helo_name \
                          - many big sender address [$sender_address]

  # Очки за диалап в имени хоста. В данном правиле используется файлик со списком масок. Честно, за давностью лет, не помню где его взял
  # Его Вы можете найти в архиве с БД, ссылка на которую приведена в начале статьи 
  # 60 очков
  warn  condition       = ${lookup{$sender_host_name} \
                            wildlsearch{/usr/local/etc/exim/dialup_hosts} \
                            {yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+60}
        logwrite        = "STAGE5: ACL m0 set = $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           host=$sender_helo_name - dialup, ppp & etc..."

  # Очки за диалап в HELO
  # 60 очков
  warn  condition       = ${lookup{$sender_helo_name} \
                            wildlsearch{/usr/local/etc/exim/dialup_hosts} \
                            {yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+60}
        logwrite        = "STAGE6: ACL m0 set = $acl_m0 for \
                           host=$sender_host_name [$sender_host_address] with \
                           HELO=$sender_helo_name - dialup, ppp & etc..."

  # Проверяем есть ли зона первого уровня из HELO
  # 150 очков
  warn  condition       = ${if !eq{${lookup mysql{SELECT 1 FROM \
                            `list_top_level_domains` WHERE `zone` = \
                            LCASE(CONCAT('.', SUBSTRING_INDEX( \
                            '${quote_mysql:$sender_helo_name}', \
                            '.', -1)))}}}{1}{yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m0      = ${eval:$acl_m0+150}
        logwrite        = non-existent domain in HELO - \
                          '$sender_helo_name' setting acl_m0 = $acl_m0

  warn  set acl_m2      = 0

  # Проверяем, не было ли письма на этот адрес от наших пользователей.
  # Тут же заполняем whitelist если письмо от нашего локального отправителя
  warn  condition       = ${if eq{${lookup mysql{SELECT 1 FROM `sended_list` \
                            WHERE `user_to` = \
                            LCASE('${quote_mysql:$sender_address}') \
                            AND `user_from` \
                            = LCASE('${quote_mysql:$local_part@$domain}') \
                            AND `last_mail_timestamp` < `last_mail_timestamp` \
                            + (60*24*60*60) LIMIT 1}}}{1}{yes}{no}}
        condition       = ${lookup mysql{INSERT IGNORE INTO `domain_whitelist` \
                            (`domainname`, `domain_ip`, `added_timestamp`, \
                            `last_mail_timestamp`, `mail_count`) VALUES \
                            (LCASE('${quote_mysql:$sender_address_domain}'), \
                            '${quote_mysql:$sender_host_address}', \
                            UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '1') \
                            ON DUPLICATE KEY UPDATE \
                            `last_mail_timestamp` = UNIX_TIMESTAMP(), \
                            `mail_count` = `mail_count` + 1}}
        hosts           = !+relay_from_hosts : *
        set acl_m2      = 1
        logwrite        = STAGE7: $sender_address ==> $local_part@$domain; \
                          setting acl_m2 = $acl_m2; WHITELIST for this addresses

  # Проверка наличия домена в whitelist
  warn  condition       = ${if eq{${lookup mysql{SELECT 1 \
                            FROM `domain_whitelist` \
                            WHERE `domain_ip` = \
                            '${quote_mysql:$sender_host_address}' \
                            LIMIT 1}}}{1}{yes}{no}}
        hosts           = !+relay_from_hosts : *
        set acl_m2      = 1
        logwrite        = STAGE8: $sender_address ==> $local_part@$domain; \
                          setting acl_m2 = $acl_m2; WHITELIST for ALL domains

  # Сбрасываем переменную с очками спама если домен в whitelist или наши пользователи общались с данным респондентом
  warn  condition       = ${if eq{$acl_m2}{1}{yes}{no}}
        logwrite        = Resetting acl_m0 $acl_m0 --> 0, host in whitelist \
                          ($sender_address ==> $local_part@$domain)
        set acl_m0      = 0  


  # Проверка получателя в локальных доменах. Если не проходит, то проверяется следующий ACL
  accept  domains       = +local_domains
          endpass
          message       = "In my mailserver not stored this user"
          verify        = recipient

  # Проверяем получателя в релейных доменах
  accept  domains       = +relay_to_domains
          endpass
          message       = "main server not know relay to this address"
          verify        = recipient

  # Разрешаем почту от доменов в списке relay_from_hosts
  accept  hosts         = +relay_from_hosts


  # Если неподошло ни одно правило - явно ищут открытый релей
  deny    message       = "This is not open relay"


Теперь перейдем к проверке тела письма.

acl_check_data:

    deny    message = contains $found_extension file (blacklisted).
            demime  = com:vbs:bat:pif:scr:exe

    deny    message = This message contains a MIME error ($demime_reason)
            demime = *
            condition = ${if >{$demime_errorlevel}{2}{1}{0}}

    deny    message = This message contains NUL characters
            log_message = NUL characters!
            condition   = ${if >{$body_zerocount}{0}{1}{0}}

    deny    message     = Incorrect headers syntax
            hosts       = !+relay_from_hosts:*
            !verify     = header_syntax


    # Здесь можем отклонить почту по подсчитанным ранее очкам спама. При сумме очков более 99, считаем, что это спам.
    # Но, как показала практика, после раскоментирования следующих строчек, юзеры начинают очень нервничать и 
    # переживать - а работает ли вообще у нас почта, и где любимый спам по утрам :) А потому далее мы используем эти
    # очки немного по другому
    #deny    message    = Possible SPAM message
    #        log_message = Possible SPAM message
    #        condition   = ${if >{$acl_m0}{99}{yes}{no}}


  # Пропускаем остальное
  accept


В exim есть механизм system filter. Вот туда и добавляем

if $acl_m0 matches ^\\d+
then
    logwrite "FILTER: debug - digit in variable acl_m0 = $acl_m0 (after first if)"

    if $acl_m0 is above 99
    then
        headers add "X-Spam-Description: if spam count >= 100 - this is spam"
        headers add "X-Spam-Count: $acl_m0"
        headers add "Old-Subject: $h_subject:"
        headers remove "Subject"
        headers add "Subject: (*** SPAM ***) $h_old-subject:"
        headers add "X-Spam: YES"
        logwrite "EXIM FILTER: Spam count = $acl_m0 ; Added SPAM header"
    endif
endif


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

Повторюсь, по моим наблюдениям, такие достаточно простые правила не пропускают где-то 97% спама.
Теги:
Хабы:
Всего голосов 22: ↑19 и ↓3+16
Комментарии19

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань