在现在安卓应用原生开发中,为了追求开发的效率以及移植的便利性,使用WebView作为业务内容展示与交互的主要载体是个不错的折中方案。那么在这种Hybrid(混合式) App中,难免就会遇到页面JS需要与Java相互调用,调用Java方法去做那部分网页JS不能完成的功能。
网上的方法可以告诉我们这个时候我们可以使用addjavascriptInterface来注入原生接口到JS中,但是在安卓4.2以下的系统中,这种方案却我们的应用带来了很大的安全风险。攻击者如果在页面执行一些非法的JS(诱导用户打开一些钓鱼网站以进入风险页面),极有可能反弹拿到用户手机的shell权限。接下来攻击者就可以在后台默默安装木马,完全洞穿用户的手机。详细的攻击过程可以见乌云平台的这份报告:WebView中接口隐患与手机挂马利用。
安卓4.2及以上版本(API >= 17),在注入类中为可调用的方法添加@JavascriptInterface注解,无注解的方法不能被调用,这种方式可以防范注入漏洞。那么有没一种安全的方式,可以完全兼顾安卓4.2以下版本呢?答案就是使用prompt,即WebChromeClient 输入框弹出模式。
我们参照 Android WebView的Js对象注入漏洞解决方案 这篇文章给出的解决方案, 但它JS下的方法有点笨拙, 动态生成JS文件过程也并没有清晰,且加载JS文件的时机也没有准确把握。那么如何改造才能便利地在JS代码中调用Java方法,并且安全可靠呢?
下面提到的源码及项目可以在这找到Safe Java-JS Bridge In Android WebView[Github]。
一、动态地生成将注入的JS代码
JsCallJava在构造时,将要注入类的public且static方法拿出来,逐个生成方法的签名,依据方法签名先将方法缓存起来,同时结合方法名称与静态的HostApp-JS代码动态生成一段将要注入到webview中的字符串。
JsCallJava.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
public
JsCallJava (String injectedName, Class injectedCls) {
try {
mMethodsMap =
new HashMap<String, Method>();
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这三个公开静态方法,那么完整的片段就是下面这样:
HostApp JS片段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
(
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;
};
* 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对象时发起的。
InjectedChromeClient.java
1
2
3
|
public
InjectedChromeClient (String injectedName, Class injectedCls) {
mJsCallJava =
new JsCallJava(injectedName, injectedCls);
}
|
从步骤一的代码,我们知道JsCallJava拼接出来的JS代码暂时被存到mPreloadInterfaceJS字段中。那么我们何时把这段代码串注入到Webview的页面空间内呢?答案是页面加载进度变化的过程中。
InjectedChromeClient.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@Override
public
void
onProgressChanged (WebView view,
int newProgress) {
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拦截到。
InjectedChromeClient.java
1
2
3
4
5
|
@Override
public
boolean
onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
result.confirm(mJsCallJava.call(view, message));
return
true;
}
|
而JsCallJava.call的具体实现如下。
JsCallJava.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
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) {
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标签的点击:
test.html
1
2
3
4
5
6
|
<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>
<li onclick="HostApp.toast(HostApp.getIMSI());">HostApp.getIMSI
</li>
</ul>
|
但同时有一种业务情景时,页面初始加载完备时就应立即触发的调用,如果我们这样写:
test.html
1
2
3
|
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()函数改造为例)。
zepto.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
$.fn = {
ready:
function(callback, jumpHostAppInject) {
var originCb = callback;
var mcounter =
0;
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的调用,如下:
test.html
1
2
3
4
5
|
<script type=
"text/javascript">
$(
function () {
HostApp.alert(
"HostApp ready now");
});
</script>
|
更多使用说明及完整源代码见:Safe Java-JS Bridge In Android WebView[Github]
欢迎转载,请注明出处链接!!!