Запровадження
Наприкінці березня ми
Деякий час тому UC Browser рекламували і розповсюджували дуже агресивно: його встановлювали на пристрої користувачів за допомогою шкідливих програм, розповсюджували з різних сайтів під виглядом відеофайлів (тобто користувачі думали, що качають, наприклад, порноролик, а отримували замість нього APK з цим браузером), використовували лякаючі банери з повідомленнями про те, що браузер застарів, вразливий і все в такому дусі. В офіційній групі UC Browser у VK є
На момент написання статті у UC Browser набралося понад 500 000 000 установок Google Play. Це вражає більше тільки у Google Chrome. Серед відгуків можна побачити чимало скарг на рекламу та редиректи на якісь програми в Google Play. Це і стало приводом для дослідження: ми вирішили подивитися, чи робить UC Browser щось погане. І виявилось, що таки робить!
У коді програми виявилася можливість завантаження та запуску виконуваного коду,
Все, що написано далі, є актуальним для версії UC Browser, яка була присутня в Google Play на момент проведення дослідження:
package: com.UCMobile.intl
versionName: 12.10.8.1172
versionCode: 10598
sha1 APK-файла: f5edb2243413c777172f6362876041eb0c3a928c
Вектор атаки
У маніфесті UC Browser можна виявити сервіс з назвою, що говорить. com.uc.deployment.UpgradeDeployService.
<service android_exported="false" android_name="com.uc.deployment.UpgradeDeployService" android_process=":deploy" />
Під час запуску цього сервісу браузер виконує POST-запит до
Отже, коли користувач хоче відкрити PDF прямо у браузері, у трафіку можна побачити такі запити:
Спочатку йде POST-запит до
завантажується архів із бібліотекою для перегляду PDF та офісних форматів. Логічно припустити, що в першому запиті передається інформація про систему (як мінімум, архітектура, щоб віддати потрібну бібліотеку), а у відповідь на нього браузер отримує інформацію про бібліотеку, яку потрібно завантажити: адресу і, можливо, щось ще. Проблема в тому, що цей запит зашифрований.
Фрагмент запиту
Фрагмент відповіді
Сама бібліотека упакована у ZIP і не зашифрована.
Пошук коду розшифровки трафіку
Спробуємо розшифрувати відповідь сервера. Дивимося код класу com.uc.deployment.UpgradeDeployService: з методу onStartCommand переходимо в com.uc.deployment.bx, а з нього в com.uc.browser.core.dcfe:
public final void e(l arg9) {
int v4_5;
String v3_1;
byte[] v3;
byte[] v1 = null;
if(arg9 == null) {
v3 = v1;
}
else {
v3_1 = arg9.iGX.ipR;
StringBuilder v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]product:");
v4.append(arg9.iGX.ipR);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]version:");
v4.append(arg9.iGX.iEn);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]upgrade_type:");
v4.append(arg9.iGX.mMode);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]force_flag:");
v4.append(arg9.iGX.iEo);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_mode:");
v4.append(arg9.iGX.iDQ);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_type:");
v4.append(arg9.iGX.iEr);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_state:");
v4.append(arg9.iGX.iEp);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_file:");
v4.append(arg9.iGX.iEq);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apk_md5:");
v4.append(arg9.iGX.iEl);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]download_type:");
v4.append(arg9.mDownloadType);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]download_group:");
v4.append(arg9.mDownloadGroup);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]download_path:");
v4.append(arg9.iGH);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_child_version:");
v4.append(arg9.iGX.iEx);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_series:");
v4.append(arg9.iGX.iEw);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_cpu_arch:");
v4.append(arg9.iGX.iEt);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_cpu_vfp3:");
v4.append(arg9.iGX.iEv);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_cpu_vfp:");
v4.append(arg9.iGX.iEu);
ArrayList v3_2 = arg9.iGX.iEz;
if(v3_2 != null && v3_2.size() != 0) {
Iterator v3_3 = v3_2.iterator();
while(v3_3.hasNext()) {
Object v4_1 = v3_3.next();
StringBuilder v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_name:");
v5.append(((au)v4_1).getName());
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_ver_name:");
v5.append(((au)v4_1).aDA());
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_ver_code:");
v5.append(((au)v4_1).gBl);
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_req_type:");
v5.append(((au)v4_1).gBq);
}
}
j v3_4 = new j();
m.b(v3_4);
h v4_2 = new h();
m.b(v4_2);
ay v5_1 = new ay();
v3_4.hS("");
v3_4.setImsi("");
v3_4.hV("");
v5_1.bPQ = v3_4;
v5_1.bPP = v4_2;
v5_1.yr(arg9.iGX.ipR);
v5_1.gBF = arg9.iGX.mMode;
v5_1.gBI = arg9.iGX.iEz;
v3_2 = v5_1.gAr;
c.aBh();
v3_2.add(g.fs("os_ver", c.getRomInfo()));
v3_2.add(g.fs("processor_arch", com.uc.b.a.a.c.getCpuArch()));
v3_2.add(g.fs("cpu_arch", com.uc.b.a.a.c.Pb()));
String v4_3 = com.uc.b.a.a.c.Pd();
v3_2.add(g.fs("cpu_vfp", v4_3));
v3_2.add(g.fs("net_type", String.valueOf(com.uc.base.system.a.Jo())));
v3_2.add(g.fs("fromhost", arg9.iGX.iEm));
v3_2.add(g.fs("plugin_ver", arg9.iGX.iEn));
v3_2.add(g.fs("target_lang", arg9.iGX.iEs));
v3_2.add(g.fs("vitamio_cpu_arch", arg9.iGX.iEt));
v3_2.add(g.fs("vitamio_vfp", arg9.iGX.iEu));
v3_2.add(g.fs("vitamio_vfp3", arg9.iGX.iEv));
v3_2.add(g.fs("plugin_child_ver", arg9.iGX.iEx));
v3_2.add(g.fs("ver_series", arg9.iGX.iEw));
v3_2.add(g.fs("child_ver", r.aVw()));
v3_2.add(g.fs("cur_ver_md5", arg9.iGX.iEl));
v3_2.add(g.fs("cur_ver_signature", SystemHelper.getUCMSignature()));
v3_2.add(g.fs("upgrade_log", i.bjt()));
v3_2.add(g.fs("silent_install", String.valueOf(arg9.iGX.iDQ)));
v3_2.add(g.fs("silent_state", String.valueOf(arg9.iGX.iEp)));
v3_2.add(g.fs("silent_file", arg9.iGX.iEq));
v3_2.add(g.fs("silent_type", String.valueOf(arg9.iGX.iEr)));
v3_2.add(g.fs("cpu_archit", com.uc.b.a.a.c.Pc()));
v3_2.add(g.fs("cpu_set", SystemHelper.getCpuInstruction()));
boolean v4_4 = v4_3 == null || !v4_3.contains("neon") ? false : true;
v3_2.add(g.fs("neon", String.valueOf(v4_4)));
v3_2.add(g.fs("cpu_cores", String.valueOf(com.uc.b.a.a.c.Jl())));
v3_2.add(g.fs("ram_1", String.valueOf(com.uc.b.a.a.h.Po())));
v3_2.add(g.fs("totalram", String.valueOf(com.uc.b.a.a.h.OL())));
c.aBh();
v3_2.add(g.fs("rom_1", c.getRomInfo()));
v4_5 = e.getScreenWidth();
int v6 = e.getScreenHeight();
StringBuilder v7 = new StringBuilder();
v7.append(v4_5);
v7.append("*");
v7.append(v6);
v3_2.add(g.fs("ss", v7.toString()));
v3_2.add(g.fs("api_level", String.valueOf(Build$VERSION.SDK_INT)));
v3_2.add(g.fs("uc_apk_list", SystemHelper.getUCMobileApks()));
Iterator v4_6 = arg9.iGX.iEA.entrySet().iterator();
while(v4_6.hasNext()) {
Object v6_1 = v4_6.next();
v3_2.add(g.fs(((Map$Entry)v6_1).getKey(), ((Map$Entry)v6_1).getValue()));
}
v3 = v5_1.toByteArray();
}
if(v3 == null) {
this.iGY.iGI.a(arg9, "up_encode", "yes", "fail");
return;
}
v4_5 = this.iGY.iGw ? 0x1F : 0;
if(v3 == null) {
}
else {
v3 = g.i(v4_5, v3);
if(v3 == null) {
}
else {
v1 = new byte[v3.length + 16];
byte[] v6_2 = new byte[16];
Arrays.fill(v6_2, 0);
v6_2[0] = 0x5F;
v6_2[1] = 0;
v6_2[2] = ((byte)v4_5);
v6_2[3] = -50;
System.arraycopy(v6_2, 0, v1, 0, 16);
System.arraycopy(v3, 0, v1, 16, v3.length);
}
}
if(v1 == null) {
this.iGY.iGI.a(arg9, "up_encrypt", "yes", "fail");
return;
}
if(TextUtils.isEmpty(this.iGY.mUpgradeUrl)) {
this.iGY.iGI.a(arg9, "up_url", "yes", "fail");
return;
}
StringBuilder v0 = new StringBuilder("[");
v0.append(arg9.iGX.ipR);
v0.append("]url:");
v0.append(this.iGY.mUpgradeUrl);
com.uc.browser.core.d.c.i v0_1 = this.iGY.iGI;
v3_1 = this.iGY.mUpgradeUrl;
com.uc.base.net.e v0_2 = new com.uc.base.net.e(new com.uc.browser.core.d.c.i$a(v0_1, arg9));
v3_1 = v3_1.contains("?") ? v3_1 + "&dataver=pb" : v3_1 + "?dataver=pb";
n v3_5 = v0_2.uc(v3_1);
m.b(v3_5, false);
v3_5.setMethod("POST");
v3_5.setBodyProvider(v1);
v0_2.b(v3_5);
this.iGY.iGI.a(arg9, "up_null", "yes", "success");
this.iGY.iGI.b(arg9);
}
Бачимо тут формування POST-запиту. Звертаємо увагу створення масиву з 16 байт та її заповнення: 0x5F, 0, 0x1F, -50 (=0xCE). Збігається з тим, що ми бачили у запиті вище.
У цьому класі можна помітити вкладений клас, у якому є інший цікавий метод:
public final void a(l arg10, byte[] arg11) {
f v0 = this.iGQ;
StringBuilder v1 = new StringBuilder("[");
v1.append(arg10.iGX.ipR);
v1.append("]:UpgradeSuccess");
byte[] v1_1 = null;
if(arg11 == null) {
}
else if(arg11.length < 16) {
}
else {
if(arg11[0] != 0x60 && arg11[3] != 0xFFFFFFD0) {
goto label_57;
}
int v3 = 1;
int v5 = arg11[1] == 1 ? 1 : 0;
if(arg11[2] != 1 && arg11[2] != 11) {
if(arg11[2] == 0x1F) {
}
else {
v3 = 0;
}
}
byte[] v7 = new byte[arg11.length - 16];
System.arraycopy(arg11, 16, v7, 0, v7.length);
if(v3 != 0) {
v7 = g.j(arg11[2], v7);
}
if(v7 == null) {
goto label_57;
}
if(v5 != 0) {
v1_1 = g.P(v7);
goto label_57;
}
v1_1 = v7;
}
label_57:
if(v1_1 == null) {
v0.iGY.iGI.a(arg10, "up_decrypt", "yes", "fail");
return;
}
q v11 = g.b(arg10, v1_1);
if(v11 == null) {
v0.iGY.iGI.a(arg10, "up_decode", "yes", "fail");
return;
}
if(v0.iGY.iGt) {
v0.d(arg10);
}
if(v0.iGY.iGo != null) {
v0.iGY.iGo.a(0, ((o)v11));
}
if(v0.iGY.iGs) {
v0.iGY.a(((o)v11));
v0.iGY.iGI.a(v11, "up_silent", "yes", "success");
v0.iGY.iGI.a(v11);
return;
}
v0.iGY.iGI.a(v11, "up_silent", "no", "success");
}
}
Метод отримує на вхід масив байтів і перевіряє, що нульовий байт дорівнює 0x60 або третій байт дорівнює 0xD0, а другий байт - 1, 11 або 0x1F. Дивимося відповідь від сервера: нульовий байт – 0x60, другий – 0x1F, третій – 0x60. Схоже на те, що нам потрібне. Судячи з рядків («up_decrypt», наприклад), тут має викликатися спосіб, який розшифрує відповідь сервера.
Переходимо до методу gj. Зауважимо, що як перший аргумент у нього передається байт зі зміщення 2 (тобто 0x1F у нашому випадку), а як другий — відповідь сервера без
перших 16 байт.
public static byte[] j(int arg1, byte[] arg2) {
if(arg1 == 1) {
arg2 = c.c(arg2, c.adu);
}
else if(arg1 == 11) {
arg2 = m.aF(arg2);
}
else if(arg1 != 0x1F) {
}
else {
arg2 = EncryptHelper.decrypt(arg2);
}
return arg2;
}
Очевидно, тут відбувається вибір алгоритму розшифровки, і той самий байт, який у нашому
випадку дорівнює 0x1F, позначає один із трьох можливих варіантів.
Продовжуємо аналіз коду. Після пари стрибків потрапляємо в метод з назвою, що говорить. decryptBytesByKey.
Тут від нашої відповіді відокремлюється ще два байти, і з них виходить рядок. Зрозуміло, що у такий спосіб вибирається ключ для розшифрування повідомлення.
private static byte[] decryptBytesByKey(byte[] bytes) {
byte[] v0 = null;
if(bytes != null) {
try {
if(bytes.length < EncryptHelper.PREFIX_BYTES_SIZE) {
}
else if(bytes.length == EncryptHelper.PREFIX_BYTES_SIZE) {
return v0;
}
else {
byte[] prefix = new byte[EncryptHelper.PREFIX_BYTES_SIZE]; // 2 байта
System.arraycopy(bytes, 0, prefix, 0, prefix.length);
String keyId = c.ayR().d(ByteBuffer.wrap(prefix).getShort()); // Выбор ключа
if(keyId == null) {
return v0;
}
else {
a v2 = EncryptHelper.ayL();
if(v2 == null) {
return v0;
}
else {
byte[] enrypted = new byte[bytes.length - EncryptHelper.PREFIX_BYTES_SIZE];
System.arraycopy(bytes, EncryptHelper.PREFIX_BYTES_SIZE, enrypted, 0, enrypted.length);
return v2.l(keyId, enrypted);
}
}
}
}
catch(SecException v7_1) {
EncryptHelper.handleDecryptException(((Throwable)v7_1), v7_1.getErrorCode());
return v0;
}
catch(Throwable v7) {
EncryptHelper.handleDecryptException(v7, 2);
return v0;
}
}
return v0;
}
Забігаючи наперед, зазначимо, що на цьому етапі виходить ще не ключ, а лише його «ідентифікатор». З отриманням ключа дещо складніше.
У наступному методі до наявних параметрів додається ще два, їх стає чотири: чарівне число 16, ідентифікатор ключа, зашифровані дані і незрозумілий рядок (у нашому випадку порожній).
public final byte[] l(String keyId, byte[] encrypted) throws SecException {
return this.ayJ().staticBinarySafeDecryptNoB64(16, keyId, encrypted, "");
}
Після серії переходів приходимо до методу staticBinarySafeDecryptNoB64 інтерфейсу com.alibaba.wireless.security.open.staticdataencrypt.IStaticDataEncryptComponent. В основному коді програми немає класів, які реалізують цей інтерфейс. Такий клас є у файлі lib/armeabi-v7a/libsgmain.so, що насправді не .so, а .jar. Метод, що цікавить нас, реалізований наступним чином:
package com.alibaba.wireless.security.a.i;
// ...
public class a implements IStaticDataEncryptComponent {
private ISecurityGuardPlugin a;
// ...
private byte[] a(int mode, int magicInt, int xzInt, String keyId, byte[] encrypted, String magicString) {
return this.a.getRouter().doCommand(10601, new Object[]{Integer.valueOf(mode), Integer.valueOf(magicInt), Integer.valueOf(xzInt), keyId, encrypted, magicString});
}
// ...
private byte[] b(int magicInt, String keyId, byte[] encrypted, String magicString) {
return this.a(2, magicInt, 0, keyId, encrypted, magicString);
}
// ...
public byte[] staticBinarySafeDecryptNoB64(int magicInt, String keyId, byte[] encrypted, String magicString) throws SecException {
if(keyId != null && keyId.length() > 0 && magicInt >= 0 && magicInt < 19 && encrypted != null && encrypted.length > 0) {
return this.b(magicInt, keyId, encrypted, magicString);
}
throw new SecException("", 301);
}
//...
}
Тут наш список параметрів доповнюється ще двома цілими числами: 2 та 0. Судячи з
всьому, 2 означає розшифровку, як у методі doFinal системного класу javax.crypto.Cipher. І все це передається в якийсь Router з числом 10601 - це, мабуть, номер команди.
Після чергового ланцюжка переходів знаходимо клас, який реалізує інтерфейс IRouterComponent та метод doCommand:
package com.alibaba.wireless.security.mainplugin;
import com.alibaba.wireless.security.framework.IRouterComponent;
import com.taobao.wireless.security.adapter.JNICLibrary;
public class a implements IRouterComponent {
public a() {
super();
}
public Object doCommand(int arg2, Object[] arg3) {
return JNICLibrary.doCommandNative(arg2, arg3);
}
}
А також клас JNICLibrary, в якому оголошено нативний метод doCommandNative:
package com.taobao.wireless.security.adapter;
public class JNICLibrary {
public static native Object doCommandNative(int arg0, Object[] arg1);
}
Отже, нам потрібно в нативному коді знайти метод doCommandNative. І тут починається сама веселість.
Обфускація машинного коду
У файлі libsgmain.so (який насправді .jar і в якому ми трохи вище знайшли реалізацію деяких інтерфейсів, пов'язаних із шифруванням) є одна бібліотека: libsgmainso-6.4.36.so. Відкриваємо її в IDA і отримуємо купу діалогових вікон із помилками. Проблема у цьому, що таблиця секцій (section header table) – невалідна. Це зроблено спеціально для ускладнення аналізу.
Але вона не потрібна: щоб коректно завантажити ELF-файл і проаналізувати його, цілком достатньо таблиці сегментів (program header table). Тому просто видаляємо таблицю секцій, занулюючи відповідні поля заголовку.
Знову відкриваємо файл у IDA.
Є два способи повідомити віртуальну Java-машину, де саме в нативній бібліотеці знаходиться реалізація методу, оголошеного в Java-коді як native. Перший - дати йому ім'я виду Java_ім'я_пакета_Ім'яКласа_ім'яМетоду.
Другий — зареєструвати його під час завантаження бібліотеки (у функції JNI_OnLoad)
за допомогою виклику функції Реєстрація тубільців.
У нашому випадку, якщо використовувати перший спосіб, ім'я має бути таким: Java_com_taobao_wireless_security_adapter_JNICLibrary_doCommandNative.
Серед функцій, що експортуються, такої немає, отже, потрібно шукати виклик Реєстрація тубільців.
Ідемо у функцію JNI_OnLoad і бачимо таку картину:
Що тут відбувається? На перший погляд, початок та кінець функції типові для архітектури ARM. Першою інструкцією у стек зберігається вміст регістрів, які функція буде використовувати у своїй роботі (в даному випадку R0, R1 і R2), а також вміст регістра LR, в якому знаходиться адреса повернення з функції. Останньою інструкцією збережені регістри відновлюються, причому адреса повернення відразу поміщається в регістр PC - таким чином відбувається повернення з функції. Але якщо придивитися, можна помітити, що передостання інструкція змінює адресу повернення, збережену в стеку. Обчислимо, яким він буде після
виконання коду. У R1 завантажується якийсь адресу 0xB130, від нього віднімається 5, потім він перекладається R0 і до нього додається 0x10. Виходить 0xB13B. Таким чином, IDA вважає, що в останній інструкції відбувається звичайне повернення з функції, а насправді відбувається перехід за обчисленою адресою 0xB13B.
Тут варто нагадати, що процесори ARM мають два режими і два набори інструкцій: ARM і Thumb. Молодший біт адреси каже процесору, який набір інструкцій використовується. Т. е. адреса насправді 0xB13A, а одиниця в молодшому биті означає режим Thumb.
На початок кожної функції до цієї бібліотеки додано подібний «перехідник» і
сміттєвий код. Далі не будемо докладно на них зупинятись – просто пам'ятаємо,
що справжній початок багатьох функцій знаходиться трохи далі.
Так як у коді немає явного переходу на 0xB13A, IDA сама не впізнала, що тут знаходиться код. З цієї ж причини більшу частину коду в бібліотеці вона не розпізнає як код, що дещо ускладнює аналіз. Говоримо IDA, що тут код, і ось що виходить:
На 0xB144 явно починається таблиця. А що в sub_494C?
При виклику цієї функції в регістрі LR отримаємо адресу згаданої таблиці (0xB144). У R0 - індекс у цій таблиці. Т. е. береться значення з таблиці, додається до LR і виходить
адресу, якою потрібно перейти. Спробуємо його обчислити: 0xB144 + [0xB144 + 8 * 4] = 0xB144 + 0x120 = 0xB264. Переходимо на отриману адресу і бачимо буквально пару корисних інструкцій і знову перехід на 0xB140:
Тепер буде перехід по зміщенню з індексом 0x20 таблиці.
Судячи з розміру таблиці таких переходів у коді зустрінеться багато. Виникає питання, чи можна якось із цим боротися більш автоматизовано, без ручного обчислення адрес. І на допомогу нам приходять скрипти та можливість патчити код у IDA:
def put_unconditional_branch(source, destination):
offset = (destination - source - 4) >> 1
if offset > 2097151 or offset < -2097152:
raise RuntimeError("Invalid offset")
if offset > 1023 or offset < -1024:
instruction1 = 0xf000 | ((offset >> 11) & 0x7ff)
instruction2 = 0xb800 | (offset & 0x7ff)
patch_word(source, instruction1)
patch_word(source + 2, instruction2)
else:
instruction = 0xe000 | (offset & 0x7ff)
patch_word(source, instruction)
ea = here()
if get_wide_word(ea) == 0xb503: #PUSH {R0,R1,LR}
ea1 = ea + 2
if get_wide_word(ea1) == 0xbf00: #NOP
ea1 += 2
if get_operand_type(ea1, 0) == 1 and get_operand_value(ea1, 0) == 0 and get_operand_type(ea1, 1) == 2:
index = get_wide_dword(get_operand_value(ea1, 1))
print "index =", hex(index)
ea1 += 2
if get_operand_type(ea1, 0) == 7:
table = get_operand_value(ea1, 0) + 4
elif get_operand_type(ea1, 1) == 2:
table = get_operand_value(ea1, 1) + 4
else:
print "Wrong operand type on", hex(ea1), "-", get_operand_type(ea1, 0), get_operand_type(ea1, 1)
table = None
if table is None:
print "Unable to find table"
else:
print "table =", hex(table)
offset = get_wide_dword(table + (index << 2))
put_unconditional_branch(ea, table + offset)
else:
print "Unknown code", get_operand_type(ea1, 0), get_operand_value(ea1, 0), get_operand_type(ea1, 1) == 2
else:
print "Unable to detect first instruction"
Ставимо курсор на рядок 0xB26A, запускаємо скрипт і бачимо перехід на 0xB4B0:
IDA знову не впізнала цю ділянку як код. Допомагаємо їй і бачимо там іншу конструкцію:
Інструкції після BLX виглядають не дуже осмисленими, це більше схоже на якесь усунення. Дивимося в sub_4964:
Тут береться dword за адресою, що лежить в LR, додається до цієї адреси, після чого береться значення за отриманою адресою і кладеться в стек. Також до LR додається 4, щоб після повернення з функції перескочити це зсув. Після цього команда POP {R1} дістає отримане значення зі стека. Якщо подивитися, що знаходиться за адресою 0xB4BA + 0xEA = 0xB5A4, можна побачити щось схоже на таблицю адрес:
Щоб пропатчити цю конструкцію, потрібно отримати два параметри коду: зміщення і номер регістру, в який потрібно покласти результат. Для кожного можливого регістру доведеться заздалегідь підготувати шматочок коду.
patches = {}
patches[0] = (0x00, 0xbf, 0x01, 0x48, 0x00, 0x68, 0x02, 0xe0)
patches[1] = (0x00, 0xbf, 0x01, 0x49, 0x09, 0x68, 0x02, 0xe0)
patches[2] = (0x00, 0xbf, 0x01, 0x4a, 0x12, 0x68, 0x02, 0xe0)
patches[3] = (0x00, 0xbf, 0x01, 0x4b, 0x1b, 0x68, 0x02, 0xe0)
patches[4] = (0x00, 0xbf, 0x01, 0x4c, 0x24, 0x68, 0x02, 0xe0)
patches[5] = (0x00, 0xbf, 0x01, 0x4d, 0x2d, 0x68, 0x02, 0xe0)
patches[8] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0x80, 0xd8, 0xf8, 0x00, 0x80, 0x01, 0xe0)
patches[9] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0x90, 0xd9, 0xf8, 0x00, 0x90, 0x01, 0xe0)
patches[10] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0xa0, 0xda, 0xf8, 0x00, 0xa0, 0x01, 0xe0)
patches[11] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0xb0, 0xdb, 0xf8, 0x00, 0xb0, 0x01, 0xe0)
ea = here()
if (get_wide_word(ea) == 0xb082 #SUB SP, SP, #8
and get_wide_word(ea + 2) == 0xb503): #PUSH {R0,R1,LR}
if get_operand_type(ea + 4, 0) == 7:
pop = get_bytes(ea + 12, 4, 0)
if pop[1] == 'xbc':
register = -1
r = get_wide_byte(ea + 12)
for i in range(8):
if r == (1 << i):
register = i
break
if register == -1:
print "Unable to detect register"
else:
address = get_wide_dword(ea + 8) + ea + 8
for b in patches[register]:
patch_byte(ea, b)
ea += 1
if ea % 4 != 0:
ea += 2
patch_dword(ea, address)
elif pop[:3] == 'x5dxf8x04':
register = ord(pop[3]) >> 4
if register in patches:
address = get_wide_dword(ea + 8) + ea + 8
for b in patches[register]:
patch_byte(ea, b)
ea += 1
patch_dword(ea, address)
else:
print "POP instruction not found"
else:
print "Wrong operand type on +4:", get_operand_type(ea + 4, 0)
else:
print "Unable to detect first instructions"
Ставимо курсор на початок конструкції, яку хочемо замінити - 0xB4B2 - і запускаємо скрипт:
Крім вже названих конструкцій у коді також трапляються такі:
Як і в попередньому випадку, після інструкції BLX йде зміщення:
Беремо зміщення на адресу з LR, додаємо його до LR і переходимо туди. 0x72044 + 0xC = 0x72050. Скрипт для цієї конструкції дуже простий:
def put_unconditional_branch(source, destination):
offset = (destination - source - 4) >> 1
if offset > 2097151 or offset < -2097152:
raise RuntimeError("Invalid offset")
if offset > 1023 or offset < -1024:
instruction1 = 0xf000 | ((offset >> 11) & 0x7ff)
instruction2 = 0xb800 | (offset & 0x7ff)
patch_word(source, instruction1)
patch_word(source + 2, instruction2)
else:
instruction = 0xe000 | (offset & 0x7ff)
patch_word(source, instruction)
ea = here()
if get_wide_word(ea) == 0xb503: #PUSH {R0,R1,LR}
ea1 = ea + 6
if get_wide_word(ea + 2) == 0xbf00: #NOP
ea1 += 2
offset = get_wide_dword(ea1)
put_unconditional_branch(ea, (ea1 + offset) & 0xffffffff)
else:
print "Unable to detect first instruction"
Результат виконання скрипту:
Після того, як у функції все пропатчено, можна вказати IDA на її справжній початок. Вона збере весь код функції шматочками, і його можна буде декомпілювати за допомогою HexRays.
Розшифровка рядків
Ми навчилися боротися з обфускацією машинного коду у бібліотеці libsgmainso-6.4.36.so з UC Browser та отримали код функції JNI_OnLoad.
int __fastcall real_JNI_OnLoad(JavaVM *vm)
{
int result; // r0
jclass clazz; // r0 MAPDST
int v4; // r0
JNIEnv *env; // r4
int v6; // [sp-40h] [bp-5Ch]
int v7; // [sp+Ch] [bp-10h]
v7 = *(_DWORD *)off_8AC00;
if ( !vm )
goto LABEL_39;
sub_7C4F4();
env = (JNIEnv *)sub_7C5B0(0);
if ( !env )
goto LABEL_39;
v4 = sub_72CCC();
sub_73634(v4);
sub_73E24(&unk_83EA6, &v6, 49);
clazz = (jclass)((int (__fastcall *)(JNIEnv *, int *))(*env)->FindClass)(env, &v6);
if ( clazz
&& (sub_9EE4(),
sub_71D68(env),
sub_E7DC(env) >= 0
&& sub_69D68(env) >= 0
&& sub_197B4(env, clazz) >= 0
&& sub_E240(env, clazz) >= 0
&& sub_B8B0(env, clazz) >= 0
&& sub_5F0F4(env, clazz) >= 0
&& sub_70640(env, clazz) >= 0
&& sub_11F3C(env) >= 0
&& sub_21C3C(env, clazz) >= 0
&& sub_2148C(env, clazz) >= 0
&& sub_210E0(env, clazz) >= 0
&& sub_41B58(env, clazz) >= 0
&& sub_27920(env, clazz) >= 0
&& sub_293E8(env, clazz) >= 0
&& sub_208F4(env, clazz) >= 0) )
{
result = (sub_B7B0(env, clazz) >> 31) | 0x10004;
}
else
{
LABEL_39:
result = -1;
}
return result;
}
Розглянемо уважніше такі рядки:
sub_73E24(&unk_83EA6, &v6, 49);
clazz = (jclass)((int (__fastcall *)(JNIEnv *, int *))(*env)->FindClass)(env, &v6);
У функції sub_73E24 явно відбувається розшифрування імені класу. Як параметри цієї функції передається покажчик на дані, схожі на зашифровані, буфер і число. Очевидно, що після виклику функції в буфері буде розшифрований рядок, тому що він передається функції FindClassяка приймає другим параметром ім'я класу. Отже, число - це розмір буфера або довжина рядка. Спробуймо розшифрувати ім'я класу, воно має вказати нам, чи в правильному напрямку ми йдемо. Розглянемо докладніше, що відбувається у sub_73E24.
int __fastcall sub_73E56(unsigned __int8 *in, unsigned __int8 *out, size_t size)
{
int v4; // r6
int v7; // r11
int v8; // r9
int v9; // r4
size_t v10; // r5
int v11; // r0
struc_1 v13; // [sp+0h] [bp-30h]
int v14; // [sp+1Ch] [bp-14h]
int v15; // [sp+20h] [bp-10h]
v4 = 0;
v15 = *(_DWORD *)off_8AC00;
v14 = 0;
v7 = sub_7AF78(17);
v8 = sub_7AF78(size);
if ( !v7 )
{
v9 = 0;
goto LABEL_12;
}
(*(void (__fastcall **)(int, const char *, int))(v7 + 12))(v7, "DcO/lcK+h?m3c*q@", 16);
if ( !v8 )
{
LABEL_9:
v4 = 0;
goto LABEL_10;
}
v4 = 0;
if ( !in )
{
LABEL_10:
v9 = 0;
goto LABEL_11;
}
v9 = 0;
if ( out )
{
memset(out, 0, size);
v10 = size - 1;
(*(void (__fastcall **)(int, unsigned __int8 *, size_t))(v8 + 12))(v8, in, v10);
memset(&v13, 0, 0x14u);
v13.field_4 = 3;
v13.field_10 = v7;
v13.field_14 = v8;
v11 = sub_6115C(&v13, &v14);
v9 = v11;
if ( v11 )
{
if ( *(_DWORD *)(v11 + 4) == v10 )
{
qmemcpy(out, *(const void **)v11, v10);
v4 = *(_DWORD *)(v9 + 4);
}
else
{
v4 = 0;
}
goto LABEL_11;
}
goto LABEL_9;
}
LABEL_11:
sub_7B148(v7);
LABEL_12:
if ( v8 )
sub_7B148(v8);
if ( v9 )
sub_7B148(v9);
return v4;
}
Функція sub_7AF78 створює екземпляр контейнера для байтових масивів зазначеного розміру (не будемо докладно зупинятись на цих контейнерах). Тут створюється два таких контейнери: в один міститься рядок "DcO/lcK+h?m3c*q@" (Неважко здогадатися, що це ключ), в іншій – зашифровані дані. Далі обидва об'єкти поміщаються в якусь структуру, яка передається функції sub_6115C. Також відзначимо у цій структурі поле зі значенням 3. Подивимося, що відбувається з цією структурою далі.
int __fastcall sub_611B4(struc_1 *a1, _DWORD *a2)
{
int v3; // lr
unsigned int v4; // r1
int v5; // r0
int v6; // r1
int result; // r0
int v8; // r0
*a2 = 820000;
if ( a1 )
{
v3 = a1->field_14;
if ( v3 )
{
v4 = a1->field_4;
if ( v4 < 0x19 )
{
switch ( v4 )
{
case 0u:
v8 = sub_6419C(a1->field_0, a1->field_10, v3);
goto LABEL_17;
case 3u:
v8 = sub_6364C(a1->field_0, a1->field_10, v3);
goto LABEL_17;
case 0x10u:
case 0x11u:
case 0x12u:
v8 = sub_612F4(
a1->field_0,
v4,
*(_QWORD *)&a1->field_8,
*(_QWORD *)&a1->field_8 >> 32,
a1->field_10,
v3,
a2);
goto LABEL_17;
case 0x14u:
v8 = sub_63A28(a1->field_0, v3);
goto LABEL_17;
case 0x15u:
sub_61A60(a1->field_0, v3, a2);
return result;
case 0x16u:
v8 = sub_62440(a1->field_14);
goto LABEL_17;
case 0x17u:
v8 = sub_6226C(a1->field_10, v3);
goto LABEL_17;
case 0x18u:
v8 = sub_63530(a1->field_14);
LABEL_17:
v6 = 0;
if ( v8 )
{
*a2 = 0;
v6 = v8;
}
return v6;
default:
LOWORD(v5) = 28032;
goto LABEL_5;
}
}
}
}
LOWORD(v5) = -27504;
LABEL_5:
HIWORD(v5) = 13;
v6 = 0;
*a2 = v5;
return v6;
}
Як параметр switch передається поле структури, якому раніше було надано значення 3. Дивимося case 3: у функцію sub_6364C передаються параметри із структури, які були складені туди у попередній функції, тобто ключ та зашифровані дані. Якщо уважно подивитися на sub_6364Cможна дізнатися в ній алгоритм RC4.
У нас є алгоритм та ключ. Спробуймо розшифрувати ім'я класу. Ось що вийшло: com/taobao/wireless/security/adapter/JNICLibrary. Чудово! Ми на правильному шляху.
Дерево команд
Тепер потрібно знайти виклик Реєстрація тубільцівякий вкаже нам на функцію doCommandNative. Переглядаємо функції, що викликаються з JNI_OnLoad, і знаходимо його в sub_B7B0:
int __fastcall sub_B7F6(JNIEnv *env, jclass clazz)
{
char signature[41]; // [sp+7h] [bp-55h]
char name[16]; // [sp+30h] [bp-2Ch]
JNINativeMethod method; // [sp+40h] [bp-1Ch]
int v8; // [sp+4Ch] [bp-10h]
v8 = *(_DWORD *)off_8AC00;
decryptString((unsigned __int8 *)&unk_83ED9, (unsigned __int8 *)name, 0x10u);// doCommandNative
decryptString((unsigned __int8 *)&unk_83EEA, (unsigned __int8 *)signature, 0x29u);// (I[Ljava/lang/Object;)Ljava/lang/Object;
method.name = name;
method.signature = signature;
method.fnPtr = sub_B69C;
return ((int (__fastcall *)(JNIEnv *, jclass, JNINativeMethod *, int))(*env)->RegisterNatives)(env, clazz, &method, 1) >> 31;
}
Тут реєструється нативний метод з ім'ям doCommandNative. Тепер ми знаємо його адресу. Подивимося, що робить.
int __fastcall doCommandNative(JNIEnv *env, jobject obj, int command, jarray args)
{
int v5; // r5
struc_2 *a5; // r6
int v9; // r1
int v11; // [sp+Ch] [bp-14h]
int v12; // [sp+10h] [bp-10h]
v5 = 0;
v12 = *(_DWORD *)off_8AC00;
v11 = 0;
a5 = (struc_2 *)malloc(0x14u);
if ( a5 )
{
a5->field_0 = 0;
a5->field_4 = 0;
a5->field_8 = 0;
a5->field_C = 0;
v9 = command % 10000 / 100;
a5->field_0 = command / 10000;
a5->field_4 = v9;
a5->field_8 = command % 100;
a5->field_C = env;
a5->field_10 = args;
v5 = sub_9D60(command / 10000, v9, command % 100, 1, (int)a5, &v11);
}
free(a5);
if ( !v5 && v11 )
sub_7CF34(env, v11, &byte_83ED7);
return v5;
}
За назвою можна здогадатися, що тут знаходиться точка входу всіх функцій, які розробники вирішили перенести до бібліотеки. Нас цікавить функція із номером 10601.
За кодом можна побачити, що з номера команди виходить три числа: command / 10000, команда % 10000 / 100 и command % 10, Т. е., у разі, 1, 6 і 1. Ці три числа, і навіть покажчик на JNIEnv і аргументи, передані функції, складаються структуру і передаються далі. За допомогою отриманих трьох чисел (позначимо їх N1, N2 та N3) будується дерево команд.
Приблизно таке:
Дерево заповнюється динамічно в JNI_OnLoad.
Три числа кодують шлях у дереві. Кожен лист дерева містить поксорену адресу відповідної функції. Ключ - у батьківському вузлі. Знайти місце в коді, де в дерево додається потрібна нам функція, не складно, якщо розібратися у всіх використовуваних структурах (їх опис не наводимо, щоб не роздмухувати і без того немаленьку статтю).
Ще обфускація
Ми отримали адресу функції, яка має розшифровувати трафік: 0x5F1AC. Але радіти поки що рано: розробники UC Browser приготували для нас ще один сюрприз.
Після отримання параметрів із масиву, який був сформований у Java-коді, потрапляємо
у функцію на адресу 0x4D070. І тут на нас чекає ще один вид обфускації коду.
Кладемо в R7 і R4 два індекси:
Перекладаємо перший індекс у R11:
Щоб отримати адресу з таблиці, використовуємо індекс:
Після переходу на першу адресу використовується другий індекс, який у R4. У таблиці 230 елементів.
Що з цим робити? Можна сказати IDA, що це такий switch: Edit -> Other -> Specify switch idiom.
Код страшний. Але, пробираючись через його нетрі, можна побачити виклик вже знайомої нам функції sub_6115C:
Там був switch, у якому в case 3 знаходилася розшифровка з використанням алгоритму RC4. А в цьому випадку структура, що передається в функцію, заповнюється з параметрів, переданих у doCommandNative. Згадуємо, що у нас там був magicInt зі значенням 16. Дивимося відповідний case – і після кількох переходів знаходимо код, яким можна впізнати алгоритм.
Це AES!
Алгоритм є, залишилося отримати його параметри: режим, ключ та, можливо, вектор ініціалізації (його наявність залежить від режиму роботи алгоритму AES). Структура з ними має формуватися десь перед викликом функції sub_6115C, але ця частина коду обфусцирована особливо добре, тому виникає ідея пропатчити код, щоб усі параметри розшифровки функції дампилися у файл.
патч
Щоб не писати весь код патча на мові асемблера вручну, можна запустити Android Studio, написати там функцію, яка отримує на вхід такі самі параметри, як у нашої функції розшифровки, і пише у файл, після чого скопіпастить код, який згенерує компілятор.
Про зручність додавання коду наші друзі з команди UC Browser теж подбали. Згадуємо, що на початку кожної функції ми маємо сміттєвий код, який легко можна замінити на будь-який інший. Дуже зручно 🙂 Правда, на початку цільової функції місця для коду, який зберігає всі параметри у файл, обмаль. Довелося розділити його на частини та використовувати сміттєві блоки сусідніх функцій. Усього вийшло чотири частини.
Перша частина:
В архітектурі ARM перші чотири параметри функції передаються через регістри R0-R3, інші, якщо вони є через стек. У регістрі LR передається адреса повернення. Все це потрібно зберегти для того, щоб функція могла відпрацювати після того, як ми стискаємо її параметри. Також потрібно зберегти всі регістри, які ми використовуватимемо в процесі, тому робимо PUSH.W {R0-R10,LR}. У R7 ми отримуємо адресу списку параметрів, переданих функції через стек.
За допомогою функції fopen відкриємо файл /data/local/tmp/aes в режимі "ab",
тобто на додавання. У R0 завантажуємо адресу імені файлу, у R1 - адресу рядка із зазначенням режиму. І тут сміттєвий код закінчується, тому переходимо до наступної функції. Щоб вона продовжувала працювати, ставимо спочатку перехід на справжній код функції в обхід сміття, а замість сміття додаємо продовження патча.
Викликаємо fopen.
Перші три параметри функції АЕС мають тип Int. Так як ми на початку зберегли регістри у стек, можна просто передати функції fwrite їх адреси у стеку.
Далі у нас є три структури, які містять розмір даних та покажчик на дані для ключа, вектора ініціалізації та зашифрованих даних.
Наприкінці закриваємо файл, відновлюємо регістри та передаємо керування справжньою функцією АЕС.
Збираємо APK з пропатченою бібліотекою, підписуємо, закидаємо на пристрій/емулятор, запускаємо. Бачимо, що наш дамп створюється і туди пишеться багато даних. Браузер використовує шифрування не тільки для трафіку, і все шифрування йде через цю функцію. А потрібних даних чомусь немає, і у трафіку не видно потрібного запиту. Щоб не чекати, поки UC Browser зробить потрібний запит, візьмемо зашифровану відповідь від сервера, отриманий раніше, і пропатчимо додаток ще раз: додамо розшифровку в onCreate головної активіті.
const/16 v1, 0x62
new-array v1, v1, [B
fill-array-data v1, :encrypted_data
const/16 v0, 0x1f
invoke-static {v0, v1}, Lcom/uc/browser/core/d/c/g;->j(I[B)[B
move-result-object v1
array-length v2, v1
invoke-static {v2}, Ljava/lang/String;->valueOf(I)Ljava/lang/String;
move-result-object v2
const-string v0, "ololo"
invoke-static {v0, v2}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
Збираємо, підписуємо, встановлюємо, запускаємо. Отримуємо NullPointerException, оскільки метод повернув null.
У ході подальшого аналізу коду було виявлено функцію, в якій розшифровуються цікаві рядки: «META-INF/» та «.RSA». Схоже, програма перевіряє свій сертифікат. Або навіть генерує ключі із нього. Розбиратися з тим, що відбувається з сертифікатом, зовсім не хочеться, тому просто підсунемо йому правильний сертифікат. Пропатчимо зашифрований рядок таким чином, щоб замість «META-INF/» вийшло «BLABLINF/», створимо папку з таким ім'ям в APK і підкинемо туди сертифікат білкобраузера.
Збираємо, підписуємо, встановлюємо, запускаємо. Бінґо! Ключ у нас!
MitM
Ми отримали ключ і вектор ініціалізації, що дорівнює ключу. Спробуймо розшифрувати відповідь сервера в режимі CBC.
Бачимо URL архіву, щось схоже на MD5, extract_unzipsize і число. Перевіряємо: MD5 архіву збігається, розмір розпакованої бібліотеки збігається. Пробуємо пропатчити цю бібліотеку та віддати її браузеру. Щоб показати, що наша пропатчена бібліотека завантажилася, запускатимемо Intent на створення SMS з текстом «PWNED!». Підміняти будемо дві відповіді від сервера:
Браузер кілька разів намагається завантажити архів, після чого видає помилку. Мабуть, щось
йому не подобається. В результаті аналізу цього каламутного формату з'ясувалося, що сервер передає ще розмір архіву:
Він закодований у LEB128. Після патчу розмір архіву з бібліотекою трохи змінився, тому браузер вважав, що архів завантажився криво, і після кількох спроб видав помилку.
Правимо розмір архіву… І – перемога! 🙂 Результат на відео.
Наслідки та реакція розробника
Так само хакери могли б використовувати небезпечну функцію UC Browser, щоб поширювати і запускати шкідливі бібліотеки. Ці бібліотеки працюватимуть у контексті браузера, тому отримають усі його системні дозволи. Як наслідок — можливість показувати фішингові вікна, а також доступ до робочих файлів помаранчевої китайської білки, включаючи логіни, паролі та куки, що зберігаються в базі даних.
Ми зв'язувалися з розробниками UC Browser і повідомляли їм про знайдену проблему, намагалися вказати на вразливість та її небезпеку, але обговорювати щось із нами вони не стали. А тим часом браузер продовжував хизуватися з небезпечною функцією у всіх на очах. Але як тільки ми розкрили деталі вразливості, ігнорувати це, як раніше, вже не можна було. 27 березня була
випущено нову версію UC Browser 12.10.9.1193, яка зверталася до сервера за HTTPS:
Крім того, після виправлення і до моменту написання статті спроба відкрити в браузері PDF призводила до появи повідомлення про помилку з текстом Упс, щось пішло не так!. Запит до сервера при спробі відкрити PDF не виконувався, але виконувався запит при запуску браузера, що натякає на можливість завантажувати виконуваний код в порушення правил Google Play.
Джерело: habr.com