или Матрица — Мнимая Безопасность, где защита зависит от того, куда попал запрос
Все началось как в Матрице: друг прислал ссылку на бота “Mimolet”, я регнулся, открыл запросы мини-приложения Telegram через Charles Proxy и начал смотреть, что именно принимает сервер.
Через несколько минут появились три ред флага:
action_tokenпроверяется, но не во всех хендлерах одного действия;ранее выданный
PHPSESSIDпродолжает работать после БАНА анкеты;на проде валяется
test.phpс подробностями окружения.
Позже появился четвертый пункт — abuse_ban, который должен был ограничить абузы, но не устранил различия между хендлерами.
Тут нет речи о «полном взломе сервиса». Просто очень показательный пример того, почему локальные проверки не складываются в одну систему защиты.
По итогам разговора представители проекта учли четыре пункта в рамках программы вознаграждений за уязвимости:
первоначальное использование Telegram ID как идентификаторов записей;
работу ранее выданного
PHPSESSIDпосле блокировки;устройство и ограничения
abuse_ban;открытый
test.php.
TL;DR
Хэш action_token действительно проверялся. Но лайк можно было отправить через другой хендлер, где этот токен не требовался.
Бан анкеты действительно отображалась. Но ранее выданная сессия не отзывалась и продолжала приниматься всем API.
abuse_ban действительно появился. Но отдельный запрет не заменил проверку стейта пользователя.
test.php действительно был диагностическим файлом. Но он лежал на проде и перечислял версии, абсолютные пути и доступные функции.
Оглавление
1. Токены лайков: хэш проверяется, но это не спасает
Структура токена действия:
base64(v1|my_id|user_id|ts+2h).sha256_hash
В него входят версия формата, идентификатор текущего пользователя, идентификатор целевого юзера, время окончания действия и подпись.
Изначально кажется, что action_token закрывает вопрос с юзер-сайд подделкой действий. Пользователь не может просто заменить user_id: подпись перестанет совпадать. Просроченный токен также должен отклоняться.
И это действительно работало. Сервер проверял хэш в feed_classic_api.php.
Проблема оказалась не в SHA-256.
Один лайк обрабатывается в двух местах
В feed_classic_api.php для лайка требовался action_token. Но аналогичное действие принимал и profile_view.php, где было достаточно валидной сессии.
Запрос выглядел так:
POST /back/profile/profile_view.php?tg_id=<TARGET_ID> HTTP/1.1 Host: mimoletbot.ru Cookie: PHPSESSID=<OWN_SESSION> Content-Type: multipart/form-data; boundary=----FormBoundary ------FormBoundary Content-Disposition: form-data; name="ajax" 1 ------FormBoundary Content-Disposition: form-data; name="action" like ------FormBoundary Content-Disposition: form-data; name="profile_id" <TARGET_ID> ------FormBoundary--
То же самое в сокращенном виде:
import requests session = requests.Session() session.cookies.set("PHPSESSID", "<OWN_SESSION>") response = session.post( "https://mimoletbot.ru/back/profile/profile_view.php", params={"tg_id": "<TARGET_ID>"}, files={ "ajax": (None, "1"), "action": (None, "like"), "profile_id": (None, "<TARGET_ID>"), }, timeout=10, ) print(response.status_code) print(response.text[:300])
В запросе нет action_token ;)
Получается очень простая конструкция:
через ленту лайк требует подписанный токен;
через просмотр профиля тот же лайк обрабатывается без него;
целостность токена не нарушена, но обязательность токена зависит от выбранного API эндпойнта.
Поэтому утверждение «хэш проверяется» верно, но недостаточно. Проверять нужно не наличие защитного кода где-то в проекте, а отсутствие любого пути, который этот код обходит.
Схематично различие выглядит примерно так:
// feed_classic_api.php $user = requireSession(); $token = $_POST['action_token'] ?? ''; verifyActionToken( $token, $user['id'], (int) $_POST['profile_id'] ); createLike( $user['id'], (int) $_POST['profile_id'] );
А во втором хендлере:
// profile_view.php $user = requireSession(); if (($_POST['action'] ?? '') === 'like') { createLike( $user['id'], (int) $_POST['profile_id'] ); }
Это не исходный код Mimolet, а короткая схема наблюдаемого поведения.
Почему нельзя просто скопировать проверку
Можно добавить verifyActionToken() во второй файл и закрыть конкретный запрос. Но это костыль…
Сегодня есть два хендлера. Завтра появится третий — например, для пушей. Если каждый из них самостоятельно проверяет права, стейт анкеты, подписку и токен, они снова разойдутся по логике.
Нормальная схема:
function createAuthorizedLike( PDO $db, array $user, int $targetId, string $actionToken ): void { verifyActionToken( $actionToken, $user['id'], $targetId ); if (!canLike($db, $user['id'], $targetId)) { http_response_code(403); exit; } createLike($user['id'], $targetId); }
И feed_classic_api.php, и profile_view.php должны вызывать именно эту функцию, а не создавать лайк-евент самостоятельно.
Telegram ID как внутренний номер
В первоначальной структуре идентификатором записи служил Telegram ID.
Сам Telegram ID не является паролем. Знание айди не дает доступ к учетке. Но прямое использование внешнего идентификатора создает ненужную связь между Telegram и внутренней базой:
номер появляется в адресах и ответах;
по нему напрямую выбирается объект;
разработчик может начать воспринимать знание ID как достаточное условие для действия;
ошибка проверки прав становится проще для воспроизведения.
Позже проект перешел на отдельные внутренние айди. Это правильное изменение, но оно не заменяет проверку прав:
if (!canAccessProfile( currentUserId: $user['id'], profileId: $profileId )) { http_response_code(403); exit; }
Хоть Telegram ID, хоть внутреннее число, хоть случайная строка — сервер все равно обязан решить, разрешено ли действие текущему пользователю. Так работает объектная авторизация.
2. PHPSESSID: работает даже для забаненных
Следующая проблема воспроизводилась на моей собственной забаненной анкете.
Интерфейс показывал страницу бана, но ранее выданный PHPSESSID продолжал приниматься API.
Проверка:
import requests session = requests.Session() session.cookies.set( "PHPSESSID", "<OWN_BLOCKED_SESSION>" ) response = session.post( "https://mimoletbot.ru/back/api/feed_classic_api.php", data={"action": "bootstrap"}, timeout=10, ) print("Код ответа:", response.status_code) print("Тело ответа:", response.text[:500])
Здесь используется собственная сессия, полученная до блокировки.

При повторном открытии мини-приложения Telegram также выдавались несколько одновременно валидных сессий одной анкете. Само наличие нескольких сессий нормально: пользователь может войти с разных устройств. Ненормально, если при блокировке сервер не умеет отзывать все.
Получается знакомая картина:
клиентская часть уже говорит «доступ запрещен, ай-ай-ай»;
старый идентификатор сессии все еще валиден;
отдельные хендлеры не проверяют актуальное состояние анкеты одинаково.
Страница ban.php — это еще не бан. Это просто HTTP 500…
Правильное название проблемы
Раньше я называл это фиксацией сессии. Это неточно.
Фиксация сессии — случай, когда нарушитель заранее знает или задает идентификатор, а затем заставляет жертву войти с ним.
Здесь происходило другое: сервер не отзывал уже выданный PHPSESSID после изменения стейта пользователя.
Точный термин будет: недостаточный отзыв сессий после блокировки.
Что должен делать сервер
Состояние пользователя нужно проверять перед каждым защищенным действием:
function requireActiveUser(PDO $db): array { session_start([ 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', 'use_strict_mode' => true, ]); $userId = $_SESSION['user_id'] ?? null; if (!$userId) { http_response_code(401); exit; } $statement = $db->prepare( 'SELECT id, status FROM users WHERE id = :id LIMIT 1' ); $statement->execute(['id' => $userId]); $user = $statement->fetch(PDO::FETCH_ASSOC); if (!$user || $user['status'] !== 'active') { $_SESSION = []; session_destroy(); http_response_code(403); exit; } return $user; }
Но одной проверки статуса мало. При бане нужно отозвать все серверные записи сессий пользователя:
UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = :user_id AND revoked_at IS NULL;
А каждый запрос должен проверять:
существует ли сессия на сервере;
валиден ли ее срок;
не установлена ли отметка отзыва;
остается ли пользователь активным.
3. Костыли: abuse_ban и почему они не работают
После первых сообщений о проблемах в сервисе появился abuse_ban.
Исходного кода бэкенда у меня нет, поэтому описывать его внутреннее устройство как факт было бы неправильно. По ответам сервера видно лишь внешнее поведение: механизм реагировал на отдельные признаки абуза и ограничивал получение либо использование сессии.

Само появление ограничения — хорошая реакция. Но оно не устранило главную причину:
ранее выданная собственная сессия продолжала приниматься;
разные PHP-файлы по-разному проверяли стейт анкеты;
feed_classic_api.phpиprofile_view.phpсохраняли разные правила обработки одного действия.
Если abuse_ban добавлен только в одну точку входа, он защищает только эту точку.
Схематично неудачная конструкция выглядит примерно так:
if (tooManyRequests($_SERVER['REMOTE_ADDR'])) { $_SESSION['abuse_ban'] = true; header('Location: /ban.php'); exit; }
Другой хендлер может вообще не читать $_SESSION['abuse_ban']. Новая сессия может не унаследовать флаг. Параллельная старая сессия может сохранить прежнее состояние.
Поэтому нужен не набор разрозненных признаков, а единое серверное состояние:
active обычная работа; restricted временное ограничение; blocked перманентный бан.
Каждый защищенный хендлер начинает работу с одной проверки:
$user = requireActiveUser($db);
Если пользователь заблокирован, общий хендлер:
возвращает
403;отзывает текущую сессию;
не запускает прикладное действие;
записывает причину отказа в журнал.
Отдельно вводится ограничение частоты запросов:
if (!$requestCounter->allow( key: $user['id'] . ':like', maximum: 30, intervalSeconds: 60 )) { http_response_code(429); header('Retry-After: 60'); exit; }
Числа приведены только для примера. Важен принцип: ограничение относится к пользователю и действию, а не к одному случайному PHP-файлу.
abuse_ban не должен подменять проверку сессии, состояния пользователя и права на действие.
4. Архитектура: ISPmanager и test.php
При скане портов обнаружились ISPmanager и Plesk Obsidian на разных айпи.
Сам факт наличия панели управления не является уязвимостью. Это обычный рабочий инструмент. Риск зависит от того, ограничен ли доступ, включена ли многофакторная проверка и своевременно ли устанавливаются обновления.
Однако, интересным оказался test.php — диагностический файл для проверки серверных библиотек.
Он раскрывал:
PHP: 8.3.6 Путь: /var/www/www-root/data/www/mimoletbot.ru/ aws-sdk-php: 3.371.3 ffmpeg: 6.1.1 shell_exec(): доступна
Внутри находилось и прямое напоминание: после проверки впс файл надо удалить, потому что открытая диагностика раскрывает сведения об окружении.
Инструкция отличная. Не хватило только последнего шага — выполнить ее.
Сохраненная страница: архивная копия test.php.
Что это доказывает
Открытый test.php сообщает внешнему юзеру:
точную версию PHP;
абсолютный путь к корню сайта;
имя системного пользователя;
версии отдельных библиотек;
наличие разрешенной функции
shell_exec().
Это полезные сведения для инвентаризации системы и подбора дальнейших проверок.
Но здесь также важна точность. Наличие shell_exec() не означает возможность выполнить произвольную команду. Для этого потребовался бы отдельный путь, по которому управляемые пользователем данные доходят до функции.
Корректная классификация — раскрытие сведений о серверном окружении.
Как исправить
Первое — удалить test.php с рабочего сервера.
Второе — закрыть известные диагностические адреса на уровне Nginx:
location = /test.php { return 404; } location = /phpinfo.php { return 404; }
Третье — стопать сборку, если временные диагностические файлы попали в состав прода:
forbidden_files="$( find . -type f \ \( -name 'test.php' \ -o -name 'phpinfo.php' \) )" if [ -n "$forbidden_files" ]; then echo "Найдены диагностические файлы:" echo "$forbidden_files" exit 1 fi
Подробная диагностика должна быть доступна только с локала. Внешняя проверка состояния может отвечать коротко:
{"status":"ok"}
5. Что здесь нужно исправить
Все следствия сводятся к одной причине: обязательные правила разбросаны по отдельным файлам.
1. Свести одинаковые действия к одной проверке
Лайк, жалоба или другое изменение состояния должны выполняться одним серверным методом. Внешних адресов может быть несколько, но все они обязаны вызывать общую функцию с одинаковыми проверками.
Порядок:
проверка сессии проверка состояния пользователя проверка права на объект проверка action_token ограничение частоты изменение данных
2. Отзывать сессии при бане
Блокировка должна одновременно:
менять состояние пользователя;
отзывать все его серверные сессии;
запрещать выпуск новых сессий;
возвращать отказ на каждом защищенном роуте.
3. Не полагаться на внутренний айди объекта
Замена Telegram ID на внутренний айди полезна, но это разделение сущностей, а не проверка доступа. Для каждого запроса все равно нужна проверка текущего юзера и целевого объекта.
4. Разделить блокировку и ограничение частоты
abuse_ban отвечает за реакцию на злоупотребление. Ограничение частоты отвечает за число запросов. Отзыв сессии отвечает за прекращение доступа. Эти механизмы связаны, но не заменяют друг друга.
5. Убрать диагностику из рабочей среды
Временные файлы должны отсекаться автоматически до деплоя. Панели управления надо закрываться сетевыми правилами и многофакторной проверкой.
6. Проверять не отдельные ответы, а свойства системы
Минимальные автоматические проверки:
def test_blocked_session_is_rejected(api, blocked_session): for route in api.protected_routes: response = api.call( route, cookies={"PHPSESSID": blocked_session}, ) assert response.status_code in {401, 403} def test_like_without_token_does_not_change_state( api, active_session, target_id, ): response = api.post( "/back/profile/profile_view.php", data={ "action": "like", "profile_id": target_id, }, cookies={"PHPSESSID": active_session}, ) assert response.status_code in {400, 401, 403} assert not api.like_exists(active_session, target_id)
Проверять нужно не только код ответа, но и отсутствие изменения данных. Хендлер может вернуть ошибку уже после того, как успел обработать лайк.
6. Границы и итог
В рамках программы вознаграждений проект учел:
первоначальную структуру с Telegram ID;
работу ранее выданной сессии после блокировки;
abuse_ban;открытый
test.php.
Все запросы выполнялись из собственной учетки. Реальные идентификаторы пользователей не опубликованы. Доступа к чужим перепискам, учетным записям, базе данных и панели управления я не получал.
Фрагменты PHP с вариантами исправления это поясняющие примеры, а не исходный код Mimolet. Выводы об устройстве abuse_ban основаны только на наблюдаемом поведении сервера.
Главный вывод простой: наличие защитного механизма в одном файле еще не означает, что защищено само действие.
Хэш должен проверяться не где-то. Бан должен существовать не только на странице. Сессия должна отзываться не на словах. А test.php после проверки действительно нужно удалять.
Автор
0xItsss — исследователь безопасности
Поддержать автора
Если ценишь подробные разборы реальных ошибок:
BTC: bc1qnvn2fwkptfw5n7q033gkd38atlntp08s5d2vuh
TRC20: TVNPzo91J7cPv2tZnvHfKyH8HMGzwXQg44
