Дисклеймер
Данная статья написана исключительно в ознакомительных и образовательных целях. Автор не поощряет взлом информационных систем. Исследование проводилось в рамках личного аккаунта для изучения протоколов передачи данных и автоматизации доступа к собственным персональным данным.Все имена, ссылки, 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
-
Алгебра ❗️ [ПР]
🕒 12:35 - 13:15
📝 Тема не указана
🏠 ДЗ: №636(а,б)638(а) -
Обществознание ❗️ [ПР]
🕒 13:45 - 14:25
📝 Повторительно-обобщающий урок по теме «Человек в экономических отношениях»
🏠 ДЗ: п. 19 -
География ⭐️ (5)
🕒 14:35 - 15:15
📝 . Контрольная работа по разделу "Природа России". Обобщающее повторение по темам: "Геологическое строение, рельеф и полезные ископаемые", "Климат и климатические ресурсы", "Моря России и внутренние воды"
🏠 ДЗ: нет
Итоги и выводы
Проведенное исследование API мобильного приложения показало, что безопасность системы строится на концепции «безопасность через неясность», которая в 2026 году уже не является эффективной защитой.
1. Технические итоги
В ходе реверса были выявлены следующие критические недоработки:
-
Хардкод в продакшене: Наличие заглушек для отладки (вроде
pdakey: 000xpda), которые не были вырезаны перед релизом. Это позволило обойти проверку легитимности устройства. -
Слабая криптография: Использование XOR с фиксированной солью, зашитой в коде. Любой декомпилятор вскрывает такую защиту за 5 минут.
-
Мертвый код: Наличие в классе
Cryptнеиспользуемых переменных для AES-шифрования и функций, работающих вхолостую (replaceChars). Это говорит о низком качестве контроля кода. -
Отсутствие привязки сессии: Токен авторизации (
X1_SSO) не привязан к «отпечатку» устройства (Fingerprint) или IP-адресу, что позволяет использовать его в сторонних скриптах.
2. Практический результат
-
Создан легковесный Telegram-бот, который работает быстрее официального клиента.
-
Реализован парсинг вложенных структур JSON.
3. Резюме
Этот проект — отличный пример того, как любопытство помогает решать бытовые задачи. Вместо того чтобы мириться с неудобным интерфейсом, я изучил внутрянку системы, перестроил её под себя и планирую развивать.
Автор: vodolaz338
