在現在安卓應用原生開發中,為了追求開發的效率以及移植的便利性,使用WebView作為業務內容展示與交互的主要載體是個不錯的折中方案。那么在這種Hybrid(混合式) App中,難免就會遇到頁面JS需要與Java相互調用,調用Java方法去做那部分網頁JS不能完成的功能。
網上的方法可以告訴我們這個時候我們可以使用addjavascriptInterface來注入原生接口到JS中,但是在安卓4.2以下的系統中,這種方案卻我們的應用帶來了很大的安全風險。攻擊者如果在頁面執行一些非法的JS(誘導用戶打開一些釣魚網站以進入風險頁面),極有可能反彈拿到用戶手機的shell權限。接下來攻擊者就可以在后臺默默安裝木馬,完全洞穿用戶的手機。詳細的攻擊過程可以見烏云平臺的這份報告:[WebView中接口隱患與手機掛馬利用](http://drops.wooyun.org/papers/548)。
安卓4.2及以上版本(API >= 17),在注入類中為可調用的方法添加@JavascriptInterface注解,無注解的方法不能被調用,這種方式可以防范注入漏洞。那么有沒一種安全的方式,可以完全兼顧安卓4.2以下版本呢?答案就是使用prompt,即WebChromeClient 輸入框彈出模式。
我們參照?[Android WebView的Js對象注入漏洞解決方案](http://blog.csdn.net/leehong2005/article/details/11808557)?這篇文章給出的解決方案, 但它JS下的方法有點笨拙, 動態生成JS文件過程也并沒有清晰,且加載JS文件的時機也沒有準確把握。那么如何改造才能便利地在JS代碼中調用Java方法,并且安全可靠呢?
下面提到的源碼及項目可以在這找到[Safe Java-JS Bridge In Android WebView[Github]](https://github.com/pedant/safe-java-js-webview-bridge)。
##一、動態地生成將注入的JS代碼
JsCallJava在構造時,將要注入類的public且static方法拿出來,逐個生成方法的簽名,依據方法簽名先將方法緩存起來,同時結合方法名稱與靜態的HostApp-JS代碼動態生成一段將要注入到webview中的字符串。
~~~
public JsCallJava (String injectedName, Class injectedCls) {
try {
mMethodsMap = new HashMap<String, Method>();
//獲取自身聲明的所有方法(包括public private protected), getMethods會獲得所有繼承與非繼承的方法
Method[] methods = injectedCls.getDeclaredMethods();
StringBuilder sb = new StringBuilder("javascript:(function(b){console.log(\"HostApp initialization begin\");var a={queue:[],callback:function(){var d=Array.prototype.slice.call(arguments,0);var c=d.shift();var e=d.shift();this.queue[c].apply(this,d);if(!e){delete this.queue[c]}}};");
for (Method method : methods) {
String sign;
if (method.getModifiers() != (Modifier.PUBLIC | Modifier.STATIC) || (sign = genJavaMethodSign(method)) == null) {
continue;
}
mMethodsMap.put(sign, method);
sb.append(String.format("a.%s=", method.getName()));
}
sb.append("function(){var f=Array.prototype.slice.call(arguments,0);if(f.length<1){throw\"HostApp call error, message:miss method name\"}var e=[];for(var h=1;h<f.length;h++){var c=f[h];var j=typeof c;e[e.length]=j;if(j==\"function\"){var d=a.queue.length;a.queue[d]=c;f[h]=d}}var g=JSON.parse(prompt(JSON.stringify({method:f.shift(),types:e,args:f})));if(g.code!=200){throw\"HostApp call error, code:\"+g.code+\", message:\"+g.result}return g.result};Object.getOwnPropertyNames(a).forEach(function(d){var c=a[d];if(typeof c===\"function\"&&d!==\"callback\"){a[d]=function(){return c.apply(a,[d].concat(Array.prototype.slice.call(arguments,0)))}}});b." + injectedName + "=a;console.log(\"HostApp initialization end\")})(window);");
mPreloadInterfaceJS = sb.toString();
} catch(Exception e){
Log.e(TAG, "init js error:" + e.getMessage());
}
}
private String genJavaMethodSign (Method method) {
String sign = method.getName();
Class[] argsTypes = method.getParameterTypes();
int len = argsTypes.length;
if (len < 1 || argsTypes[0] != WebView.class) {
Log.w(TAG, "method(" + sign + ") must use webview to be first parameter, will be pass");
return null;
}
for (int k = 1; k < len; k++) {
Class cls = argsTypes[k];
if (cls == String.class) {
sign += "_S";
} else if (cls == int.class ||
cls == long.class ||
cls == float.class ||
cls == double.class) {
sign += "_N";
} else if (cls == boolean.class) {
sign += "_B";
} else if (cls == JSONObject.class) {
sign += "_O";
} else if (cls == JsCallback.class) {
sign += "_F";
} else {
sign += "_P";
}
}
return sign;
}
~~~
從上面可以看出,類的各個方法名稱被拼接到前后兩段靜態壓縮的JS代碼當中,那么這樣生成的完整清晰的HostApp-JS片段是怎樣的呢? 我們假設HostJsScope類中目前只定義了toast、alert、getIMSI這三個公開靜態方法,那么完整的片段就是下面這樣:
~~~
(function(global){
console.log("HostApp initialization begin");
var hostApp = {
queue: [],
callback: function () {
var args = Array.prototype.slice.call(arguments, 0);
var index = args.shift();
var isPermanent = args.shift();
this.queue[index].apply(this, args);
if (!isPermanent) {
delete this.queue[index];
}
}
};
hostApp.toast = hostApp.alert = hostApp.getIMSI = function () {
var args = Array.prototype.slice.call(arguments, 0);
if (args.length < 1) {
throw "HostApp call error, message:miss method name";
}
var aTypes = [];
for (var i = 1;i < args.length;i++) {
var arg = args[i];
var type = typeof arg;
aTypes[aTypes.length] = type;
if (type == "function") {
var index = hostApp.queue.length;
hostApp.queue[index] = arg;
args[i] = index;
}
}
var res = JSON.parse(prompt(JSON.stringify({
method: args.shift(),
types: aTypes,
args: args
})));
if (res.code != 200) {
throw "HostApp call error, code:" + res.code + ", message:" + res.result;
}
return res.result;
};
//有時候,我們希望在該方法執行前插入一些其他的行為用來檢查當前狀態或是監測
//代碼行為,這就要用到攔截(Interception)或者叫注入(Injection)技術了
/**
* Object.getOwnPropertyName 返回一個數組,內容是指定對象的所有屬性
*
* 其后遍歷這個數組,分別做以下處理:
* 1\. 備份原始屬性;
* 2\. 檢查屬性是否為 function(即方法);
* 3\. 若是重新定義該方法,做你需要做的事情,之后 apply 原來的方法體。
*/
Object.getOwnPropertyNames(hostApp).forEach(function (property) {
var original = hostApp[property];
if (typeof original === 'function'&&property!=="callback") {
hostApp[property] = function () {
return original.apply(hostApp, [property].concat(Array.prototype.slice.call(arguments, 0)));
};
}
});
global.HostApp = hostApp;
console.log("HostApp initialization end");
})(window);
~~~
其實在JsCallJava初始化時我們拼接的只是上面第15行?hostApp.toast = hostApp.alert = hostApp.getIMSI = function ()?這段。目的是將所有JS層調用函數嫁接到一個匿名函數1中,而后利用攔截技術,遍歷hostApp下所有的函數,拿出對應的函數名,然后將hostApp下所有的函數調用嫁接到另一個匿名函數2,這樣做的目的是hostApp下函數調用時首先執行匿名函數2,匿名函數2將對應的函數名作為第一個參數然后再調用匿名函數1,這樣匿名函數1中就能區分執行時調用來源。實現了JS層調用入口統一,返回出口統一的結構體系。
##二、HostApp JS片段注入時機
步驟一說明了HostApp-JS片段的拼接方法,同時JS片段拼接是在JsCallJava初始化完成的,而JsCallJava初始化是在實例化InjectedChromeClient對象時發起的。
~~~
public InjectedChromeClient (String injectedName, Class injectedCls) {
mJsCallJava = new JsCallJava(injectedName, injectedCls);
}
~~~
從步驟一的代碼,我們知道JsCallJava拼接出來的JS代碼暫時被存到mPreloadInterfaceJS字段中。那么我們何時把這段代碼串注入到Webview的頁面空間內呢?答案是頁面加載進度變化的過程中。
~~~
@Override
public void onProgressChanged (WebView view, int newProgress) {
//為什么要在這里注入JS
//1 OnPageStarted中注入有可能全局注入不成功,導致頁面腳本上所有接口任何時候都不可用
//2 OnPageFinished中注入,雖然最后都會全局注入成功,但是完成時間有可能太晚,當頁面在初始化調用接口函數時會等待時間過長
//3 在進度變化時注入,剛好可以在上面兩個問題中得到一個折中處理
//為什么是進度大于25%才進行注入,因為從測試看來只有進度大于這個數字頁面才真正得到框架刷新加載,保證100%注入成功
if (newProgress <= 25) {
mIsInjectedJS = false;
} else if (!mIsInjectedJS) {
view.loadUrl(mJsCallJava.getPreloadInterfaceJS());
mIsInjectedJS = true;
Log.d(TAG, " inject js interface completely on progress " + newProgress);
}
super.onProgressChanged(view, newProgress);
}
~~~
從上面我們可以看出,注入的時機是準確把握在進度大于25%時。如果在OnPageFinished注入,頁面document.ready的初始回調會等待時間過長,詳細的原因我們會在后面講到。
##三、頁面調用Java方法執行的過程
OK,上面兩步解決了動態生成與成功注入的兩大問題,接下來就要處理JS具體的調用過程。上面,我們知道頁面調用Java方法時,匿名js函數在拼接好參數后prompt json數據。prompt消息被Java層的WebChromeClient.onJsPrompt攔截到。
~~~
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
result.confirm(mJsCallJava.call(view, message));
return true;
}
~~~
而JsCallJava.call的具體實現如下。
~~~
public String call(WebView webView, String jsonStr) {
if (!TextUtils.isEmpty(jsonStr)) {
try {
JSONObject callJson = new JSONObject(jsonStr);
String methodName = callJson.getString("method");
JSONArray argsTypes = callJson.getJSONArray("types");
JSONArray argsVals = callJson.getJSONArray("args");
String sign = methodName;
int len = argsTypes.length();
Object[] values = new Object[len + 1];
int numIndex = 0;
String currType;
values[0] = webView;
for (int k = 0; k < len; k++) {
currType = argsTypes.optString(k);
if ("string".equals(currType)) {
sign += "_S";
values[k + 1] = argsVals.isNull(k) ? null : argsVals.getString(k);
} else if ("number".equals(currType)) {
sign += "_N";
numIndex = numIndex * 10 + k + 1;
} else if ("boolean".equals(currType)) {
sign += "_B";
values[k + 1] = argsVals.getBoolean(k);
} else if ("object".equals(currType)) {
sign += "_O";
values[k + 1] = argsVals.isNull(k) ? null : argsVals.getJSONObject(k);
} else if ("function".equals(currType)) {
sign += "_F";
values[k + 1] = new JsCallback(webView, argsVals.getInt(k));
} else {
sign += "_P";
}
}
Method currMethod = mMethodsMap.get(sign);
// 方法匹配失敗
if (currMethod == null) {
return getReturn(jsonStr, 500, "not found method(" + methodName + ") with valid parameters");
}
// 數字類型細分匹配
if (numIndex > 0) {
Class[] methodTypes = currMethod.getParameterTypes();
int currIndex;
Class currCls;
while (numIndex > 0) {
currIndex = numIndex - numIndex / 10 * 10;
currCls = methodTypes[currIndex];
if (currCls == int.class) {
values[currIndex] = argsVals.getInt(currIndex - 1);
} else if (currCls == long.class) {
//WARN: argsJson.getLong(k + defValue) will return a bigger incorrect number
values[currIndex] = Long.parseLong(argsVals.getString(currIndex - 1));
} else {
values[currIndex] = argsVals.getDouble(currIndex - 1);
}
numIndex /= 10;
}
}
return getReturn(jsonStr, 200, currMethod.invoke(null, values));
} catch (Exception e) {
//優先返回詳細的錯誤信息
if (e.getCause() != null) {
return getReturn(jsonStr, 500, "method execute error:" + e.getCause().getMessage());
}
return getReturn(jsonStr, 500, "method execute error:" + e.getMessage());
}
} else {
return getReturn(jsonStr, 500, "call data empty");
}
}
~~~
這是一個完整的解析匹配過程,會依據js層傳入的方法名、參數類型列表再次生成方法簽名,與之前初始化構造好的緩存對象中的方法匹配。匹配成功后則判斷js調用參數類型中是否有number類型,如果有依據Java層方法的定義決定是取int、long還是double類型的值。最后使用調用值列表和方法對象反射執行,返回函數執行的結果。這里有幾點需要注意:
* 方法反射執行時會將當前WebView的實例放到第一個參數,方便在HostJsScope靜態方法依據Context拿到一些相關上下文信息;
* 注入類(如HostJsScope)靜態方法的參數定義可使用的類型有int/long/double、String、boolean、JSONObject、JsCallback,對應于js層傳入的類型為number、string、boolean、object、function,注意number數字過大時(如時間戳),可能需要先轉為string類型(Java方法中參數也須定義為String),避免精度丟失;
* Java方法的返回值可以是void 或 能轉為字符串的類型(如int、long、String、double、float等)或?可序列化的自定義類型;
* 如果執行失敗或找不到調用方法時,Java層會將異常信息傳遞到JS層, JS匿名函數中會throw拋出錯誤;
##四、HostApp在頁面的使用
有了上面的準備工作,現在我們在頁面中就可以很方便地使用HostApp了,而不需要加載任何依賴文件。如li標簽的點擊:
~~~
<ul class="entry">
<li onclick="HostApp.alert('HostApp.alert');">HostApp.alert</li>
<li onclick="HostApp.toast('HostApp.toast');">HostApp.toast</li>
<li onclick="HostApp.testLossTime(new Date().getTime() + '');">HostApp.testLossTime</li> <!-- 時間戳長整型調用前先轉換為string -->
<li onclick="HostApp.toast(HostApp.getIMSI());">HostApp.getIMSI</li>
</ul>
~~~
但同時有一種業務情景時,頁面初始加載完備時就應立即觸發的調用,如果我們這樣寫:
~~~
document.addEventListener('DOMContentLoaded', function() {
HostApp.toast('document ready now');;
}, false);
~~~
那么HostApp的調用極有可能不成功,因為端注入HostApp-JS片段的時機可能在document.ready前也可能在其后。那么如何解決這個矛盾的問題呢?
如果document.ready的時候HostApp JS已經注入成功,這種情況OK沒有問題。當document.ready的時候HostApp JS還未開始注入,這種情景下我們的js腳本層就需要做出變動,即輪詢狀態,直到端注入成功或者超時(1.5s),再發生回調。具體實現如下(下面的是以zepto.js的$.ready()函數改造為例)。
~~~
//針對DOM的一些操作
// Define methods that will be available on all
// Zepto collections
$.fn = {
//DOM Ready
ready: function(callback, jumpHostAppInject) {
var originCb = callback;
var mcounter = 0;
//嘗試等待(1500ms超時)讓端注入HostApp Js
callback = function () {
if(!window.HostApp && mcounter++ < 150)setTimeout(callback, 10);else originCb($);
};
//是否跳過等待HostApp的注入
if (jumpHostAppInject) {
callback = originCb;
}
if (readyRE.test(document.readyState)) callback($); else document.addEventListener('DOMContentLoaded', function() {
callback($)
}, false);
return this
},
...
...
};
~~~
這樣的機制也就解釋了為什么不把Java層的JS注入放在OnPageFinish了,如果那樣頁面輪詢的次數就會上升,等待的時間就會變長,而且有可能會超時。好了,有了上面的改動,頁面初始加載完備時需要立即觸發HostApp的調用,如下:
~~~
<script type="text/javascript">
$(function () {
HostApp.alert("HostApp ready now");
});
</script>
~~~
更多[使用說明](https://github.com/pedant/safe-java-js-webview-bridge)及完整源代碼見:[Safe Java-JS Bridge In Android WebView[Github]](https://github.com/pedant/safe-java-js-webview-bridge)
原文:[http://www.pedant.cn/2014/07/04/webview-js-java-interface-research/](http://www.pedant.cn/2014/07/04/webview-js-java-interface-research/)
感謝分享。