Дисклеймер
Данная статья написана исключительно в ознакомительных и образовательных целях. Автор не поощряет взлом информационных систем. Исследование проводилось в рамках личного аккаунта для изучения протоколов передачи данных и автоматизации доступа к собственным персональным данным.Все имена, ссылки, ID изменены/удалены.
Предыстория: как всё начиналось
В предыдущей части я рассказывал о том, как медлительность и «привет из 2015-го» в дизайне нового школьного дневника заставили меня заглянуть под капот его .apk.
Если кратко напомнить содержание:
-
Реверс-инжиниринг: Через JADX я обнаружил, что разработчики оставили отладочный вывод сессионных кук
X1_SSOпрямо вlogcat. -
Криптография «на коленке»: Вместо обещанного в коде AES, вся защита данных держалась на обычном XOR с захардкоженным ключом
ru.vendor.schoolapp(имя пакета) -
Костыли и заглушки: Выяснилось, что если приложение не может получить ключ устройства, оно использует универсальную заглушку
000xpda.
Тогда моей целью была простая автоматизация: я написал Python-скрипт и Telegram-бота, чтобы просто по-человечески смотреть своё расписание и оценки, не дожидаясь, пока WebView соизволит прогрузиться.
Но чем дальше я копал API, тем больше понимал: возможность видеть свои оценки — это не фича, это лишь верхушка айсберга.
Я думал, что сервер проверяет мои права доступа, но стоило мне подставить в запрос случайный guid из полученного JSON и убрать X1_SSO из заголовков, как «дверь» открылась настежь. Оказалось, что система не просто плохо написана — в ней фактически отсутствует проверка полномочий.
Прежде чем мы перейдем к коду, стоит прояснить один момент: это приложение — не уникальная разработка одного города. Это настоящий «серийный» продукт. Исследование показало, что абсолютно идентичный интерфейс, логика и (что самое печальное) дыры в безопасности «радуют» пользователей как минимум в трех субъектах.
Бюрократический пинг-понг (Минцифры vs РКН vs Разработчики)
Я решил пойти путем Responsible Disclosure (ответственного разглашения). Казалось бы, данные миллионов детей под угрозой — реакция должна быть мгновенной. Но реальность оказалась куда хуже.
Раунд 1: Минцифры Первым делом улетело обращение в Минцифры. Суть проста: «Ребята, у вас в региональном приложении авторизация — это одно название, поправьте».
Результат: Минцифры, недолго думая, включили режим «моя хата с краю» и перенаправили запрос
Я → Минцифры → Минобр N-нного города → РЦИТ (Региональный центр информационных технологий, который указан как разработчик в одном из регионов).
Тогда РЦИТ официально отрапортовал: «Информация принята для анализа и устранения выявленных замечаний». Казалось бы, победа? Как бы не так.
Их «работа над ошибками» выглядела как идеальный анекдот:
-
Вместо рефакторинга — макияж: Они убрали простейший XOR-шифр, который смущал всех, кто хоть раз открывал декомпилятор.
-
Вместо реальной криптографии — «имитация»: На его место водрузили AES. Казалось бы, прогресс! Но стоило заглянуть в класс
Crypt.java, как всё встало на свои места: секретный ключ шифрования и API-ключи они заботливо положили прямо в код приложения, как записку с паролем под клавиатурой. -
Бездействие в остальных регионах: проблему «исправили» только в одном из 3 приложений
package ru.vendor.schoolapp2.common;import android.util.Base64;import javax.crypto.Cipher;import javax.crypto.spec.SecretKeySpec;/* JADX INFO: loaded from: classes2.dex */public class Crypt { public static String alt_api_key = "gqhy671nmn3478fhsdjf4f09f45sf4fg4lf842d1"; private static Cipher cipher = null; public static String encryptedTOKEN = ""; private static SecretKeySpec key = null; public static String session_apikey = "0xt25240s9s12xv767v1ll17757e32e34x12ppix332vdi2i"; private static String transformation = "AES/ECB/PKCS5Padding"; private static byte[] encrypt(SecretKeySpec secretKeySpec, byte[] bArr) { try { Cipher cipher2 = Cipher.getInstance(transformation); cipher = cipher2; cipher2.init(1, secretKeySpec); return cipher.doFinal(bArr); } catch (Exception unused) { return null; } } private static byte[] decrypt(SecretKeySpec secretKeySpec, byte[] bArr) { try { Cipher cipher2 = Cipher.getInstance(transformation); cipher = cipher2; cipher2.init(2, secretKeySpec); return cipher.doFinal(bArr); } catch (Exception unused) { return null; } } public static String encryptString(String str) { return Base64.encodeToString(encrypt(key, str.getBytes()), 2); } public static String decryptString(String str) { byte[] bArrDecrypt = decrypt(key, Base64.decode(str, 2)); if (bArrDecrypt != null) { return new String(bArrDecrypt); } return ""; } public static void initKey() { key = createSecretKey(); } private static SecretKeySpec createSecretKey() { return new SecretKeySpec(new byte[]{10, 20, 30, 40, 50, 23, 19, 31, 0, 0, 0, 0, 0, 0, 0, 0}, 0, 16, "AES"); }}
Раунд 2: Второе обращение, Роскомнадзор
-
Я → Минцифры : «Ребята, ваш предыдущий патч — это декорация. Данные миллионов детей всё еще в открытом доступе».
-
Минцифры → Роскомнадзор: «Это по части защиты ПДн, разбирайтесь».
-
РКН → Управление РКН по ЦФО: «Спустите на региональный уровень, пусть проверят».
В официальном ответе РКН было сказано, что обращение рассмотрено и перенаправлено по компетенции. Но пока «компетенция» раскачивалась, я решил проверить гипотезу о Client-Side Trust.
Точка входа — Shared Preferences и доверие на слово
Изучив исходники в JADX, я наткнулся на интересную особенность архитектуры. Разработчики решили, что хранить все данные о текущей сессии пользователя в обычном XML-файле shared_prefs — это отличная идея. Но дьявол, как обычно, крылся в переменных.
Моё внимание привлекли поля:
-
user_sys_guid— уникальный ID пользователя. -
rolename— строка, определяющая, кто ты: «Ученик» или «Учитель».
Я задался вопросом: а что, если приложение само решает, какой интерфейс мне показывать, основываясь только на этой строчке? Проверяет ли сервер мою роль при каждом запросе, или он просто верит тому, что прислал клиент?
Эксперимент: Для проверки гипотезы мне понадобилась полноценная android среда с root. На моем Arch Linux выбор пал на Waydroid — контейнеризированный Android, который позволяет копаться в потрохах приложений без лишних костылей.
-
Запуск: Ставим приложение, логинимся под обычной учеткой ученика.
-
Проникновение: Пока приложение «спит», ныряем в терминал:
sudo waydroid shellcd /data/data/ru.vendor.schoolapp/shared_prefs/ -
Момент истины: Открываем заветный XML. Кодировка встретила меня «кракозябрами» (привет, UTF-8 в ASCII-терминале), но структуру не скрыть. Находим строки:
<string name="rolename">Ученик</string>и<string name="user_sys_guid">123abc</string> -
Ставим учительский GUID, который нам вернул сервер при запросе дневника, роль учителя и даже имя

Приложение открылось. Но вместо привычного скучного дневника меня встретил… полноценный интерфейс учителя. Появились кнопки «СОХРАНИТЬ», «ДОБАВИТЬ КОЛОНКУ» и выбор любого класса.
Это был официальный диагноз системе: Broken Access Control. Серверная часть оказалась настолько доверчивой, что просто отдала мне функции управления журналом, потому что я сам себя назвал учителем в текстовом файле на телефоне.

Заключение: Безопасность как декорация
Подводя итоги моего «сериала» с обращениями в Минцифры и РКН:
-
Имитация патчей: Замена XOR на AES с ключом в коде — это не защита, а попытка пустить пыль в глаза.
-
Игнорирование критических уязвимостей: Баги, позволяющие любому школьнику стать учителем, живут, несмотря на официальные жалобы.
-
Масштаб: Проблема затрагивает как минимум три региона, а значит, под угрозой данные сотен тысяч учеников.
Дисклеймер (Повторно)
Все данные в ходе исследования остались в сохранности. Ни одна оценка не была исправлена (хотя искушение было велико), ни один прогул не был удален. Цель статьи — не навредить, а заставить ответственных лиц наконец-то заняться делом.
А я пока жду исправлений
ссылка на оригинал статьи https://habr.com/ru/articles/1025016/