详细分析一款移动端浏览器安全性

栏目: 编程工具 · 发布时间: 6年前

内容简介:在3月底时,我们我们想知道此浏览器是否存在异常行为,事实证明的确如此。该应用会下载并运行可执行代码,这违背了Google Play的应用发布策略。浏览器不仅下载可执行代码,整个过程也会被用于MitM(中间人)攻击。接下来我们看一下是否可以通过这种攻击方式利用该应用。

详细分析一款移动端浏览器安全性

0x00 前言

在3月底时,我们 报告 了一款移动端浏览器中存在下载并运行未验证代码的潜在漏洞。今天我们将详细分析漏洞成因以及黑客利用该漏洞的具体方式。

我们想知道此浏览器是否存在异常行为,事实证明的确如此。该应用会下载并运行可执行代码,这违背了Google Play的应用发布策略。浏览器不仅下载可执行代码,整个过程也会被用于MitM(中间人)攻击。接下来我们看一下是否可以通过这种攻击方式利用该应用。

本文研究的浏览器下载自Google Play,版本信息如下:

package: com.UCMobile.intl
versionName: 12.10.8.1172
versionCode: 10598
sha1 APK-file: f5edb2243413c777172f6362876041eb0c3a928c

0x01 攻击向量

浏览器的manifest文件中包含名为 com.uc.deployment.UpgradeDeployService 的一个服务:

<service android:exported="false" android:name="com.uc.deployment.UpgradeDeployService" android:process=":deploy" />

当该服务启动时,浏览器会向 puds.ucweb.com/upgrade/index.xhtml 发送请求,我们可以在应用启动后某个时刻观察到相应流量。在响应数据中,浏览器可能会收到下载更新或者下载新模块的指令。在分析过程中,我们从未从服务端收到过这类命令,但我们注意到一点,当用户尝试在浏览器中打开PDF文件时,浏览器会向该地址发送请求,然后下载一个原生(native)库。为了模拟攻击行为,我们决定使用浏览器的这个功能:使用原生库来打开PDF文件,这个原生库并没有在APK文件中,而是下载自互联网。从技术角度上来讲,只要对启动时发送的请求返回正确的响应,浏览器就可以在未经用户授权情况下下载某些数据。为了测试这个猜想,我们需要更加详细研究与服务端的交互协议,我们认为可以hook并修改响应数据,将打开PDF文件所需的原生库替换为其他库,这样操作起来会更加简单。

当用户想通过浏览器直接打开一个PDF文件,我们可以看到包含如下请求的通信数据:

详细分析一款移动端浏览器安全性

首先,应用会向 puds.ucweb.com/upgrade/index.xhtml 发送一个POST请求,然后下载经过压缩的一个库,该库可以查看PDF文件以及Office文档。从逻辑上讲,我们可以假设第一个请求发送的是关于系统的一些信息(至少包含架构信息,因为服务端需要选择匹配的库),然后服务端会返回待下载库的某些信息,比如下载地址等。这里问题在于该请求经过加密处理。

详细分析一款移动端浏览器安全性

程序库为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请求的代码。大家可以看到包含 0x5F00x1F-50=0xCE )的16字节数组,这些值与这个请求中的值相同。

这个类中还有一个嵌套类,其中包含另一个有趣的方法:

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");
        }
    }

这个方法接收一组输入字节数组,检查第0字节是否为 0x60 或者第3字节是否为 0xD0 ,同时第2字节是否为 111 或者 0x1F 。来看一下服务端响应:第0字节为 0x60 、第2字节为 0x1F 、第3字节为 0x60 ,看上去数据满足要求。通过字符串来判断(比如 up_decrypt ),这里应该会调用一个方法来解密服务端响应。现在我们来看一下 g.j 方法。注意到第1个参数为offset为2的一个字节(这里即 0x1F ),并且第2个参数为服务端响应数据(除去前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 这个值为3种匹配选项之一。

回到代码分析上,经过多次跳转后,我们找到了 decryptBytesByKey 这个方法。代码从响应数据中又提取2个字节,形成一个字符串。显然这是选择密钥的过程,用来解密消息。

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;
    }

稍微跟进一下,请注意,目前我们看到的只是密钥标识,并不是密钥。密钥选择会更加复杂一些。

在下一个方法中,代码新增了两个参数,因此我们现在总共看到了4个参数:魔术值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个新增整数参数: 2 以及 0 。与 javax.crypto.Cipher 系统类的 doFinal 方法一样,这里2表示解密操作。然后,该信息会与 10601 一起发送到某个 Router10601 显然是一个命令编号)。

在接下来的跳转链之后,我们又找到了实现 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 文件中包含一个原生库( libsgmain.so 实际上是一个 .JAR 文件,其中实现了与加密有关的接口): libsgmainso-6.4.36.so 。在IDA中加载该库后,我们看到了一堆错误消息提示框,问题在于section头表无效。开发人员采用这种方式来进一步提高逆向分析难度。

详细分析一款移动端浏览器安全性

但我们并不需要这个信息,程序头表对我们而言已经足够,可以正确加载并分析ELF文件。因此我们可以简单删除section头表,将头部中对应的字段置空。

详细分析一款移动端浏览器安全性

然后再次在IDA中打开该文件。

我们有两种方法能告诉 Java 虚拟机哪个原生库包含代码中声明的原生代码的具体实现。第一种方法就是采用 Java_package_name_ClassName_methodName 之类的名字,第二种方法是调用 RegisterNatives 函数,在加载库的时候进行注册(在 JNI_OnLoad 函数中)。对于这个案例,如果我们使用第一种方法,那么函数名应该类似于 Java_com_taobao_wireless_security_adapter_JNICLibrary_doCommandNative 。在导出函数中我们找不到这个名字,这意味着我们需要查找 RegisterNatives 。因此,我们转到 JNI_OnLoad 函数,看到如下代码:

详细分析一款移动端浏览器安全性

这里代码执行了哪些逻辑?初步分析时,函数头以及函数尾都是典型的ARM架构。第一条指令会将函数需要使用的寄存器值push到栈中(这里为 R0R1R2 以及 LR ,用来保存函数返回地址)。最后一条指令恢复已保存的寄存器值,将返回地址存到PC寄存器中,然后返回函数。但如果我们仔细分析,可能会注意到倒数第二条指令改变了返回地址。来计算一下代码执行后返回地址的值。该地址加载自 R10xB130 ),减去 5 ,然后被 movR0 ,再加上 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中使用脚本来patch代码:

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无法将这个位置的数据识别为代码。帮IDA调整后,我们可以看到另一个结构:

详细分析一款移动端浏览器安全性

BLX 之后的指令看起来意义不清,更像是某类偏移。来看一下 sub_4964

详细分析一款移动端浏览器安全性

实际上,代码获取 LR 处提取一个 DWORD ,将其加到这个地址,然后提取结果地址处的值,将其存入栈中。此外,从函数返回后,代码将 LR 与4相加以便跳转到同一个offset,然后利用 POP {R1} 命令从栈中提取结果值。观察位于 0xB4BA + 0xEA = 0xB5A4 地址处的数据,我们可以看到与地址表类似的某些内容:

详细分析一款移动端浏览器安全性

为了patch这个结构,我们需要从代码中提取两个参数:offset值以及待存入结果的寄存器编号。我们必须提前为每个可能使用的寄存器准备一段代码。

patches = {}
patches[0] = (0x00, 0xbf, 0x01, 0x48, 0x00, 0x68, 0x02, 0xe0)
patches[1] = (0x00, 0xbf, 0x01, 0x49, 0x09, 0x68, 0x02, 0xe0)
patches[2] = (0x00, 0xbf, 0x01, 0x4a, 0x12, 0x68, 0x02, 0xe0)
patches[3] = (0x00, 0xbf, 0x01, 0x4b, 0x1b, 0x68, 0x02, 0xe0)
patches[4] = (0x00, 0xbf, 0x01, 0x4c, 0x24, 0x68, 0x02, 0xe0)
patches[5] = (0x00, 0xbf, 0x01, 0x4d, 0x2d, 0x68, 0x02, 0xe0)
patches[8] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0x80, 0xd8, 0xf8, 0x00, 0x80, 0x01, 0xe0)
patches[9] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0x90, 0xd9, 0xf8, 0x00, 0x90, 0x01, 0xe0)
patches[10] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0xa0, 0xda, 0xf8, 0x00, 0xa0, 0x01, 0xe0)
patches[11] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0xb0, 0xdb, 0xf8, 0x00, 0xb0, 0x01, 0xe0)

ea = here()
if (get_wide_word(ea) == 0xb082 #SUB SP, SP, #8
        and get_wide_word(ea + 2) == 0xb503): #PUSH {R0,R1,LR}
    if get_operand_type(ea + 4, 0) == 7:
        pop = get_bytes(ea + 12, 4, 0)
        if pop[1] == 'xbc':
            register = -1
            r = get_wide_byte(ea + 12)
            for i in range(8):
                if r == (1 << i):
                    register = i
                    break
            if register == -1:
                print "Unable to detect register"
            else:
                address = get_wide_dword(ea + 8) + ea + 8
                for b in patches[register]:
                    patch_byte(ea, b)
                    ea += 1
                if ea % 4 != 0:
                    ea += 2
                patch_dword(ea, address)
        elif pop[:3] == 'x5dxf8x04':
            register = ord(pop[3]) >> 4
            if register in patches:
                address = get_wide_dword(ea + 8) + ea + 8
                for b in patches[register]:
                    patch_byte(ea, b)
                    ea += 1
                patch_dword(ea, address)
        else:
            print "POP instruction not found"
    else:
        print "Wrong operand type on +4:", get_operand_type(ea + 4, 0)
else:
    print "Unable to detect first instructions"

将光标定位到我们想替换的结构的头部(即 0xB4B2 ),然后运行如上脚本:

详细分析一款移动端浏览器安全性

除了前面提到的结构之外,我们还可以看到如下代码:

详细分析一款移动端浏览器安全性

与前文类似, BLX 指令后还有一个offset:

详细分析一款移动端浏览器安全性

LR 寄存器处提取offset地址值,转到该地址。 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"

执行脚本后的结果如下所示:

详细分析一款移动端浏览器安全性

patch该函数后,我们可以指引IDA找到函数的真实代码。IDA会逐一收集所有函数代码,然后我们就可以使用HexRays来反编译代码。

0x04 解密字符串

前面我们已经知道如何处理浏览器 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*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;
}

前面值为3的这个字段会被当成 switch 条件。来看一下 case 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 的函数非常感兴趣。

从代码中我们可以通过命令编号生成3个子编号: command / 10000command % 10000 / 100 以及 command % 10 (这里我们对应的是 16 以及 1 )。这3个子编号、指向 JNIEnv 的指针以及传给该函数的其他参数共同组成一个结构体,以便后续使用。这3个子编号(这里我们用 N1N2 以及 N3 来表示)构成了一棵命令树,如下所示:

详细分析一款移动端浏览器安全性

这棵树会在 JNI_OnLoad 中动态创建,其中3个子编号共同编码了整棵树的路径。树中每个节点都包含相应函数经过异或处理后的地址,秘钥位于父节点中。如果我们理解了所有这些结构,那么想在代码中查找我们需要的函数是否被添加到这棵树中是非常简单的一件事(本文不会专门描述这方面内容)。

0x06 更多混淆技术

我们已经得到了能够解密通信数据的函数地址: 0x5F1AC ,但先别高兴得太早,浏览器开发者还给我们准备了另一个惊喜。

从Java代码中某个数组接受参数后,我们跳转到位于 0x4D070 的函数,这里有另一种代码混淆技术在等着我们。

首先将两个索引存入 R7 以及 R4 寄存器:

详细分析一款移动端浏览器安全性

第一个索引 movR11 寄存器:

详细分析一款移动端浏览器安全性

使用该索引从表中获得地址值:

详细分析一款移动端浏览器安全性

转到第一个地址后,我们使用来自 R4 的第二个索引。该表中包含230个元素。

如何处理这张表?我们需要告诉IDA这是一种switch跳转:选择“Edit -> Other -> Specify switch idiom”。

详细分析一款移动端浏览器安全性

生成的代码比较庞大,然而我们可以看到其中又调用了熟悉的 sub_6115C 函数:

详细分析一款移动端浏览器安全性

在case 3中有个switch参数涉及到RC4解密。在这个case中,传递给处理函数的结构体字段来源于传递给 doCommandNative 的参数。前面提到过,其中包含一个魔术Int值16。分析对应的case,经过多次转换后,我们成功找到了能帮助我们识别加密算法的代码:

详细分析一款移动端浏览器安全性

这是AES算法!

知道算法后,我们只需要获得参数即可,比如加密模式、秘钥以及初始向量(IV,是否存在该向量取决于AES算法的操作模式)。在调用 sub_6115C 函数之前,代码必须在某个地方先创建包含这些参数的结构体。但由于这部分代码经过混淆处理,我们决定patch代码,将解密函数用到的所有参数都dump到文件中。

0x07 Patch

如果不想使用汇编语言手动编写所有的patch代码,我们可以运行Android Studio,编写自己的解密函数,该函数接收相同的参数,然后写入文件,再拷贝由编译器生成的结果代码。

浏览器团队顺便也在“帮助”我们添加代码。前面提到过,每个函数的开头都有一些垃圾代码,我们可以使用其他任意代码来替代这些代码,这一点非常方便。然而,目标函数开头处没有足够的空间,无法插入保存参数到文件的完整代码。我们需要将这些代码切分成几部分,使用相邻函数的垃圾代码空间。我们总共使用了4块垃圾空间。

第1块代码:

详细分析一款移动端浏览器安全性

ARM架构使用 R0R3 寄存器来存放前4个函数参数,如果还有其他参数,则通过栈来保存。 LR 寄存器用来保存返回地址。我们需要保存这些数据,这样当我们dump参数后,函数还能正常工作。我们还需要保存过程中使用的所有寄存器的值,因此我们使用的是 PUSH.W {R0-R10,LR} 指令。在 R7 寄存器中,我们可以得到通过栈传递给函数的参数列表地址。

使用 fopen 函数,以 ab 模式打开 /data/local/tmp/aes 文件(这样我们就能追加写入)。然后加载 R0 寄存器中的文件名地址以及 R1 寄存器中的字符串地址(该字符串用来表示文件打开模式)。随后就是垃圾代码结束的位置,因此我们继续转到下一个函数。由于我们希望程序能继续运行,因此我们在函数开头处、垃圾代码之前转移到实际函数代码,然后将垃圾代码替换为剩余的patch代码。

详细分析一款移动端浏览器安全性

接下来调用 fopen

aes函数的前3个参数都为 int 类型。由于我们在一开始就将寄存器压入栈,因此我们可以将栈中的地址直接传输到 fwrite 函数。

详细分析一款移动端浏览器安全性

接下来我们有3个结构体,用来表示数据大小,包含指向秘钥、初始向量以及加密数据的指针。

详细分析一款移动端浏览器安全性

最后,关闭该文件,恢复寄存器,将控制权交给真正的aes函数。我们使用patch后的库重新编译APK文件、经过签名后下载到设备或者模拟器上然后运行。现在我们已经dump出了许多数据,可以看到浏览器不仅会解密流量,也会解密其他数据,并且解密操作都通过该函数来执行。因为某些原因,我们并没有看到我们需要的数据,并且也没有在流量中看到我们预期的请求。我们不需要等浏览器发起请求,然后再用之前获取的加密响应来替换服务器返回的响应。这里我们直接在main activity的 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

重新编译、签名、安装和运行。由于该方法会返回一个 null 值,因此现在我们会得到一个 NullPointerException

进一步分析代码后,我们找到包含两个有趣字符串的一个函数: META-INF/ 以及 .RSA 。貌似该应用会校验证书,或者直接生成秘钥。我们并不想深入分析证书方面的操作,因此我们可以直接提供正确的证书。我们patch经过加密的字符串,使用 BLABLINF/ 来替换 META-INF/ ,在APK文件中使用该字符串创建一个目录,在其中保存浏览器证书。

重新编译、签名、安装并运行后,我们成功获得了秘钥。

0x08 MitM攻击

现在我们已经得到秘钥以及匹配的初始向量,可以尝试以CBC模式解密服务端返回的响应。

详细分析一款移动端浏览器安全性

可以看到压缩文件的URL、类似MD5的哈希、 extract_unzipsize 以及一个数字,来逐个过一下。这里压缩文件的MD5值相同、解压后程序库的大小相同。现在我们尝试patch这个库,然后将其传输给浏览器。为了验证patch后的库是否已被成功加载,我们构建了一个Intent,用来弹出一个“PWNED!”文本消息。我们替换了来自服务端的两个响应: puds.ucweb.com/upgrade/index.xhtml 以及提示下载压缩文件的响应。在第一个响应中,我们替换了MD5值(解压后大小保持不变);在第二个响应中,我们发送了带有patch库的文件。

浏览器发起了多次请求来尝试下载文件,最终出现错误,导致下载失败。显然还有一些问题我们没有解决。分析返回的响应数据格式后,我们发现服务端还会传输压缩文件的大小:

详细分析一款移动端浏览器安全性

该数据经过LEB128编码。我们的patch会稍微修改压缩库的大小,因此浏览器判定文件在下载过程中已被破坏,会在多次尝试后显示错误。当我们修复压缩文件大小后,就能看到预期的攻击结果,完整攻击过程可参考 此处视频

0x09 攻击效果及官方回应

攻击者可以采用相同方法,利用浏览器这个不安全的功能来传播其启动恶意库。这些库会在浏览器的上下文中运行,拥有浏览器具备的完整系统权限。这样攻击者就能不受约束地显示钓鱼页面、访问浏览器的敏感文件以及数据库中的登录信息、密码以及cookie信息。

我们与浏览器开发者联系后,通知我们发现的这些问题,想解释这个漏洞的成因以及危险程度,但开发者拒绝与我们讨论这个问题。与此同时,带有危险函数的浏览器依然正常提供下载和使用。一旦我们发现漏洞细节后,我们再也不能像之前那样坐视不理。3月27日,官方发布了12.10.9.1193版浏览器,这个版本会通过HTTPS协议访问 puds.ucweb.com/upgrade/index.xhtml 。此外,在“漏洞修复”和本文发表这段时间内,如果我们使用该浏览器打开PDF文件,就可以看到一个错误信息:“Oops, something is wrong.”。打开PDF文件时浏览器并不会往服务器发送请求,然而这只是在应用启动时采取的措施,这意味着浏览器还是可以下载可执行代码,这仍然与Google Play的政策不符。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Ajax for Web Application Developers

Ajax for Web Application Developers

Kris Hadlock / Sams / 2006-10-30 / GBP 32.99

Book Description Reusable components and patterns for Ajax-driven applications Ajax is one of the latest and greatest ways to improve users’ online experience and create new and innovative web f......一起来看看 《Ajax for Web Application Developers》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

html转js在线工具
html转js在线工具

html转js在线工具