Когда я устал от атак хакеров, завёл дегтярную ловушку и в итоге построил общую защиту на 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-паттерне никак не проявляется в виде ошибки, файлы просто остаются такими, какие есть. Без сверки «что должно происходить» с «что происходит на самом деле» это можно не заметить очень долго.
А главный человеческий урок — что усталость от хакеров лечится не только фаерволом, но и хорошей дегтярной ловушкой с чувством юмора. Атаки как шли, так и идут — интернет большой, ботов много, — но теперь они хотя бы уходят от нас с чётким пониманием, что к тем кто пользуется кириллицей им не стоило идти изначально.




