Большой Аргумент

Большой Аргумент

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

Искусственный интеллект

...Мы научили песок думать, нарисовали на нём руны светом и оживили молнией...

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

Он предложил сделать дегтярную ловушку.

 

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

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

Технически это работает так. Когда запрос попадает в один из «медовых» триггеров — специально подсунутое ботам скрытое поле формы, на которое клюют только автоматические заполнители, или откровенно похожий на SQL-инъекцию набор символов в параметрах запроса — сервер не просто отдаёт ошибку и прощается с гостем. Вместо этого он честно рапортует HTTP 200 OK, то есть «всё получилось, запрос принят», отключает буферизацию и сжатие ответа и открывает соединение как text/plain. А дальше — бесконечный цикл: раз в секунду в сокет улетает очередное «ХУЙ» и обязательно с пробелом на конце (чтобы слова не слипались в один сплошной кирпич текста) А то плохо поймут, сервер сбрасывает буфер и засыпает ровно на секунду до следующей порции. И так до тех пор, пока бот или скрипт хакера сам не оборвёт соединение — вручную сервер его никогда не отпустит. С точки зрения атакующего это выглядит как успешный ответ сервера, который почему-то никак не заканчивается: соединение живо, данные капают, а полезной нагрузки — то есть базы, дампа или что там боту было нужно — как не было, так и нет. Наивные сканеры, которые пытаются целиком прочитать тело ответа в память перед тем как его разбирать, на этом просто зависают или падают по таймауту. При этом серверу такой разговор обходится почти бесплатно: PHP-процесс всё это время не работает, а спит, так что ресурсов на одного зависшего бота уходит исчезающе мало — а вот бот теряет поток, воркер или сколько там у него было выделено под эту атаку, и куда больше времени, чем рассчитывал.

Что я сделал

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

Но идиллия продержалась недолго: спустя время выяснилось, что часть этой защиты работала… не совсем так, как задумывалось. О том, как в итоге всё было устроено на самом деле — дальше.

Постановка задачи

Ещё до этого я уже пытался решить вопрос защиты — но только для одного сайта, пилотного. Там уже лежали кое-какие заготовки: свой .htaccess с ручными банами, свой скрипт-ловушка. Работало, но полукустарно и без единой логики — что называется, «на живую нитку». Дальше одного сайта эта затея тогда не пошла: масштабировать вручную то же самое ещё на семь доменов не хотелось совершенно.

На хостинг-аккаунте (/home/Acc12345/), крутятся 8 сайтов — в основном на Joomla, пара на чистом HTML. У каждого сайта в своей папке лежит одинаковый набор «защитных» файлов:

public_html/

  ├── site1.com/

  │     ├── .htaccess          <-- блокировка на уровне Apache

  │     ├── local_bans.conf    <-- список забаненных IP

  │     ├── sentinel.php       <-- ловушка для ботов

  │     └── incident.log       <-- лог пойманных атак

  ├── site2.com/  ... (то же самое)

  ├── site3.com/

  ├── site4.com/

  ├── site5.com/

  ├── site6.com/

  ├── site7.com/

  ├── site8.com/

  ├── guardian.sh              <-- крон: скан + защита

  └── weekly_audit.php         <-- крон: еженедельный отчёт

Идея: sentinel.php вешается на ErrorDocument 403, ловит ботов (WordPress-сканеры, попытки достать .git/.env, Joomla-эксплойты, honeypot-триггеры), пишет их IP в local_bans.conf и инцидент — в incident.log. guardian.sh по крону должен переносить эти IP в .htaccess как жёсткий бан на уровне Apache (Require not ip). Раз в неделю weekly_audit.php присылает отчёт на почту.

Проблема: в local_bans.conf IP исправно накапливались, а в .htaccess — нет. Бот формально «забанен», но реально продолжает долбить сайт.

Шаг 1. Кто вообще должен писать в .htaccess

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

●      sentinel.php — ядро: ловит атаку, пишет IP в local_bans.conf, инцидент — в incident.log.

●      guardian.sh — единственный, кто должен переносить IP из лога в .htaccess (шаг «6.5» в скрипте).

●      weekly_audit.php — только читает local_bans.conf и шлёт письмо. К .htaccess не прикасается вообще.

Шаг 2. Баг №1 — моя опечатка, которая ломала всё

В guardian.sh строка, которая должна была вытаскивать новые IP из лога:

NEW_IPS=$(grep "\[TRAFFIC BAN\]" "$CUSTOM_LOG" \

    | awk -F'IP: ' '{print $2}' | ...)

Скрипт искал буквально подстроку [TRAFFIC BAN] — открывающая скобка сразу перед словом «TRAFFIC». А sentinel.php никогда такого не пишет: у него всегда более конкретные типы — [WORDPRESS SNOOPER BAN], [JOOMLA TRAFFIC BAN], [HELIX HONEYPOT BAN]. Между скобкой и словом «TRAFFIC»/«BAN» везде стоит ещё слово. grep никогда ничего не находил, переменная всегда оставалась пустой.

Фикс — вытаскивать IP по метке IP:, которая есть в любой строке лога независимо от типа:

NEW_IPS=$(grep -oP 'IP:\s*\K[0-9a-fA-F:.]+' "$CUSTOM_LOG" | sort -u)

То есть месяцами дегтярная ловушка исправно варила ботов заживо, лог пух от записей, а .htaccess жил своей отдельной спокойной жизнью, ничего об этом не подозревая. Классика: код не падает, ошибок нет, значит «работает» — а на деле просто молча ничего не делает.

Шаг 3. Баг №2 — HTML-сайты выпадали из процесса

Весь основной цикл guardian.sh запускался только для сайтов, где есть configuration.php:

if [ -d "$SITE_PATH" ] && [ -f "${SITE_PATH}/configuration.php" ]; then

Для HTML-сайтов (без Joomla) синхронизация .htaccess не выполнялась вообще, даже после фикса grep. Решение — вынести синхронизацию .htaccess в отдельный цикл, который проходит по всем 8 сайтам без проверки на configuration.php, и запускать его до основного (тяжёлого, Joomla-специфичного) сканирования:

echo -e "${YELLOW}=== СИНХРОНИЗАЦИЯ .HTACCESS (ВСЕ САЙТЫ) ===${NC}"

for SITE_PATH in "${SITES[@]}"; do

    if [ -d "$SITE_PATH" ]; then

        SITE_DOMAIN=$(basename "$SITE_PATH")

        sync_htaccess_blacklist "$SITE_PATH" "$SITE_DOMAIN"

    fi

done 

# ОСНОВНОЙ ЦИКЛ (глубокое сканирование — только Joomla)

for SITE_PATH in "${SITES[@]}"; do

    if [ -d "$SITE_PATH" ] && [ -f "${SITE_PATH}/configuration.php" ]; then

Шаг 4. А кросс-сайтовая защита вообще есть?

Проверка того, что реально попадает в .htaccess разных сайтов, показала: списки IP на Сайте 1 и Сайте 4 вообще не пересекаются. То есть бот, отхвативший бан на одном сайте, преспокойно шёл пробовать счастья на соседнем — как будто между восемью домиками одного и того же хозяина нет забора. Разбор sentinel.php показал причину — есть массив $all_blacklists из всех 9 путей, но используется он только для чтения (проверка «не забанен ли уже»), а запись бана шла только в файл текущего сайта:

До — банит только локально:

$ban_bot_action = function($type) use ($blacklist_file, $ip, $uri) {

    // ... читает и пишет только $blacklist_file (локальный)

    if (!in_array($ip, $lines, true)) {

        $lines[] = $ip;

        @file_put_contents(

            $blacklist_file,

            implode(PHP_EOL, $lines) . PHP_EOL,

            LOCK_EX

        );

    }

};

После — банит сразу на всех 9 сайтах:

$ban_bot_action = function($type) use ($ip, $uri, $all_blacklists) {

    foreach ($all_blacklists as $target_file) {

        $lines = @file($target_file,

            FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: array();

        $lines = array_map('trim', $lines);

        $lines = array_filter($lines, function($line) {

            return filter_var($line, FILTER_VALIDATE_IP) !== false;

        });

        if (!in_array($ip, $lines, true)) {

            $lines[] = $ip;

            @file_put_contents(

                $target_file,

                implode(PHP_EOL, $lines) . PHP_EOL,

                LOCK_EX

            );

        }

    }

    // Инцидент логируем только в лог ТОГО сайта, где произошла атака

    $log_file = __DIR__ . '/incident.log';

    @file_put_contents($log_file, $log_entry, FILE_APPEND);

};

Соответственно поменялась логика guardian.sh: раз local_bans.conf теперь общий, синхронизацию .htaccess стали делать не по локальному логу атак, а прямо по содержимому local_bans.conf.

Шаг 5. Синхронизация должна не только добавлять, но и убирать

Пока список был «только растущим», примитивного добавления новых строк хватало. Но с появлением еженедельной чистки (следующий шаг) стало ясно: если IP выпал из local_bans.conf, а строка Require not ip осталась — толку от чистки ноль. Функцию синхронизации переписал на полную реконсиляцию — каждый прогон полностью пересобирает содержимое между метками:

awk -v block="$IP_BLOCK" '

    /# Blacklist Start/ { print; in_block=1; next }

    /# Blacklist End/ { printf "%s", block; print; in_block=0; next }

    {

        if (in_block == 1) {

            if ($0 ~ /Require all granted/) { print; next }

            else { next }   # старые "Require not ip ..." — пересоздаём заново

        } else {

            print

        }

    }

' "$HTACCESS_FILE" > "$TMP_FILE"

Тестирование на копии реальных данных вскрыло два дополнительных бага уже в собственном коде:

●      $(...) в bash обрезает завершающий перевод строки — последний IP слипался с меткой # Blacklist End в одну строку. Пришлось явно возвращать \n после подстановки: IP_BLOCK="${IP_BLOCK}"$'\n'

●      В «бутстрап»-блоке (создание меток с нуля) <RequireAll> оказался внутри меток Start/End — и полная пересборка, чистящая всё «неопознанное» внутри блока, съедала сам тег. Поправил порядок так, чтобы <RequireAll> оборачивал метки снаружи, как в реальных файлах.

После фиксов — нет дубля (повторный запуск не плодит дублей) и корректная работа проверены на синтетических кейсах, включая «файл без меток вообще» и «файл с уже существующими метками».

Шаг 6. Список будет расти вечно — нужна чистка

Изначально в комментариях weekly_audit.php была явная идея — «вечный бан», очистка сознательно отключена. Обсудили компромисс: сколько IP держать после чистки (сошлись на 100 последних/самых свежих) и что делать со старыми, которые «вымоются» при переполнении. Решение — отдельный «вечный» список для вручную забаненных IP, который автоматическая чистка не трогает.

6.1. permanent_bans.conf

Новый файл в корне public_html/, правится только вручную. sentinel.php при проверке «уже забанен ли IP» заглядывает и в этот файл тоже (для мгновенной блокировки), но функция бана в него никогда не пишет:

$permanent_blacklist_file = $base_path . 'permanent_bans.conf';

$all_blacklists_for_check = array_merge(

    $all_blacklists, [$permanent_blacklist_file]

);

 

foreach ($all_blacklists_for_check as $file) {

    // ... проверка на 503, ban_bot_action() сюда НЕ пишет

}

6.2. Еженедельная чистка в weekly_audit.php

$MAX_BLACKLIST_SIZE = 100;

if ($total_before > $MAX_BLACKLIST_SIZE) {

    // Порядок в файле = порядок добавления (append-only)

    $dropped_ips = array_slice(

        $ref_lines, 0, $total_before - $MAX_BLACKLIST_SIZE

    );

    $kept_ips = array_slice($ref_lines, -$MAX_BLACKLIST_SIZE);

 

    foreach ($sites as $site_path) {

        $site_blacklist = rtrim($site_path, '/')

            . '/local_bans.conf';

        @file_put_contents(

            $site_blacklist,

            implode(PHP_EOL, $kept_ips) . PHP_EOL,

            LOCK_EX

        );

    }

}

guardian.sh при синхронизации объединяет оба источника — скользящий список и вечный, — так что вечные баны всегда остаются в .htaccess, даже если давно выпали из «топ-100»:

ALL_IPS=$(cat "$BLACKLIST_FILE" "$PERMANENT_BLACKLIST" 2>/dev/null \

    | grep -v '^#' | grep -v '^[[:space:]]*$' | tr -d ' \t' | sort -u)

6.3. Убрал дублирующую логику

Заодно нашли и убрали лишний кусок в sentinel.php — старый жёсткий кап на 500 записей, применявшийся к каждому из 9 файлов независимо в реальном времени:

// Было:

if (count($lines) > 500) { $lines = array_slice($lines, -500); }

 

// Стало: строка убрана. Единственный источник правды по размеру

// списка — еженедельная чистка в weekly_audit.php.

Шаг 7. Одноразовое выравнивание накопленного "добра"

Все фиксы решают проблему на будущее, но на момент внедрения на 8 сайтах уже лежали разные, несинхронизированные списки. Написал одноразовый merge_bans.sh:

●      Собирает реальное время бана каждого IP из incident.log всех 8 сайтов — по настоящему таймстампу из лога, а не по порядку строк в файле.

●      Если у IP из local_bans.conf нет записи в логе — берёт его тоже, но с самым низким приоритетом свежести.

●      Сортирует по свежести, убирает дубли, оставляет топ-100.

●      Пишет единый список во все 8 local_bans.conf и сразу пересобирает .htaccess — не дожидаясь крона.

Ключевой фрагмент — сборка таймстампа из формата лога:

awk -F'[][]' '

    {

        ts = $2   # "24-Aug-2026 15:28:33 UTC"

        if (match($0, /IP: [0-9a-fA-F:.]+/)) {

            ip = substr($0, RSTART + 4, RLENGTH - 4)

            print ts "\t" ip

        }

    }

' "$LOGF" | while IFS=$'\t' read -r ts ip; do

    epoch=$(date -d "$ts" +%s 2>/dev/null) || continue

    echo -e "${epoch}\t${ip}" >> "$TMP_TS_IP"

done

Результат реального прогона: 80 записей в логах → 39 уникальных IP в старых разрозненных списках → 56 уникальных суммарно (лимит 100 не превышен, обрезка не понадобилась) → все 8 сайтов синхронизированы за один запуск.

Шаг 8. Разбор лога — проверка на практике

После внедрения пользователь прислал реальный error_log веб-сервера за пару дней. Разбор черезbash/awk:

grep -oP '(?<=\d{4}\] \[)\w+:error' "$FILE" | sort | uniq -c | sort -rn

grep -oP 'public_html/\K[^/]+' "$FILE" | sort | uniq -c | sort -rn

grep -oP '\[client \K[^:\]]+' "$FILE" | sort | uniq -c | sort -rn | head -20

показал рабочую картину:

●      293 из 296 записей — блокировка на уровне Apache (authz_core), ещё до PHP: .git, десятки вариантов .env*, попытки закинуть PHP-шеллы в images/. Всё блокируется штатными правилами — вежливо, без дегтя, просто «Require not ip», разговор окончен.

●      Единственная ранее ручная запись в .htaccess (91.92.241.196, 48 отказов в логе) после перехода на автоматическую систему просто перестала атаковать.

●      Потенциально серьёзная находка: IP, совпадающий с whitelist-префиксом владельца сайта (моим) в sentinel.php (86.41.), 13 раз подряд получал отказ при попытке открыть картинку в статье — судя по referer, это сам я редактировал контент в админке Joomla.

Причина: PHP-уровневый whitelist в sentinel.php защищает только от новых банов через сам скрипт, но не может отменить уже существующую запись Require not ip в .htaccess — Apache блокирует запрос до того, как PHP вообще запускается:

// Белый список: если это ваш IP, скрипт сразу отключается

if (!empty($ip) && strpos($ip, '86.41.') === 0) {

    return;   // <-- защищает ТОЛЬКО от новых банов через sentinel.php,

              //     не отменяет уже существующий Require not ip в .htaccess

}

Проверка (grep по всем local_bans.conf и .htaccess) показала, что на момент разбора записи уже не было — она сама исчезла как побочный эффект перехода на полную рекомпиляцию .htaccess (Шаг 5): раз IP не было ни в одном актуальном источнике, при очередной пересборке он перестал попадать в список.

Отдельно всплыла ошибка «Include not allowed here» на одном из сайтов — оказалась не связана с системой банов: я сам днём раньше пробовал подключить local_bans.conf напрямую через Include в .htaccess (а директива не работает в этом контексте, только в основном конфиге сервера), поймал ошибку, закомментировал строку — и позже удалил её вовсе.

Итог

Начиналось всё с одной опечатки в grep, а закончилось полноценной архитектурой: общий блэклист на все сайты аккаунта, двухуровневая система хранения (скользящий список на 100 записей + отдельный вечный для сознательных банов), еженедельная автоматическая чистка, полная реконсиляция .htaccess, и разовый инструмент для приведения в порядок уже накопленных за прошлые месяцы данных.

Главный технический урок: код, который выглядит рабочим (переменные объявлены, файлы пишутся, письма уходят), может годами молча не выполнять свою главную функцию — потому что единственная опечатка в regex-паттерне никак не проявляется в виде ошибки, файлы просто остаются такими, какие есть. Без сверки «что должно происходить» с «что происходит на самом деле» это можно не заметить очень долго.

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

 

Обо мне

bottom-logo

Я дизайнер. Здесь вы можете увидеть мои работы. Хорошо я делаю свою работу или не очень — судить вам.

Главное правило — вы никогда не увидите незаконченной работы. Только досконально отточенный проект можно выпускать в жизнь.

Калькулятор упаковки

Выберите язык

Выберите язык

Настройки cookies
Мы используем cookies для удобной работы сайта и анонимной статистики посещений. Если вы отклоните cookies, часть функций сайта может работать иначе.
Принять все
Отклонить все
Read more
Analytics
Tools used to analyze the data to measure the effectiveness of a website and to understand how it works.
Google Analytics
Accept
Decline
Advertisement
If you accept, the ads on the page will be adapted to your preferences.
Google Ad
Accept
Decline
Save