В прошлой статье я рассказывал про SSH-ловушку на своём сервере. В комментариях несколько раз спросили: а что за сайт, на котором всё это показывается, и почему он так устроен. Отвечаю. Это не обзор разделов, а история решений: что выбрал, во что это обошлось и где ошибся. А самый частый вопрос — как сайт понимает, что пришёл один и тот же бот, если не хранит адресов, — получил отдельный раздел.
Немного цифр для масштаба. Первый коммит — 25 октября 2026, сейчас в репозитории 243 коммита и около 22 тысяч строк PHP, JS и CSS, плюс 5 тысяч строк серверных скриптов на Bash и Python. Зависимостей ноль: ни Composer, ни npm, ни CDN. Сайт делает ровно ноль запросов к чужим доменам — шрифты лежат рядом.
Почему без фреймворка и без базы данных
Решение было скучным и практичным. Сайт — личный, пишет в него один человек, читают все. Записей — сотни, не миллионы. Под такую нагрузку база данных даёт в основном новые способы сломаться: отдельный процесс, бэкапы дампом, миграции, учётка, которую надо где-то хранить.
Поэтому весь контент — один JSON-файл. Сохранение выглядит так:
function data_save(array $data): void{ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (is_file(DATA_FILE) && file_get_contents(DATA_FILE) === $json) { return; // ничего не изменилось — копию не плодим } backup_create(); // прежняя версия уходит в data/backups/, хранятся 20 последних $tmp = DATA_FILE . '.' . new_id() . '.tmp'; file_put_contents($tmp, $json, LOCK_EX); rename($tmp, DATA_FILE); // атомарная замена: читатель видит либо старый файл, либо новый}
Запись во временный файл и rename() — главное, что нужно от «базы» на таком сайте: файл никогда не бывает наполовину записанным. Перед каждым сохранением прежняя версия кладётся рядом, так что откат — это копирование файла. Перенос сайта на другую машину — копирование папки.
Цена решения:
-
Нет запросов. Поиск, фильтры, сортировки — всё руками, в памяти. На сотнях записей это незаметно, на сотнях тысяч было бы больно.
-
Конкурентная запись. Там, где пишут посетители (например, сводка нарушений CSP, о ней ниже), нужен
flock()и ограничение на размер, иначе любой желающий раздует файл мусором. -
Нет фреймворка — нет и его защиты. Экранирование, CSRF, сессии, ограничение попыток входа пришлось писать самому. Это же стало причиной написать тесты, которые атакуют сайт (о них дальше).
Без фреймворка я остался по той же причине: страниц около сорока, у каждой свой шаблон, а маршрутизация — это таблица разделов и перенаправлений старых адресов. Фреймворк закрыл бы задачи, которых у меня нет, и добавил бы обновления, которые надо отслеживать.
Строгая CSP: что пришлось переделать
Политика у сайта такая:
$csp = "default-src 'self'; " . "script-src 'self' 'nonce-$nonce'; " . "style-src 'self'; font-src 'self'; " . "img-src 'self' data:; connect-src 'self'; object-src 'none'; " . "base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
Скрипты — только свои файлы и встроенные с одноразовым nonce (16 случайных байт на каждый ответ). Стили — только свои файлы, без 'unsafe-inline'. Звучит как одна строчка в конфиге, а на деле это перекроило вёрстку.
style="" запрещён. Это больнее всего. Когда у тебя графики, полоски доступности, тепловые карты, первая мысль — style="width: 37%". Под такой CSP браузер это молча проигнорирует. Что я сделал вместо этого:
-
графики и полосы рисуются в SVG, где ширина и высота — обычные атрибуты
, а не стиль; -
то, что должно меняться на лету, JS выставляет через CSSOM (
el.style.setProperty('--glitch-delay', '0s')) — это CSP не запрещает, запрет касается только атрибута в разметке; -
всё остальное — классы.
Никаких onclick. Только addEventListener в своих файлах.
Данные для скриптов — не переменная в , а , который не исполняется и читается через JSON.parse. Тут есть своя ловушка: если в данных встретится , тег закроется раньше времени. На это есть отдельный тест.
Ранний скрипт. Выбранная тема и ночной режим должны включиться до отрисовки, иначе страница на долю секунды мигнёт не той темой. Это один из немногих встроенных скриптов, и он получает nonce.
И чтобы всё это не развалилось через месяц, когда я забуду и напишу style= в новом шаблоне, — тест:
test('CSP: nonce каждый раз новый, встроенных скриптов без nonce и style="" нет', function () use ($hdr) { [, $h1, $html] = http('GET', '/'); [, $h2] = http('GET', '/'); preg_match("/'nonce-([^']+)'/", $hdr($h1, 'content-security-policy'), $m1); preg_match("/'nonce-([^']+)'/", $hdr($h2, 'content-security-policy'), $m2); check(($m1[1] ?? '') !== '' && $m1[1] !== ($m2[1] ?? ''), 'nonce повторяется'); preg_match_all('/]*>/i', $html, $tags); foreach ($tags[0] as $tag) { check(str_contains($tag, 'src=') || str_contains($tag, 'nonce="' . $m1[1] . '"') || str_contains($tag, 'application/json') || str_contains($tag, 'application/ld+json'), "скрипт без nonce: $tag"); } check(!preg_match('/sstyles*=/i', $html), 'есть атрибут style=""');});
Недавно добавил ещё и отчёты о нарушениях (report-uri для старых браузеров, report-to для новых). Оказалось, что это полезно в первую очередь для своих ошибок: забытый nonce в новом шаблоне всплывает в сводке раньше, чем я его замечу глазами. Хранится только сводка — директива, источник без пути, страница, счётчик. Ни адресов, ни User-Agent. Отчёты от расширений браузера (chrome-extension://…) отбрасываются: это шум, а не проблема сайта. И есть потолок в 200 разных записей — иначе любой может завалить файл придуманными нарушениями.
Тесты, которые атакуют свой же сайт
Тестов два набора. Первый — php tests/run.php, 160 проверок, проходит примерно за 20 секунд и не требует ничего, кроме PHP. Фреймворка для тестов тоже нет: test(), check() и HTTP-клиент на fsockopen() — это около 50 строк. Сайт поднимается встроенным сервером PHP на временной копии данных, настоящие файлы не трогаются.
Главная идея — тестовые данные сами по себе атака. В каждом поле профиля, которое выводится на сайт, лежит что-нибудь неприятное:
const XSS = 'alert(1)
';$data['profile'] = array_merge($data['profile'], [ 'name' => 'Test ' . XSS, 'bio' => "Bio e]0;pwnedx07e[2J" . XSS, // escape-последовательности терминала — для curl-версии 'specialty' => '">', // …]);
А дальше проверки, что ничего из этого не вылезло. Несколько названий тестов, чтобы было понятно, о чём речь:
-
«Отражённый XSS: адрес и параметры не попадают в страницу как HTML»;
-
«Подменённый Host не попадает в ссылки страницы»;
-
«SSRF: /audit.json не ходит по адресу из запроса»;
-
«Перебор пароля: после 5 ошибок блокировка, даже для верного пароля»;
-
«CSRF: без токена или с чужим токеном форма не проходит»;
-
«JPEG: EXIF с GPS удаляется, картинка остаётся JPEG»;
-
«Закрытые файлы и каталоги не отдаются» —
/.git/config,/data/site.json,/../и прочее; -
«Markdown: кавычка в адресе не выходит из атрибута».
Последний важен потому, что Markdown-парсер у меня тоже свой, и кавычка в адресе ссылки — классический способ выйти из атрибута href и дописать обработчик события.
Отдельный слой — тесты «на настоящих данных». Когда я впервые прогнал экспорт на живых журналах ловушки, всплыло сразу несколько вещей, до которых синтетика не додумалась: команды, в которых была метка очистки, пропадали целиком, сумма атак и сессий не сходилась, в выдачу попадали мусорные файлы, а в статистике появлялось -0.0. Каждый такой случай теперь отдельная проверка.
Тесты встроены в выкладку: скрипт деплоя запускает их сам и ничего не выкладывает, если что-то упало. Обойти можно, но только явно, переменной окружения — чтобы обход был осознанным решением, а не привычкой.
Бюджет скорости и доступность в браузерных тестах
PHP-тесты не видят вёрстку и не исполняют JavaScript. Поэтому второй набор — tests/browser.py: настоящий Chromium через DevTools Protocol, без Selenium и Playwright, только python3-websocket. Он обходит 35 страниц в двух размерах — телефон 375×812 и компьютер 1280×800 — и на каждой проверяет:
-
нет ошибок в консоли, в том числе нарушений CSP;
-
нет горизонтальной прокрутки на телефоне — самая частая моя вёрсточная ошибка;
-
контраст и порядок заголовков в обеих темах;
-
в английской версии не осталось русского текста;
-
проход клавишей Tab: элемент в фокусе виден;
-
режим «уменьшить движение»: ничего не крутится;
-
бюджет скорости.
Бюджет — это словарь с порогами:
# Сейчас страницы ≈280 КБ и 14–19 запросов: запас оставлен под рост,# а превышение значит, что что-то тяжёлое попало на страницу случайно.BUDGET = {'kb': 400, 'html': 80, 'js': 150, 'req': 25, 'load': 1500}
Вес страницы со всем, что она подгрузила, HTML и скрипты отдельно, число запросов и время до load. Числа берутся из performance.getEntriesByType() прямо в странице. Если страница потяжелела — тест падает.
Зачем это личному сайту? Потому что без порога вес растёт незаметно: каждая новая фича добавляет «всего 20 килобайт». С порогом добавление тяжёлого скрипта становится решением, которое приходится принимать явно.
Прогон занимает около трёх минут, поэтому перед выкладкой по умолчанию идёт быстрый режим — только ошибки на всех страницах и работа основных функций.
Тесты доступности нашли реальные вещи: на двух интерактивных страницах — тренажёре и форме проверки домена — элемент в фокусе после Tab не был виден, а раньше всплыли недостаточный контраст кнопок и пропуски в порядке заголовков.
Очистка текста атакующих перед публикацией
Это часть, ради которой сайт вообще стал интересен: он показывает, что делают боты в SSH-ловушке. Но показывать сырые команды атакующих — плохая идея. В них ссылки на вредоносы, адреса командных серверов, иногда мат, иногда чужие данные. Публиковать это — значит раздавать ссылки на малварь со своего домена.
Поэтому между журналом ловушки и сайтом стоит экспорт, и он исходит из того, что любой текст атакующего враждебен. Команда для показа проходит через белый список, а не через чёрный:
def show_cmd(raw): c = re.sub(r'[^x20-x7e]', '', raw) # только печатный ASCII c = re.sub(r'b[a-z][a-z0-9+.-]*://S+', '', c, flags=re.I) # любые ссылки c = re.sub(r'bd{1,3}(.d{1,3}){3}(:d+)?b', '', c) # адреса c = re.sub(r'"[^"]*"|'[^']*'', '', c) # строки в кавычках c = re.sub(r'[A-Za-z0-9+/=]{32,}', '', c) # base64 и прочие длинные куски ...
Дальше команда режется по ;, |, &&, и у каждой части проверяется программа. Незнакомая программа целиком становится . У знакомой остаются флаги, пути и права (chmod +x), а случайные имена файлов вида /tmp/x7f3k превращаются в . Всё, что похоже на свободный текст, — в .
Получается, например, cd /tmp; wget ; chmod +x ./; ./ — видно, что бот делал, и ничего, по чему можно куда-то перейти.
Второе правило: значение публикуется, только если пришло хотя бы с трёх разных адресов. Это отсекает уникальные строки — пароли, которые кто-то набрал руками, случайные данные одного человека. Сами адреса на сайт не попадают вообще — как при этом считать ботов, расскажу в следующем разделе.
Цена: часть интересного теряется. Бывает, что самое любопытное в сессии — как раз незнакомая программа, а на сайте она . Я считаю, что это правильный размен.
Как отличить одного бота от многих, если адреса не публикуются
Это самый частый вопрос к первой статье: «вы пишете, что один бот пришёл с 18 адресов, — как вы это поняли, если IP не сохраняете?» Ответ в том, где именно адреса есть, а где их нет.
Адреса есть только в журнале ловушки. Ловушка без них бесполезна, поэтому пишет всё. Раз в 5 минут экспорт читает журнал за последние 7 дней и считает всё в памяти. В данные сайта уходят только итоговые числа.
«Бот» — это сценарий, а не адрес. У каждой сессии берутся первые 12 команд после той же очистки, что и для публикации, числа заменяются на N, и от этой последовательности считается короткий хеш — подпись поведения:
# разведка одной командой uname — один вид, с какими бы ключами и по какому пути её ни вызвалиif len(shown) == 1 and re.fullmatch(r'(S*/)?uname( -[a-zA-Z]+)*', shown[0][1]): sig = hashlib.sha1(b'uname').hexdigest()[:4]else: sig = hashlib.sha1('n'.join(re.sub(r'd+', 'N', c) for _, c, _ in shown[:12]).encode()).hexdigest()[:4]
Сессии с одинаковой подписью — одно семейство. Особый случай с uname появился после первых же настоящих данных: один бот вызывал её то как uname -a, то как /bin/./uname -s -v -n -r -m, и без этого правила распадался на десяток «видов».
«18 адресов» — это размер множества. Для каждого семейства в памяти собирается множество адресов, с которых пришли его сессии, а наружу уходит только его размер. Семейство, замеченное меньше чем с трёх адресов, не публикуется вовсе. С правилом для uname этот вид за две недели набрал 47 адресов и больше 30 тысяч сессий.
Честное ограничение. «Один бот» значит «один сценарий», а не одна машина и не один владелец. Одинаковый скрипт могут запускать разные люди, а один оператор может гонять разные скрипты. Поэтому дальше в ход идут признаки, которые помогают отличать одно от другого.
-
Отпечаток SSH-клиента (HASSH) — хеш от набора алгоритмов шифрования, который клиент предлагает при подключении. Строку версии бот пишет какую хочет, а набор алгоритмов выдаёт настоящую библиотеку. Так видно, например, что у клиента, который называет себя
SSH-2.0-internetdata.io, тот же отпечаток, что у части клиентовSSH-2.0-Go: это одна и та же библиотека под разными вывесками. -
Паспорт семейства — часы активности и любимые пароли. Если у двух видов один клиент и общие пароли, на сайте это помечено как «возможно, один оператор». Именно «возможно»: это повод присмотреться, а не вывод.
-
Родословная. Новое семейство связывается со старым, если их команды совпадают хотя бы на 60% (
difflib.SequenceMatcherпо спискам команд). Так нашлись два вида с общим скриптом закрепления и сходством 75%: один меняет пароль черезpasswd, другой — черезchpasswdи вдобавок убивает чужие скрипты в/tmp, то есть выгоняет конкурентов. -
Сети. Под каждым видом теперь видно, сколько адресов из хостингов и облаков, а сколько из обычных провайдеров: это отличает арендованный парк серверов от заражённых роутеров и камер. Подсказал это читатель англоязычной версии статьи, но предложил присылать адреса в сторонний сервис геолокации. Так я делать не стал: таблица «диапазон → сеть» скачивается на сервер раз в месяц, и поиск идёт по ней локально. Первый же источник, кстати, из России отдавал по 4 КБ в минуту — пришлось научить скрипт обрывать медленную загрузку и переходить к запасному. Первые результаты удивили: и тот разведчик с
uname, и самый массовый вид, который дописывает свой SSH-ключ, примерно на три четверти и больше живут в сетях обычных провайдеров, а не в облаках. То есть это скорее заражённые устройства, чем арендованный парк. Оговорка: «хостинг или нет» определяется по названию сети, и часть облаков с непримечательными названиями попадает в «обычные провайдеры», так что это оценка, а не точный подсчёт.
Один адрес в нескольких ловушках. Кроме SSH-ловушки на сервере есть смоляная яма, приманки баз данных и ловушки на самом сайте. Чтобы увидеть, что один и тот же адрес обошёл несколько из них, каждая хранит не адрес, а HMAC от него с секретным ключом. Совпадение хешей говорит «это тот же адрес», а сам адрес из хеша не восстановить без ключа, который лежит только на сервере. За последние 30 дней ловушки видели 2986 адресов, 274 из них попали больше чем в одну. Самый частый путь — со сканирования сайта на фальшивую страницу входа WordPress, без паузы: сканер нашёл /wp-login.php и тут же пробует пароли. Второй — из SSH-ловушки в смоляную яму, тоже меньше чем за минуту: бот закончил с одним портом и сразу пробует соседний.
Ошибки
Второй брандмауэр
Я поставил смоляную яму и приманки баз данных, открыл порты в ufw — и два дня на них не пришло ни одного соединения. Локально всё работало. tcpdump показывал входящие SYN, счётчик правила ufw рос, ответа не было.
Оказалось, на сервере одновременно включён firewalld, и его цепочка в nftables идёт после цепочки ufw. Разрешение в одном не отменяет запрет в другом. Нашёл через nft list ruleset | grep -nE '^table|hook ' — там видно все таблицы и в каком порядке они цепляются к хукам.
Исправление — скрипт, который открывает порт во всех включённых брандмауэрах сразу, и правило для себя: не делать firewall-cmd --reload, потому что он может задеть сети контейнеров.
Ложная тревога сторожа
Сторож раз в 10 минут проверяет внешнюю связь и после обрыва пишет черновик разбора — полоса доступности на странице инцидентов краснеет. 5 ноября он нарисовал обрыв, которого для посетителей не было: отвалился DNS на исходящих запросах, а сайт в это время спокойно отвечал.
Ошибка в модели: «сервер не может выйти наружу» — это не то же самое, что «сайт недоступен». Теперь перед черновиком сторож смотрит журнал веб-сервера: если внешние запросы приходили в большинстве 10-минутных отрезков перерыва, сайт был доступен, и вместо разбора приходит пуш с пояснением.
if start <= ts < end and ip.is_global: seen.add(int(ts - start) // 600) # номер 10-минутного отрезка, где был внешний запросprint(100 * len(seen) // max(1, -(-(end - start) // 600)))
Флешка, которая дважды положила сервер
Система у него стоит на флеш-накопителе. 27 октября и 8 ноября флешка зависла под записью, и сервер стоял от 7 до 16 часов.
Самое неприятное — аппаратный сторожевой таймер не помог. Ядро было живо и исправно его «кормило», просто все процессы, которым нужен был диск, висели. С точки зрения таймера всё в порядке.
Исправлений два:
-
Ядро само перезагружается при зависшем диске.
kernel.hung_task_timeout_secs = 300,hung_task_panic = 1,panic = 30: если процесс ждёт диск дольше 5 минут, ядро паникует и через 30 секунд перезагружается. Часы простоя превращаются в минуты. Риск понятен — законная операция дольше пяти минут без ответа тоже вызовет перезагрузку, но на этом сервере таких не бывает. След паники остаётся вpstore, и черновик разбора подхватывает его после перезагрузки. -
Убрать запись с флешки. Главным оставшимся источником записи была база системы блокировок. Её каталог перенесён на диск с данными и смонтирован на старое место через bind в
/etc/fstab— пути в настройках не поменялись. Скрипт переноса проверяет каждый шаг и откатывает всё, если служба после переноса не поднялась. На него тоже есть тест.
Что бы я сделал иначе
-
Тесты на настоящих данных — с первого дня. Синтетика проверяет то, что я придумал. Живые журналы проверяют то, что придумали боты, а они изобретательнее.
-
CSP — до первой строчки вёрстки. Включить строгую политику на готовом сайте — это переписать половину шаблонов. Включить её в начале — просто так писать с самого начала.
-
Сторожу — проверять то, что видит пользователь, а не то, что видит сервер. Ложная тревога обошлась дешевле пропущенной, но доверие к полосе доступности она подточила.
Без базы данных и фреймворка за три недели ни разу не хотелось вернуться к ним. Хотелось другого — чтобы тесты появлялись раньше ошибок, а не после.
Как устроен сайт и из чего он сделан — на странице «Как устроен этот сайт», как он защищён — «Аудит», справочник всех видов ботов с паспортами, родословной и сетями — бестиарий, а разборы сбоев — на странице «Инциденты».
Источник: habr.com