
Introducción
A finales de marzo, encontramos una oportunidad oculta para cargar y ejecutar código no verificado en UC Browser. Hoy analizaremos en detalle cómo se realiza esta carga y cómo los hackers pueden utilizarla a su favor. Hace algún tiempo, UC Browser fue promocionado y distribuido de manera muy agresiva: se instalaba en los dispositivos de los usuarios mediante malware, se distribuía desde varios sitios haciéndose pasar por archivos de video (es decir, los usuarios pensaban que estaban descargando, por ejemplo, un video para adultos, pero en su lugar recibían un APK con este navegador), se utilizaban banners alarmantes con mensajes sobre que el navegador estaba desactualizado, era vulnerable y cosas por el estilo. En el grupo oficial de UC Browser en VK hay
un tema, en el que los usuarios pueden quejarse sobre publicidad engañosa; hay muchos ejemplos. En 2016, incluso hubo en ruso (sí, publicidad del navegador que bloquea anuncios). En el código de la aplicación se descubrió la posibilidad de cargar y ejecutar código ejecutable,
lo cual contradice las normas de publicación de aplicaciones
en Google Play. Además, UC Browser carga código ejecutable de manera insegura, lo que podría ser utilizado para llevar a cabo un ataque MitM. Veamos si podemos realizar tal ataque. package: com.UCMobile.intl versionName: 12.10.8.1172 versionCode: 10598 sha1 del archivo APK: f5edb2243413c777172f6362876041eb0c3a928c
En el manifiesto de UC Browser se puede encontrar un servicio con un nombre elocuente
com.uc.deployment.UpgradeDeployServiceVector de ataque
En el manifiesto de UC Browser se puede encontrar un servicio con un nombre muy elocuente Al iniciar este servicio, el navegador realiza una solicitud POST a.
puds.ucweb.com/upgrade/index.xhtml
Al iniciar este servicio, el navegador realiza una solicitud POST a , que se puede notar en el tráfico después de un tiempo tras el inicio. En respuesta, puede recibir un comando para cargar alguna actualización o nuevo módulo. Durante el análisis, el servidor no emitió tales comandos, pero notamos que al intentar abrir un PDF en el navegador, este hace una nueva solicitud a la dirección indicada anteriormente, después de lo cual descarga una biblioteca nativa. Para llevar a cabo el ataque, decidimos aprovechar esta característica de UC Browser: la capacidad de abrir PDFs utilizando una biblioteca nativa que no está en el APK y que carga desde Internet cuando es necesario. Cabe destacar que teóricamente se puede obligar a UC Browser a descargar algo sin la interacción del usuario: si se proporciona una respuesta correctamente formada a la solicitud que se realiza tras iniciar el navegador. Pero para esto es necesario estudiar más a fondo el protocolo de interacción con el servidor, por lo que decidimos que era más sencillo editar la respuesta interceptada y sustituir la biblioteca para trabajar con PDFs.
Así que, cuando el usuario quiere abrir un PDF directamente en el navegador, se pueden ver las siguientes solicitudes en el tráfico:

Primero se envía una solicitud POST a , después de la cual
se descarga un archivo comprimido con la biblioteca para ver PDFs y formatos de oficina. Es lógico suponer que en la primera solicitud se envía información sobre el sistema (como mínimo, la arquitectura, para proporcionar la biblioteca adecuada), y en respuesta a esto, el navegador recibe cierta información sobre la biblioteca que necesita descargar: la dirección y, posiblemente, algo más. El problema es que esta solicitud está cifrada.
Fragmento de la solicitud
Fragmento de la respuesta


La propia biblioteca está empaquetada en un ZIP y no está cifrada.

Búsqueda del código para descifrar el tráfico
Intentemos descifrar la respuesta del servidor. Miramos el código de la clase Al iniciar este servicio, el navegador realiza una solicitud POST a: del método onStartCommand pasamos a com.uc.deployment.b.x, y desde ahí a 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("]producto:");
v4.append(arg9.iGX.ipR);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]versión:");
v4.append(arg9.iGX.iEn);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]tipo_de_actualización:");
v4.append(arg9.iGX.mMode);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]fuerza_flag:");
v4.append(arg9.iGX.iEo);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]modo_silencioso:");
v4.append(arg9.iGX.iDQ);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]tipo_silencioso:");
v4.append(arg9.iGX.iEr);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]estado_silencioso:");
v4.append(arg9.iGX.iEp);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]archivo_silencioso:");
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("]tipo_de_descarga:");
v4.append(arg9.mDownloadType);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]grupo_de_descarga:");
v4.append(arg9.mDownloadGroup);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]ruta_de_descarga:");
v4.append(arg9.iGH);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]versión_hijo_apolo:");
v4.append(arg9.iGX.iEx);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]serie_apolo:");
v4.append(arg9.iGX.iEw);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]arquitectura_cpu_apolo:");
v4.append(arg9.iGX.iEt);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]cpu_vfp3_apolo:");
v4.append(arg9.iGX.iEv);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]cpu_vfp_apolo:");
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("]nombre_componente:");
v5.append(((au)v4_1).getName());
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]nombre_ver_componente:");
v5.append(((au)v4_1).aDA());
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]código_ver_componente:");
v5.append(((au)v4_1).gBl);
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]tipo_req_componente:");
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("arquitectura_procesador", 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("tipo_net", String.valueOf(com.uc.base.system.a.Jo())));
v3_2.add(g.fs("host_origen", arg9.iGX.iEm));
v3_2.add(g.fs("versión_plugin", arg9.iGX.iEn));
v3_2.add(g.fs("lang_objetivo", arg9.iGX.iEs));
v3_2.add(g.fs("arquitectura_cpu_vitamio", arg9.iGX.iEt));
v3_2.add(g.fs("vfp_vitamio", arg9.iGX.iEu));
v3_2.add(g.fs("vfp3_vitamio", arg9.iGX.iEv));
v3_2.add(g.fs("versión_hijo_plugin", arg9.iGX.iEx));
v3_2.add(g.fs("versión_serie", arg9.iGX.iEw));
v3_2.add(g.fs("versión_hijo", r.aVw()));
v3_2.add(g.fs("cur_version_md5", arg9.iGX.iEl));
v3_2.add(g.fs("cur_version_signature", SystemHelper.getUCMSignature()));
v3_2.add(g.fs("registro_actualización", i.bjt()));
v3_2.add(g.fs("instalación_silenciosa", String.valueOf(arg9.iGX.iDQ)));
v3_2.add(g.fs("estado_silencioso", String.valueOf(arg9.iGX.iEp)));
v3_2.add(g.fs("archivo_silencioso", arg9.iGX.iEq));
v3_2.add(g.fs("tipo_silencioso", String.valueOf(arg9.iGX.iEr)));
v3_2.add(g.fs("arquitectura_cpu", com.uc.b.a.a.c.Pc()));
v3_2.add(g.fs("conjunto_cpu", 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("nucleos_cpu", 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("nivel_api", String.valueOf(Build$VERSION.SDK_INT)));
v3_2.add(g.fs("lista_apk_uc", 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);
}Aquí vemos la formación de la solicitud POST. Prestamos atención a la creación de un array de 16 bytes y su llenado: 0x5F, 0, 0x1F, -50 (=0xCE). Coincide con lo que vimos en la solicitud anterior.
En esta misma clase, se puede notar una clase anidada que contiene otro método interesante:
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");
}
} El método recibe un array de bytes y verifica que el byte cero sea igual a 0x60 o que el byte tres sea igual a 0xD0, y que el byte dos sea 1, 11 o 0x1F. Observamos la respuesta del servidor: el byte cero es 0x60, el segundo es 0x1F, el tercero es 0x60. Parece que es lo que necesitamos. Según las líneas ("up_decrypt", por ejemplo), debería llamarse un método que descifre la respuesta del servidor.
Pasemos al método g.j. Notamos que como primer argumento se pasa el byte en la posición 2 (o sea, 0x1F en nuestro caso), y como segundo, la respuesta del servidor sin
los primeros 16 bytes.
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;
} Es evidente que aquí se elige el algoritmo de descifrado, y ese mismo byte que en nuestro
En el caso de que sea 0x1F, representa una de las tres posibles variaciones.
Continuamos analizando el código. Después de un par de saltos, llegamos a un método con un nombre muy ilustrativo. decryptBytesByKey.
Aquí, de nuestra respuesta, se separan otros dos bytes, y de ellos se obtiene una cadena. Está claro que de esta manera se selecciona la clave para descifrar el mensaje.
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 bytes
System.arraycopy(bytes, 0, prefix, 0, prefix.length);
String keyId = c.ayR().d(ByteBuffer.wrap(prefix).getShort()); // Selección de la clave
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;
}Avanzando, notamos que en esta etapa no se obtiene aún la clave, sino solo su "identificador". Obtener la clave es un poco más complicado.
En el siguiente método, se agregan dos parámetros más a los que ya existen, resultando en cuatro: el número mágico 16, el identificador de la clave, los datos cifrados y una cadena incomprensible (en nuestro caso, vacía).
public final byte[] l(String keyId, byte[] encrypted) throws SecException {
return this.ayJ().staticBinarySafeDecryptNoB64(16, keyId, encrypted, "");
}Después de una serie de transiciones, llegamos al método staticBinarySafeDecryptNoB64 de la interfaz com.alibaba.wireless.security.open.staticdataencrypt.IStaticDataEncryptComponent. En el código principal de la aplicación no hay clases que implementen esta interfaz. Tal clase se encuentra en el archivo lib/armeabi-v7a/libsgmain.so, que en realidad no es un .so, sino un .jar. El método que nos interesa se implementa de la siguiente manera:
paquete com.alibaba.wireless.security.a.i;
// ...
pública clase a implementa IStaticDataEncryptComponent {
privada ISecurityGuardPlugin a;
// ...
privado byte[] a(int modo, int magicInt, int xzInt, String keyId, byte[] encrypted, String magicString) {
return this.a.getRouter().doCommand(10601, new Object[]{Integer.valueOf(modo), Integer.valueOf(magicInt), Integer.valueOf(xzInt), keyId, encrypted, magicString});
}
// ...
privado byte[] b(int magicInt, String keyId, byte[] encrypted, String magicString) {
return this.a(2, magicInt, 0, keyId, encrypted, magicString);
}
// ...
pública byte[] staticBinarySafeDecryptNoB64(int magicInt, String keyId, byte[] encrypted, String magicString) lanza SecException {
if(keyId != null && keyId.length() > 0 && magicInt >= 0 && magicInt 0) {
return this.b(magicInt, keyId, encrypted, magicString);
}
throw new SecException("", 301);
}
//...
} Aquí nuestra lista de parámetros se complementa con dos números enteros más: 2 y 0. Según parece,
2 significa descifrado, como en el método doFinal de la clase del sistema javax.crypto.Cipher. Y todo esto se transmite a un Router con el número 10601, que parece ser el número del comando.
Después de otra cadena de transiciones, encontramos la clase que implementa la interfaz IRouterComponent y un método doCommand:
paquete com.alibaba.wireless.security.mainplugin;
import com.alibaba.wireless.security.framework.IRouterComponent;
import com.taobao.wireless.security.adapter.JNICLibrary;
pública clase a implementa IRouterComponent {
pública a() {
super();
}
pública Object doCommand(int arg2, Object[] arg3) {
return JNICLibrary.doCommandNative(arg2, arg3);
}
}Y también la clase JNICLibrary, donde se declara el método nativo doCommandNative:
paquete com.taobao.wireless.security.adapter;
pública clase JNICLibrary {
pública estática nativa Object doCommandNative(int arg0, Object[] arg1);
}Así que necesitamos encontrar el método en el código nativo. doCommandNative. Y aquí comienza la diversión.
Ofuscación de código máquina
En el archivo libsgmain.so (que en realidad es .jar y en el que encontramos anteriormente la implementación de algunas interfaces relacionadas con el cifrado) hay una biblioteca nativa: libsgmainso-6.4.36.so. La abrimos en IDA y obtenemos un montón de ventanas de diálogo con errores. El problema es que la tabla de secciones (section header table) es inválida. Esto se hace intencionadamente para dificultar el análisis.

Pero no la necesitamos: para cargar y analizar correctamente un archivo ELF, es suficiente con la tabla de segmentos (program header table). Por lo tanto, simplemente eliminamos la tabla de secciones, poniendo a cero los campos correspondientes en el encabezado.

Abrimos nuevamente el archivo en IDA.
Hay dos formas de informar a la máquina virtual de Java dónde se encuentra exactamente en la biblioteca nativa la implementación del método declarado en el código Java como nativo. La primera es darle un nombre del tipo Java_nombre_paquete_NombreClase_nombreMétodo.
El segundo es registrarlo al cargar la biblioteca (en la función JNI_OnLoad)
con una llamada a la función RegisterNatives.
En nuestro caso, si utilizamos el primer método, el nombre debe ser así: Java_com_taobao_wireless_security_adapter_JNICLibrary_doCommandNative.
Entre las funciones exportadas no hay ninguna así, lo que significa que hay que buscar la llamada RegisterNatives.
Vamos a la función JNI_OnLoad y vemos esta imagen:

¿Qué está pasando aquí? A primera vista, el inicio y el final de la función son típicos para la arquitectura ARM. La primera instrucción guarda en la pila el contenido de los registros que la función usará en su trabajo (en este caso R0, R1 y R2), así como el contenido del registro LR, que contiene la dirección de retorno de la función. La última instrucción restaura los registros guardados, y la dirección de retorno se coloca inmediatamente en el registro PC: así es como se realiza el retorno de la función. Pero si observamos con atención, podemos notar que la penúltima instrucción modifica la dirección de retorno guardada en la pila. Calculamos cuál será después
de ejecutar el código. En R1 se carga cierta dirección 0xB130, se le resta 5, luego se transfiere a R0 y se le suma 0x10. Resulta 0xB13B. De esta manera, IDA piensa que en la última instrucción ocurre un retorno normal de la función, mientras que en realidad se produce un salto a la dirección calculada 0xB13B.
Aquí cabe recordar que los procesadores ARM tienen dos modos y dos conjuntos de instrucciones: ARM y Thumb. El bit menos significativo de la dirección le indica al procesador qué conjunto de instrucciones se está utilizando. Es decir, la dirección es en realidad 0xB13A, y la unidad en el bit menos significativo indica el modo Thumb.
Al inicio de cada función en esta biblioteca se ha añadido un «adaptador» similar y
código basura. No nos detendremos en detalles sobre ellos; simplemente recordamos
que el verdadero inicio de casi todas las funciones está un poco más allá.
Dado que en el código no hay un salto explícito a 0xB13A, IDA no reconoce por sí sola que en este lugar hay código. Por la misma razón, no reconoce la mayor parte del código en la biblioteca como tal, lo que dificulta un poco el análisis. Le decimos a IDA que aquí hay código, y esto es lo que obtenemos:

En 0xB144 claramente comienza la tabla. ¿Y qué hay en sub_494C?

Al llamar a esta función, en el registro LR obtendremos la dirección de la tabla mencionada anteriormente (0xB144). En R0 está el índice en esta tabla. Es decir, se toma el valor de la tabla, se suma a LR y se obtiene
la dirección a la que debemos ir. Intentemos calcularla: 0xB144 + [0xB144 + 8 * 4] = 0xB144 + 0x120 = 0xB264. Vamos a la dirección obtenida y vemos literalmente un par de instrucciones útiles y otra vez un salto a 0xB140:

Ahora habrá un salto en desplazamiento con el índice 0x20 de la tabla.
Dado el tamaño de la tabla, habrá muchos saltos como este en el código. Surge la pregunta, ¿podemos hacer esto de manera más automatizada, sin calcular direcciones manualmente? Aquí entran en juego los scripts y la posibilidad de parchear código en IDA:
def put_unconditional_branch(source, destination):
offset = (destination - source - 4) >> 1
if offset > 2097151 or offset 1023 or 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 "índice =", 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 "Tipo de operando incorrecto en", hex(ea1), "-", get_operand_type(ea1, 0), get_operand_type(ea1, 1)
table = None
if table is None:
print "No se pudo encontrar la tabla"
else:
print "tabla =", hex(table)
offset = get_wide_dword(table + (index << 2))
put_unconditional_branch(ea, table + offset)
else:
print "Código desconocido", get_operand_type(ea1, 0), get_operand_value(ea1, 0), get_operand_type(ea1, 1) == 2
else:
print "No se pudo detectar la primera instrucción"Colocamos el cursor en la línea 0xB26A, ejecutamos el script y vemos un salto a 0xB4B0:

IDA nuevamente no reconoció esta sección como código. La ayudamos y vemos allí otra construcción:

Las instrucciones después de BLX no parecen muy coherentes, parece más un desplazamiento. Miramos en sub_4964:

Y de hecho, aquí se toma un dword de la dirección que está en LR, se suma a esta dirección, después se toma el valor de la dirección resultante y se coloca en la pila. También se suma 4 a LR, para saltar este desplazamiento después de regresar de la función. Después, el comando POP {R1} saca el valor obtenido de la pila. Si miramos lo que hay en la dirección 0xB4BA + 0xEA = 0xB5A4, podemos ver algo que se asemeja a una tabla de direcciones:

Para parchear esta construcción, será necesario obtener dos parámetros del código: el desplazamiento y el número del registro en el que se debe colocar el resultado. Para cada registro posible, será necesario preparar un fragmento de código con antelación.
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 <> 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 "Instrucción POP no encontrada"
else:
print "Tipo de operando incorrecto en +4:", get_operand_type(ea + 4, 0)
else:
print "No se pudo detectar las primeras instrucciones"Colocamos el cursor al comienzo de la construcción que queremos reemplazar — 0xB4B2 — y ejecutamos el script:

Además de las construcciones mencionadas, en el código también encontramos las siguientes:

Al igual que en el caso anterior, después de la instrucción BLX, sigue un desplazamiento:

Tomamos el desplazamiento de la dirección desde LR, lo sumamos a LR y vamos allí. 0x72044 + 0xC = 0x72050. El script para esta construcción es muy simple:
def put_unconditional_branch(source, destination):
offset = (destination - source - 4) >> 1
if offset > 2097151 or offset 1023 or 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 "No se pudo detectar la primera instrucción"Resultado de la ejecución del script:

Después de que la función ha sido parcheada, se puede indicar a IDA su verdadero inicio. Esta recolectará el código de la función en fragmentos, y podrá ser descompilado con HexRays.
Descifrando cadenas
Hemos aprendido a combatir la ofuscación del código máquina en la biblioteca libsgmainso-6.4.36.so de UC Browser y obtuvimos el código de la función 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;
}Examinemos más de cerca las siguientes líneas:
sub_73E24(&unk_83EA6, &v6, 49);
clazz = (jclass)((int (__fastcall *)(JNIEnv *, int *))(*env)->FindClass)(env, &v6);En la función sub_73E24 se lleva a cabo claramente la desencriptación del nombre de la clase. Esta función recibe como parámetros un puntero a datos que parecen estar encriptados, un búfer y un número. Es evidente que después de la llamada a la función, el búfer contendrá la cadena desencriptada, ya que se pasa a la función FindClass, que acepta como segundo parámetro el nombre de la clase. Por lo tanto, el número es el tamaño del búfer o la longitud de la cadena. Intentemos desencriptar el nombre de la clase, debería indicarnos si vamos en la dirección correcta. Veamos más de cerca qué sucede en 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;
}La función sub_7AF78 Crea una instancia de un contenedor para arrays de bytes del tamaño especificado (no vamos a entrar en detalles sobre estos contenedores). Se crean dos de estos contenedores: uno para la cadena «DcO/lcK+h?m3c*q@» (no es difícil adivinar que esta es la clave), el otro para los datos cifrados. Luego ambos objetos se colocan en una estructura que se pasa a la función sub_6115C. También señalemos que en esta estructura hay un campo con el valor 3. Veamos qué sucede con esta estructura a continuación.
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 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;
}Se pasa un campo de la estructura como parámetro switch, al que previamente se le ha asignado el valor 3. Vamos a case 3: a la función sub_6364C se pasan parámetros de la estructura, que se guardaron allí en la función anterior, es decir, la clave y los datos encriptados. Si observamos atentamente sub_6364C, podemos reconocer el algoritmo RC4 en ella.
Tenemos el algoritmo y la clave. Intentemos descifrar el nombre de la clase. Esto es lo que obtuvimos: com/taobao/wireless/security/adapter/JNICLibrary. ¡Excelente! Estamos en el camino correcto.
Árbol de comandos
Ahora necesitamos encontrar la llamada RegisterNatives, que nos indicará la función doCommandNative. Revisamos las funciones llamadas desde JNI_OnLoad, y la encontramos en 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;
}Y efectivamente, aquí se registra un método nativo llamado doCommandNative. Ahora conocemos su dirección. Veamos qué hace.
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;
}Por el nombre se puede inferir que aquí se encuentra el punto de entrada de todas las funciones que los desarrolladores decidieron trasladar a la biblioteca nativa. Nos interesa la función número 10601.
Por el código, se puede ver que del número de comando se generan tres números: command / 10000, command % 10000 / 100 y command % 10, es decir, en nuestro caso, 1, 6 y 1. Estos tres números, junto con un puntero a JNIEnv y los argumentos pasados a la función, se combinan en una estructura y se envían a continuación. Con los tres números obtenidos (los denominaremos N1, N2 y N3) se construye un árbol de comandos.
Algo como esto:

El árbol se llena dinámicamente en JNI_OnLoad.
Tres números codifican el camino en el árbol. Cada hoja del árbol contiene la dirección desofuscada de la función correspondiente. La clave está en el nodo padre. Encontrar el lugar en el código donde se añade la función que necesitamos al árbol no es muy complicado si se comprenden todas las estructuras utilizadas (no incluimos su descripción para no alargar innecesariamente este artículo ya bastante largo).
Más sobre la ofuscación
Hemos obtenido la dirección de la función que debería desofuscar el tráfico: 0x5F1AC. Pero no hay que alegrarse todavía: los desarrolladores de UC Browser nos han preparado otra sorpresa.
Después de obtener los parámetros del array que fue formado en el código Java, llegamos
a la función en la dirección 0x4D070. Y aquí nos espera otro tipo de ofuscación del código.
Colocamos en R7 y R4 dos índices:

Movemos el primer índice a R11:

Para obtener la dirección de la tabla, usamos el índice:

Después de la transición por la primera dirección, se utiliza el segundo índice, que está en R4. Hay 230 elementos en la tabla.
¿Qué hacer con esto? Se puede decirle a IDA que es un switch: Editar -> Otro -> Especificar idiomática de switch.

El código resultante es aterrador. Pero, al avanzar a través de sus laberintos, uno puede notar la llamada a la función que ya conocemos. sub_6115C:

Allí había un switch, donde en el case 3 estaba la desofuscación utilizando el algoritmo RC4. Y en este caso, la estructura que se pasa a la función se llena con los parámetros transmitidos en doCommandNative. Recordamos que teníamos magicInt con el valor 16. Miramos el case correspondiente y, tras varias transiciones, encontramos el código que permite identificar el algoritmo.

¡Es AES!
Tenemos el algoritmo, solo falta obtener sus parámetros: modo, clave y, posiblemente, vector de inicialización (su presencia depende del modo de operación del algoritmo AES). La estructura con ellos debe formarse en algún lugar antes de la llamada a la función sub_6115C, pero esta parte del código está especialmente bien ofuscada, por lo que surge la idea de parchear el código para que todos los parámetros de la función de desofuscación se almacenen en un archivo.
Parche
Para no escribir todo el código del parche en lenguaje ensamblador manualmente, se puede abrir Android Studio, escribir allí una función que reciba los mismos parámetros que nuestra función de desofuscación, y escriba en un archivo, después de lo cual copiar y pegar el código que generará el compilador.
Nuestros amigos del equipo de UC Browser también se han «preocupado» por la comodidad de agregar código. Recordamos que al principio de cada función tenemos código de relleno que se puede reemplazar fácilmente por cualquier otro. Muy conveniente 🙂 Sin embargo, al inicio de la función de destino, hay poco espacio para el código que guarda todos los parámetros en un archivo. Tuvimos que dividirlo en partes y usar bloques de relleno de funciones adyacentes. En total, resultaron ser cuatro partes.
Primera parte:

En la arquitectura ARM, los primeros cuatro parámetros de la función se pasan a través de los registros R0-R3, y los demás, si hay, a través de la pila. En el registro LR se pasa la dirección de retorno. Todo esto es necesario para que la función pueda ejecutarse después de que descarguemos sus parámetros. También es necesario guardar todos los registros que utilizaremos en el proceso, así que hacemos PUSH.W {R0-R10,LR}. En R7 obtendremos la dirección de la lista de parámetros pasados a la función a través de la pila.
Con la función fopen abrimos el archivo /data/local/tmp/aes en modo «ab»,
es decir, para agregar. Cargamos en R0 la dirección del nombre del archivo y en R1 la dirección de la cadena que indica el modo. Y aquí termina el código de relleno, por lo que pasamos a la siguiente función. Para que continúe funcionando, colocamos al principio un salto al código real de la función, evadiendo el relleno, y en lugar del relleno añadimos la continuación del parche.

Llamamos a fopen.
Los primeros tres parámetros de la función aes tienen el tipo int. Dado que al principio guardamos los registros en la pila, simplemente podemos pasar a la función fwrite sus direcciones en la pila.

A continuación, tenemos tres estructuras que contienen el tamaño de los datos y un puntero a los datos para la clave, el vector de inicialización y los datos cifrados.

Al final cerramos el archivo, restauramos los registros y devolvemos el control a la función real aes.
Compilamos el APK con la biblioteca parcheada, lo firmamos, lo subimos al dispositivo/emulador y lo ejecutamos. Vemos que nuestra descarga se crea y que se escribe mucha información. El navegador utiliza cifrado no solo para el tráfico, y todo el cifrado pasa por la función en cuestión. Sin embargo, no encontramos los datos que necesitamos, y no vemos la solicitud necesaria en el tráfico. Para no esperar a que UC Browser se digne a hacer la solicitud necesaria, tomaremos la respuesta cifrada del servidor, obtenida anteriormente, y parcheremos la aplicación una vez más: añadiremos la descifrado en onCreate de la actividad principal.
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;)IRecogemos, firmamos, instalamos y ejecutamos. Obtenemos NullPointerException porque el método devolvió null.
Durante un análisis posterior del código se descubrió una función que descifra líneas interesantes: «META-INF\/» y «.RSA». Parece que la aplicación verifica su certificado. O incluso genera claves a partir de él. No queremos lidiar con lo que sucede con el certificado, así que simplemente le proporcionaremos el certificado correcto. Parcheamos la línea cifrada de tal manera que en lugar de «META-INF\/» obtengamos «BLABLINF\/», creamos una carpeta con ese nombre en el APK y le proporcionamos allí el certificado del navegador.
Recogemos, firmamos, instalamos y ejecutamos. ¡Bingo! ¡Tenemos la clave!
MitM
Hemos obtenido la clave y el vector de inicialización, que es igual a la clave. Intentemos descifrar la respuesta del servidor en modo CBC.

Vemos la URL del archivo, algo que se parece a MD5, «extract_unzipsize» y un número. Verificamos: el MD5 del archivo coincide, el tamaño de la biblioteca descomprimida coincide. Intentamos parchear esta biblioteca y entregársela al navegador. Para demostrar que nuestra biblioteca parcheada se ha cargado, vamos a iniciar un Intent para crear un SMS con el texto «PWNED!». Vamos a reemplazar dos respuestas del servidor: y en la descarga del archivo. En el primero reemplazamos el MD5 (el tamaño después de la descompresión no cambia), en el segundo entregamos el archivo con la biblioteca parcheada.
El navegador intenta descargar el archivo varias veces, luego arroja un error. Parece que algo
no le gusta. Como resultado del análisis de este formato turbio, se descubrió que el servidor también transmite el tamaño del archivo:

Está codificado en LEB128. Después del parche, el tamaño del archivo con la biblioteca cambió un poco, por lo que el navegador consideró que el archivo se descargó incorrectamente, y después de varios intentos, arrojó un error.
Corregimos el tamaño del archivo... ¡Y – victoria! 🙂 El resultado en video.
Consecuencias y reacción del desarrollador.
De la misma manera, los hackers podrían utilizar la función insegura del UC Browser para distribuir y ejecutar bibliotecas maliciosas. Estas bibliotecas funcionarían en el contexto del navegador, por lo que tendrían todos sus permisos del sistema. Como consecuencia, podrían mostrar ventanas de phishing y acceder a archivos de trabajo de la ardilla china naranja, incluyendo inicios de sesión, contraseñas y cookies almacenadas en la base de datos.
Nos pusimos en contacto con los desarrolladores de UC Browser y les informamos sobre el problema encontrado, intentando señalar la vulnerabilidad y su peligrosidad, pero no quisieron discutir nada con nosotros. Mientras tanto, el navegador continuaba presumiblemente con la función peligrosa a la vista de todos. Pero tan pronto como revelamos los detalles de la vulnerabilidad, ya no se podía ignorar como antes. El 27 de marzo se
lanzó una nueva versión del UC Browser 12.10.9.1193, que se conectaba al servidor por HTTPS: .
Además, después de la "corrección" y hasta el momento de escribir el artículo, intentar abrir un PDF en el navegador resultaba en un mensaje de error con el texto "¡Ups, algo salió mal!". La solicitud al servidor al intentar abrir el PDF no se ejecutaba, pero se ejecutaba al iniciar el navegador, lo que sugiere una posibilidad persistente de cargar código ejecutable en violación de las normas de Google Play.
Fuente: habr.com
