from:Breaking UC Browser
0x00 引言
我们在三月底对在UC浏览器中下载和运行未验证代码的潜在可能性进行了报告。今天我们将详细探讨这个过程如何发生以及黑客如何利用这个漏洞。
前段时间,UC公司相当积极地宣传推广UC浏览器。这个应用通过恶意软件安装在设备上的,假借视频文件的幌子通过网站传播(也就是说,用户以为他们在下载色情或其他内容,但实际上是通过这个浏览器获取APK文件),用广告手段让用户以为自己的浏览器已经过时或容易受到攻击。UC浏览器的官方VK小组有一个话题,用户在其中抱怨虚假宣传广告,许多用户提供了案例。2016年,俄罗斯甚至出现了一则商业广告(是的,一则屏蔽商业广告的浏览器广告)。
在我们写这篇文章的时候,UC浏览器在Google Play上的安装次数已达50亿次。这个数字十分惊人,因为只有谷歌浏览器超过它。在评论中,你可以看到很多用户抱怨广告太多,以及被引导到Google Play上的其他应用程序。这就是我们进行研究的原因:我们想看看UC浏览器是否存在错误。而事实上,它有!该应用程序可以下载并运行可执行代码,这违反了 Google Play的应用程序发布政策,而且UC浏览器不仅仅只是下载可执行代码,这个过程还十分不安全,可以被黑客利用进行MitM攻击。让我们看看我们能否这样利用它。
以下所有内容均适用于我们进行研究期间通过Google Play发布的 UC浏览器版本:
package: com.UCMobile.intl versionName: 12.10.8.1172 versionCode: 10598 sha1 APK-file: f5edb2243413c777172f6362876041eb0c3a928c
0x01 攻击向量
UC浏览器的manifest中包含一个具有警示性名称com.uc.deployment.UpgradeDeployService
的服务。
<service android:exported="false" android:name="com.uc.deployment.UpgradeDeployService" android:process=":deploy" />
当这个服务启动时,浏览器向puds.ucweb.com/upgrade/index.xhtml
发送一个 POST 请求,该请求在启动后的一段时间内可以在流量中看到。浏览器会接收下载任意更新或新模块的命令作为响应。在我们的分析过程中,我们从未从服务器接收过此类命令,但我们注意到,当尝试在浏览器中打开PDF文件时,它会重复对上述地址的请求,然后下载一个本地库。为了模拟攻击,我们决定使用UC浏览器的这个功能 ,即使用不在APK文件中,但可以从网上下载的本地库打开PDF文件。从技术上来说,如果需要对启动时发出的请求给予适当响应,UC 浏览器可以在未经用户许可的情况下下载某些东西。但对此,我们需要更详细地研究与服务器的交互协议,因此我们认为,只要挂钩和编辑响应,然后用不同的东西替换打开PDF 文件所需的库会更容易一些。
因此,当用户想要直接在浏览器中打开PDF文件时,流量可能会包含以下请求:
首先,向puds.ucweb.com/upgrade/index.xhtml发送一个POST请求,然后下载可查看PDF文件和office文档的压缩库。从逻辑上讲,我们可以假设第一个请求发送有关系统的信息(至少涉及体系结构,因为服务器需要选择一个合适的库),服务器会响应一些需要下载的库的信息,比如它的地址或其他信息。问题是这个请求是加密的。
Request fragment | Response fragment |
---|---|
该库以ZIP文件压缩,未加密。
0x02 搜索流量解密码
我们现在尝试解密服务器的响应。看看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。检查服务器响应:0字节为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, ""); }
经过一系列跳转之后,我们看到了
com.alibaba.wireless.security.open.staticdataencrypt.IStaticDataEncryptComponent
接口的
staticBinarySafeDecryptNoB64
方法。主应用程序代码没有实现此接口的类别。这个类别包含在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表示解密,和javax.crypto.Cipher
系统类的doFinal方法中的一样。然后,这些信息和数字10601一起被传输到一个特定路由器,这个数字显然是命令编号。
在下一个跳转链之后,我们找到一个实现RouterComponent
接口和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方法。这里就是乐趣的源头。
0x03 机器代码混淆
libsgmain.so
文件中有一个本地库(实际上是一个.JAR文件,就像我们上面所说的,实现了一些与加密相关的接口):libsgmainso-6.4.36.so
。我们在IDA中加载这个库,获取了一堆带有错误消息的对话框。问题是节标题表无效。我们故意这样做的目的是为了使分析复杂化。
但我们并不需要节标题表。程序标题表足以正确加载ELF文件并进行分析。因此,我们只需删除节标题表,使标题中的相应字段为空即可。
然后再次在IDA中打开文件。
我们有两种方法可以准确地辨别出本机库中包含方法实现(被Java代码声明为本地方法)的Java虚拟机。第一个方法是将它命名为:Java_package_name_ClassName_methodName
。第二个方法是在加载库时(在JNI_OnLoad
函数中),通过调用RegisterNatives
函数进行注册。在本例中,如果你采用第一个方法,则名称应当为:
Java_com_taobao_wireless_security_adapter_JNICLibrary_doCommandNative
。导出函数的列表不包含此名称,这意味着我们需要查找RegisterNatives
。因此,转到JNI_OnLoad
函数并查看以下内容:
这里是怎么回事?乍一看,函数的开头和结尾是ARM架构的典型特征。第一条指令将函数使用的寄存器的内容推送到堆栈(在本例中为R0、R1和R2),以及附带函数返回地址的LR寄存器。最后一条指令恢复已保存的寄存器并将返回地址放入PC寄存器内,从而从函数中返回。但如果仔细观察,会注意到倒数第二条指令改变了存储在堆栈中的返回地址。我们计算一下代码执行时的返回地址是什么。地址0xB130在R1中加载,从中减去5,然后移到R0,再加上0x10。最后,它等于0xB13B。因此,IDA认为最终指令执行了正常的函数返回操作,而实际上,它执行的是转换到计算地址0xB13B的过程。
此时我们要提醒您一下,ARM处理器有两种模式和两组指令 — ARM和Thumb。地址的低阶位决定处理器将使用哪个指令集。也就是说,地址实际上是0xB13A,而低阶位的值表示Thumb模式。
类似的“适配器”和一些语义垃圾被添加到该库中每个函数的开头。 但我们对此不做详细讨论。只要记住,几乎所有函数的真正开头都要稍微远一点。
由于代码中没有显式转换为0xB13A,因此IDA无法识别其中存在代码。出于同样的原因,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:
实际上,它从LR的地址中获取DWORD,将其添加到这个地址,然后取得结果地址的值,再存储在堆栈中。查看位于地址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] == '/x5d/xf8/x04': 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对其进行反编译。
0x04 解密字符串
我们已经知道如何在UC浏览器的libsgmainso-6.4.36.so库中处理机器代码混淆问题,且已经获得了函数代码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*[email protected]", 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*[email protected]
(很容易猜到这就是密钥),另一个包含加密的数据。然后将两个对象放置在某个特定结构中,该结构转移到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; }
之前取值为3的字段被转换为开关参数。让我们看一下实例3 — 将前一个函数添加到结构中的参数(即密钥和加密的数据)转换为sub_6364C函数。如果仔细观察sub_6364C,便能识别出RC4算法。
所以我们手上有算法和密钥。让我们尝试解密该类的名称。这就是我们得到的结果:com/taobao/wireless/security/adapter/JNICLibrary
。太好了!我们的方向是正确的。
0x05 命令树
现在我们需要找到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表示)构造命令树,如图所示:
命令树是在JNI_OnLoad
中动态创建的。三个数字对树中的路径进行编码。树的每片叶子都包含相应函数的xorred地址。密钥存在于双亲节点中。如果我们理解树中的所有结构(本文对此不作赘述),在代码中找到一个添加我们需要的函数的位置是很容易的。
0x06 其他混淆类型
我们已经获得了用于解密流量的函数的地址:0x5F1AC。 但现在放松还为时尚早 — UC浏览器开发人员还给我们准备了另一个惊喜。
从Java代码中的数组接收参数后,转到0x4D070处的函数。另一种类型的代码混淆已经在等着我们了。
然后在R7和R4中推送两个索引:
第一个指索引移至R11:
利用这个索引从表中获取地址:
转移到第一个地址后,使用R4的第二个索引。该表包含230个元素。
我们要用它做什么?我们可以告知IDA这是一种switch:Edit -> Other -> Specify switch idiom
。
得到的结果代码令人惊心。但我们可以在这乱糟糟的结果中看到对熟悉的sub_6115C
函数的调用程序:
实例3中存在进行过RC4解密的开关参数。在这个实例下,传递给该函数的结构填充有传送到doCommandNative
的参数。我们记得此处有取值为16的magicInt。看一下相应的实例,经过几次转换后,找到帮助我们识别算法的代码。
它是AES!
我们已经有了一个算法,只需要获取它的参数即可,例如模式、密钥和(如有)初始化向量(取决于AES算法的操作模式)。在调用sub_6115C函数之前,应该在某处创建包含这些参数的结构。但由于这部分代码的混淆不堪,我们决定修补代码,以便解密函数的所有参数都可以转储到文件中。
0x07 补丁
如果你不想用汇编语言手动编写所有的补丁代码,可以运行Android Studio工具,编写一个能接收与我们的解密函数相同的参数并写入文件的函数,然后复制编译器生成的结果代码。
我们来自UC浏览器团队的好朋友也“确保”了添加代码的方便性。我们确实记得在每个函数的开头都有垃圾代码,这些代码可以轻易替换为任何其他代码。非常方便:) 但是,在目标函数的开头没有足够的空间来存放将所有参数保存到文件中的代码。我们必须把它分成若干个部分,然后使用相邻函数的垃圾块。总共分为四个部分。第一部分:
ARM架构中的前四个函数参数在R0-R3寄存器中予以指示,其余的(如有)通过堆栈提供。LR寄存器指示了返回地址。我们需要保存所有这些数据,以便在我们转储函数的参数后,该函数可以正常发挥作用。我们还需要保存这个过程中使用的所有寄存器,因此使用PUSH.W {R0‑R10,LR}
。我们从R7中获取通过堆栈传递给函数的参数列表的地址。
通过fopen函数的使用,我们在“ab”模式下打开/data/local/tmp/aes
文件(这样我们就可以添加一些内容)。 然后,在R0中加载文件名地址,在R1中加载指示模式的字符串地址。这是垃圾代码结束的地方,因此我们导航到下一个函数。由于我们希望它继续有效,所以一开始就将垃圾代码转换为实际的函数代码,并将垃圾代码替换为补丁的其余部分。
然后调用fopen。
aes函数的前三个参数属于int类型。由于我们在开始时将寄存器推送到堆栈,因此可以简单地将堆栈中的地址传输给fwrite函数。
接下来,我们有三个结构可指示数据的大小并包含指向密钥数据、初始化向量和加密数据的指针。
最后,关闭文件,恢复寄存器并将控制权交还给实际的aes函数。我们使用修补后的库编译APK文件,对其进行签名,将其下载到设备或模拟器上,然后运行。此时可以看到转储处已经创建了大量数据。浏览器不仅会解密流量,还会解密其他数据,并且所有解密都通过此函数执行。出于某种原因,我们没有看到我们需要的数据,同时我们期望看到的请求在流量中也不可见。让我们跳过等待的时间,让UC浏览器有机会发出此请求并从服务器获取之前获得的加密响应再次修补应用程序。我们将解密数据添加到主要活动的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
方法,因为该方法会返回一个空值。
进一步分析代码之后,我们发现了一个函数存在一个相当有趣的字符串:“META‑INF/” 和“.RSA”。应用程序似乎会验证它的证书,甚至可以从中生成密钥。我们真的不想深挖这个证书,所以直接提供正确的证书。我们对加密的字符串进行修补,得到的“BLABLINF”代替“META-INF/”,在APK文件中创建一个具有此名称的文件夹,并将浏览器证书保存在其中。
我们进行编译、签名和运行。没错!我们拿到了密钥!
0x08 MitM
现在我们已经拿到了密钥和一个相等的初始化向量。让我们尝试在CBC模式下解密服务器响应。
我们看到了存档文件URL(类似MD5)、“extract_unzipsize
”和一个数字。存档文件的MD5相同;解压缩库的大小相同。现在我们尝试修补此库并将其传输到浏览器。为了表明我们的补丁库已加载完毕,我们会构建一个Intent来创建文字消息«PWNED!»我们从服务器替换两个响应:puds.ucweb.com/upgrade/index.xhtml
和提示下载存档文件的响应。我们替换掉第一个响应中的MD5(压缩后大小保持不变);在第二个响应中,我们使用修补后的库发送存档文件。
浏览器多次尝试下载存档文件,导致错误。显然,那里发生了一些可疑的事情。分析这个奇怪的格式后,我们发现服务器还会传输存档文件的大小:
它进行过LEB128编码。补丁稍微改变了压缩库的大小,因此浏览器确定存档文件在下载时遭到破坏并在多次尝试后显示错误。所以我们修复了存档文件大小......瞧!:)观看视频中的演示结果。
0x09 结果和开发者回应
同样地,黑客可以使用UC浏览器这种不安全特性来传播和启动恶意库。这些库会在浏览器的中运行,从而拿到浏览器具备的完整系统权限。这使黑客可以自由控制显示网络钓鱼窗口,以及访问浏览器的工作文件,包括数据库中的登录名、密码和cookie等。
我们联系了UC浏览器的开发人员并将我们发现的问题告知他们,试图指出漏洞及其危险,但他们拒绝讨论此事。同时,存在危险功能的浏览器仍然可以下载。一旦我们披露了漏洞的细节,就不可能像以前那样忽略它。UC 浏览器的新版本12.10.9.1193于3月27日发布,该版浏览器通过 HTTPS puds.ucweb.com/upgrade/index.xhtml
访问服务器。此外,在“修复缺陷”和撰写本文期间,在浏览器中尝试打开 PDF 时,会出现一条错误提示,内容是“糟糕,软件出现了问题(Oops,something is wrong)。”尝试打开PDF文件时,未向服务器发出请求。不过,这是在启动时执行的,表示违反Google Play政策下载可执行代码的功能仍然存在。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论