Ищем уязвимости в UC Browser

Ищем уязвимости в UC Browser

Введение

В конце марта мы сообщали, что обнаружили скрытую возможность загрузки и запуска непроверенного кода в UC Browser. Сегодня разберём подробно, как эта загрузка происходит и как хакеры могут использовать её в своих целях.

Некоторое время назад UC Browser рекламировали и распространяли очень агрессивно: его устанавливали на устройства пользователей с помощью вредоносных программ, распространяли с различных сайтов под видом видеофайлов (т. е. пользователи думали, что качают, например, порноролик, а получали вместо него APK с этим браузером), использовали пугающие баннеры с сообщениями о том, что браузер устарел, уязвим и всё в таком духе. В официальной группе UC Browser в VK есть тема, в которой пользователи могут пожаловаться на недобросовестную рекламу, там много примеров. В 2016 году была даже видеореклама на русском языке (да, реклама браузера, блокирующего рекламу).

На момент написания статьи у UC Browser набралось более 500 000 000 установок в Google Play. Это впечатляет — больше только у Google Chrome. Среди отзывов можно увидеть достаточно много жалоб на рекламу и редиректы на какие-то приложения в Google Play. Это и стало поводом к исследованию: мы решили посмотреть, не делает ли UC Browser что-то нехорошее. И оказалось, что таки делает!

В коде приложения обнаружилась возможность загрузки и запуска исполняемого кода, что противоречит правилам публикации приложений в Google Play. Помимо того, что UC Browser загружает исполняемый код, он делает это небезопасно, что можно использовать для проведения MitM-атаки. Посмотрим, получится ли у нас такую атаку провести.

Всё, что написано далее, актуально для версии 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-запрос к puds.ucweb.com/upgrade/index.xhtml, который можно заметить в трафике через некоторое время после старта. В ответ он может получить команду на загрузку какого-либо обновления или нового модуля. В процессе анализа сервер таких команд не давал, но мы заметили, что при попытке открыть в браузере PDF тот делает повторный запрос по указанному выше адресу, после чего скачивает нативную библиотеку. Для проведения атаки мы решили использовать эту особенность UC Browser: способность открывать PDF с помощью нативной библиотеки, которой нет в APK и которую он при необходимости подгружает из Интернета. Стоит отметить, что теоретически UC Browser можно заставить что-то скачать и без взаимодействия с пользователем – если отдать правильно сформированный ответ на запрос, который выполняется после запуска браузера. Но для этого нужно более детально изучать протокол взаимодействия с сервером, поэтому мы решили, что проще отредактировать перехваченный ответ и подменить библиотеку для работы с PDF.

Итак, когда пользователь хочет открыть PDF прямо в браузере, в трафике можно увидеть следующие запросы:

Ищем уязвимости в UC Browser

Сначала идёт POST-запрос к puds.ucweb.com/upgrade/index.xhtml, после чего
скачивается архив с библиотекой для просмотра PDF и офисных форматов. Логично предположить, что в первом запросе передаётся информация о системе (как минимум, архитектура, чтобы отдать нужную библиотеку), а в ответ на него браузер получает некую информацию о библиотеке, которую нужно скачать: адрес и, возможно, что-то ещё. Проблема в том, что запрос этот зашифрован.

Фрагмент запроса

Фрагмент ответа

Ищем уязвимости в UC Browser

Ищем уязвимости в UC Browser

Сама библиотека упакована в ZIP и не зашифрована.

Ищем уязвимости в UC Browser

Поиск кода расшифровки трафика

Попробуем расшифровать ответ сервера. Смотрим код класса com.uc.deployment.UpgradeDeployService: из метода onStartCommand переходим в com.uc.deployment.b.x, а из него в com.uc.browser.core.d.c.f.e:

    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», например), тут должен вызываться метод, который расшифрует ответ сервера.
Переходим к методу g.j. Заметим, что в качестве первого аргумента в него передаётся байт по смещению 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) – невалидная. Это сделано специально, чтобы усложнить анализ.

Ищем уязвимости в UC Browser

Но она и не нужна: чтобы корректно загрузить ELF-файл и проанализировать его, вполне достаточно таблицы сегментов (program header table). Поэтому просто удаляем таблицу секций, зануляя соответствующие поля в заголовке.

Ищем уязвимости в UC Browser

Снова открываем файл в IDA.

Есть два способа сообщить виртуальной Java-машине, где именно в нативной библиотеке находится реализация метода, объявленного в Java-коде как native. Первый — дать ему имя вида Java_имя_пакета_ИмяКласса_имяМетода.

Второй — зарегистрировать его при загрузке библиотеки (в функции JNI_OnLoad)
с помощью вызова функции RegisterNatives.

В нашем случае, если использовать первый способ, имя должно быть таким: Java_com_taobao_wireless_security_adapter_JNICLibrary_doCommandNative.

Среди экспортируемых функций такой нет, значит, нужно искать вызов RegisterNatives.
Идём в функцию JNI_OnLoad и видим такую картину:

Ищем уязвимости в UC Browser

Что тут происходит? На первый взгляд, начало и конец функции типичны для архитектуры ARM. Первой инструкцией в стек сохраняется содержимое регистров, которые функция будет использовать в своей работе (в данном случае R0, R1 и R2), а также содержимое регистра LR, в котором находится адрес возврата из функции. Последней инструкцией сохранённые регистры восстанавливаются, причём адрес возврата сразу помещается в регистр PC — таким образом происходит возврат из функции. Но если присмотреться, можно заметить, что предпоследняя инструкция изменяет адрес возврата, сохранённый в стеке. Вычислим, каким он будет после
выполнения кода. В R1 загружается некий адрес 0xB130, из него вычитается 5, затем он перекладывается в R0 и к нему прибавляется 0x10. Получается 0xB13B. Таким образом, IDA думает, что в последней инструкции происходит обычный возврат из функции, а на самом деле происходит переход по вычисленному адресу 0xB13B.

Тут стоит напомнить, что у процессоров ARM есть два режима и два набора инструкций: ARM и Thumb. Младший бит адреса говорит процессору, какой набор инструкций используется. Т. е. адрес на самом деле 0xB13A, а единица в младшем бите обозначает режим Thumb.

В начало каждой функции в этой библиотеке добавлен подобный «переходник» и
мусорный код. Далее не будем подробно на них останавливаться – просто помним,
что настоящее начало почти всех функций находится чуть дальше.

Так как в коде нет явного перехода на 0xB13A, IDA сама не опознала, что в этом месте находится код. По этой же причине большую часть кода в библиотеке она не опознаёт как код, что несколько затрудняет анализ. Говорим IDA, что тут код, и вот что получается:

Ищем уязвимости в UC Browser

На 0xB144 явно начинается таблица. А что в sub_494C?

Ищем уязвимости в UC Browser

При вызове этой функции в регистре LR получим адрес упомянутой ранее таблицы (0xB144). В R0 — индекс в этой таблице. Т. е. берётся значение из таблицы, прибавляется к LR и получается
адрес, по которому нужно перейти. Попробуем его вычислить: 0xB144 + [0xB144 + 8* 4] = 0xB144 + 0x120 = 0xB264. Переходим по полученному адресу и видим буквально пару полезных инструкций и опять переход на 0xB140:

Ищем уязвимости в UC Browser

Теперь будет переход по смещению с индексом 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:

Ищем уязвимости в UC Browser

IDA опять не опознала этот участок как код. Помогаем ей и видим там другую конструкцию:

Ищем уязвимости в UC Browser

Инструкции после BLX выглядят не очень осмысленными, это больше похоже на какое-то смещение. Смотрим в sub_4964:

Ищем уязвимости в UC Browser

И действительно, тут берётся dword по адресу, лежащему в LR, прибавляется к этому адресу, после чего берётся значение по полученному адресу и кладётся в стек. Также к LR прибавляется 4, чтобы после возврата из функции перескочить это самое смещение. После чего команда POP {R1} достаёт полученное значение из стека. Если посмотреть, что находится по адресу 0xB4BA + 0xEA = 0xB5A4, то можно увидеть нечто похожее на таблицу адресов:

Ищем уязвимости в UC Browser

Чтобы пропатчить эту конструкцию, потребуется получить два параметра из кода: смещение и номер регистра, в который нужно положить результат. Для каждого возможного регистра придётся заранее подготовить кусочек кода.

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 — и запускаем скрипт:

Ищем уязвимости в UC Browser

Помимо уже названных конструкций в коде также попадаются вот такие:

Ищем уязвимости в UC Browser

Как и в предыдущем случае, после инструкции BLX идёт смещение:

Ищем уязвимости в UC Browser

Берём смещение по адресу из 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"

Результат выполнения скрипта:

Ищем уязвимости в UC Browser

После того как в функции всё пропатчено, можно указать 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. Отлично! Мы на правильном пути.

Дерево команд

Теперь нужно найти вызов RegisterNatives, который укажет нам на функцию 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, command % 10000 / 100 и command % 10, т. е., в нашем случае, 1, 6 и 1. Эти три числа, а также указатель на JNIEnv и аргументы, переданные функции, складываются в структуру и передаются дальше. С помощью полученных трёх чисел (обозначим их N1, N2 и N3) строится дерево команд.

Примерно такое:

Ищем уязвимости в UC Browser

Дерево заполняется динамически в JNI_OnLoad.
Три числа кодируют путь в дереве. Каждый лист дерева содержит поксоренный адрес соответствующей функции. Ключ — в родительском узле. Найти место в коде, где в дерево добавляется нужная нам функция, не составляет большого труда, если разобраться во всех используемых структурах (их описание не приводим, чтобы не раздувать и без того немаленькую статью).

Ещё обфускация

Мы получили адрес функции, которая должна расшифровывать трафик: 0x5F1AC. Но радоваться пока рано: разработчики UC Browser приготовили для нас ещё один сюрприз.

После получения параметров из массива, который был сформирован в Java-коде, попадаем
в функцию по адресу 0x4D070. И тут нас ждёт ещё один вид обфускации кода.

Кладём в R7 и R4 два индекса:

Ищем уязвимости в UC Browser

Перекладываем первый индекс в R11:

Ищем уязвимости в UC Browser

Чтобы получить адрес из таблицы, используем индекс:

Ищем уязвимости в UC Browser

После перехода по первому адресу используется второй индекс, который в R4. В таблице 230 элементов.

Что с этим делать? Можно сказать IDA, что это такой switch: Edit -> Other -> Specify switch idiom.

Ищем уязвимости в UC Browser

Получившийся код страшен. Но, пробираясь через его дебри, можно заметить вызов уже знакомой нам функции sub_6115C:

Ищем уязвимости в UC Browser

Там был switch, в котором в case 3 находилась расшифровка с использованием алгоритма RC4. А в этом случае передаваемая в функцию структура заполняется из параметров, переданных в doCommandNative. Вспоминаем, что у нас там был magicInt со значением 16. Смотрим соответствующий case – и после нескольких переходов находим код, по которому можно опознать алгоритм.

Ищем уязвимости в UC Browser

Это AES!

Алгоритм есть, осталось получить его параметры: режим, ключ и, возможно, вектор инициализации (его наличие зависит от режима работы алгоритма AES). Структура с ними должна формироваться где-то перед вызовом функции sub_6115C, но эта часть кода обфусцирована особенно хорошо, поэтому возникает идея пропатчить код, чтобы все параметры функции расшифровки дампились в файл.

Патч

Чтобы не писать весь код патча на языке ассемблера вручную, можно запустить Android Studio, написать там функцию, которая получает на вход такие же параметры, как у нашей функции расшифровки, и пишет в файл, после чего скопипастить код, который сгенерирует компилятор.

Об удобстве добавления кода наши друзья из команды UC Browser тоже «позаботились». Вспоминаем, что в начале каждой функции у нас мусорный код, который легко можно заменить на любой другой. Очень удобно 🙂 Правда, в начале целевой функции места для кода, который сохраняет все параметры в файл, маловато. Пришлось разделить его на части и использовать мусорные блоки соседних функций. Всего получилось четыре части.

Первая часть:

Ищем уязвимости в UC Browser

В архитектуре ARM первые четыре параметра функции передаются через регистры R0-R3, остальные, если они есть — через стек. В регистре LR передаётся адрес возврата. Всё это нужно сохранить для того, чтобы функция могла отработать после того, как мы сдампим её параметры. Также нужно сохранить все регистры, которые мы будем использовать в процессе, поэтому делаем PUSH.W {R0-R10,LR}. В R7 у нас получается адрес списка параметров, переданных функции через стек.

С помощью функции fopen откроем файл /data/local/tmp/aes в режиме «ab»,
т. е. на добавление. В R0 загружаем адрес имени файла, в R1 — адрес строки с указанием режима. И тут мусорный код заканчивается, поэтому переходим в следующую функцию. Чтобы она продолжала работать, ставим в начало переход на настоящий код функции в обход мусора, а вместо мусора добавляем продолжение патча.

Ищем уязвимости в UC Browser

Вызываем fopen.

Первые три параметра функции aes имеют тип int. Так как мы в начале сохранили регистры в стек, можно просто передать функции fwrite их адреса в стеке.

Ищем уязвимости в UC Browser

Дальше у нас есть три структуры, которые содержат размер данных и указатель на данные для ключа, вектора инициализации и зашифрованных данных.

Ищем уязвимости в UC Browser

В конце закрываем файл, восстанавливаем регистры и передаём управление настоящей функции aes.

Собираем 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.

Ищем уязвимости в UC Browser

Видим URL архива, что-то похожее на MD5, «extract_unzipsize» и число. Проверяем: MD5 архива совпадает, размер распакованной библиотеки совпадает. Пробуем пропатчить эту библиотеку и отдать её браузеру. Чтобы показать, что наша пропатченная библиотека загрузилась, будем запускать Intent на создание СМС с текстом «PWNED!». Подменять будем два ответа от сервера: puds.ucweb.com/upgrade/index.xhtml и на скачивание архива. В первом подменяем MD5 (размер после распаковки не меняется), во втором отдаём архив с пропатченной библиотекой.

Браузер несколько раз пытается скачать архив, после чего выдаёт ошибку. Видимо, что-то
ему не нравится. В результате анализа этого мутного формата выяснилось, что сервер передаёт ещё размер архива:

Ищем уязвимости в UC Browser

Он закодирован в LEB128. После патча размер архива с библиотекой немного изменился, поэтому браузер посчитал, что архив скачался криво, и после нескольких попыток выдал ошибку.

Правим размер архива… И – победа! 🙂 Результат на видео.

https://www.youtube.com/watch?v=Nfns7uH03J8

Последствия и реакция разработчика

Точно таким же образом хакеры могли бы использовать небезопасную функцию UC Browser, чтобы распространять и запускать вредоносные библиотеки. Эти библиотеки будут работать в контексте браузера, поэтому получат все его системные разрешения. Как следствие — возможность показывать фишинговые окна, а также доступ к рабочим файлам оранжевой китайской белки, включая хранящиеся в базе данных логины, пароли и куки.

Мы связывались с разработчиками UC Browser и сообщали им о найденной проблеме, пытались указать на уязвимость и её опасность, но обсуждать что-либо с нами они не стали. А тем временем браузер продолжал щеголять с опасной функцией у всех на виду. Но как только мы раскрыли детали уязвимости, игнорировать это, как раньше, уже было нельзя. 27 марта была
выпущена новая версия UC Browser 12.10.9.1193, которая обращалась к серверу по HTTPS: puds.ucweb.com/upgrade/index.xhtml.

Кроме того, после «исправления» и до момента написания статьи попытка открыть в браузере PDF приводила к появлению сообщения об ошибке с текстом «Упс, что-то пошло не так!». Запрос к серверу при попытке открыть PDF не выполнялся, но выполнялся запрос при запуске браузера, что намекает на сохранившуюся возможность загружать исполняемый код в нарушение правил Google Play.

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

Добавить комментарий