在现在安卓应用原生开发中,为了追求开发的效率以及移植的便利性,使用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>();
//获取自身声明的所有方法(包括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这三个公开静态方法,那么完整的片段就是下面这样:

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;
}; //有时候,我们希望在该方法执行前插入一些其他的行为用来检查当前状态或是监测
//代码行为,这就要用到拦截(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对象时发起的。

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) {
//为什么要在这里注入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拦截到。

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) {
//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标签的点击:

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> <!-- 时间戳长整型调用前先转换为string -->
<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
//针对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的调用,如下:

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]

原文:http://www.pedant.cn/2014/07/04/webview-js-java-interface-research/

感谢分享。

在WebView中如何让JS与Java安全地互相调用的更多相关文章

  1. Android混合开发之WebViewJavascriptBridge实现JS与java安全交互

    前言: 为了加快开发效率,目前公司一些功能使用H5开发,这里难免会用到Js与Java函数互相调用的问题,这个Android是提供了原生支持的,不过存在安全隐患,今天我们来学习一种安全方式来满足Js与j ...

  2. webview中java与js交互

    WebView提供了在Android应用中展示网页的强大功能.也是目前Hybird app的大力发展的基础.作为Android系统的一个非常重要的组件,它提供两方面的强大的能力:对HTML的解析,布局 ...

  3. 在Android的webview中定做js的alert,confirm和prompt对话框的方法

    在Android的webview中定制js的alert,confirm和prompt对话框的方法 http://618119.com/archives/2010/12/20/199.html 1.首先 ...

  4. WebView中JS调用Android Method 遇到的坑整理

    WebView是android中常用的一个组件,其作用是展示网页,并让网页和android app进行一些业务逻辑上的交互. 其坑无数,相信用过的都知道,一个一个来解决吧. 1.怎么互调: <! ...

  5. android webview 中 js 模板引擎的使用

    最近在项目中要求用 webview 展示几个界面, 而后台返回的不是 html 而是 json 数据. 起初用 StringBuilder 一个一个拼 html, 后来感觉太繁琐,拼一个还行,拼多了就 ...

  6. iOS之在webView中引入本地html,image,js,css文件的方法 - sky//////////////////////////////////////ZZZZZZZZZZZZZZZ

    iOS之在webView中引入本地html,image,js,css文件的方法   2014-12-08 20:00:16CSDN-sky_2016-点击数:10292     项目需求 最近开发的项 ...

  7. 在webView 中使用JS 调用 Android / IOS的函数 Function

    最近做一个项目,混合了NativeCode 和 HTML,为了便于JS 调用App的一些方法,统一封装一个Js方法,记录如下 Android 端首先要再WebView中允许JS的调用 WebView ...

  8. WebView中Java与JavaScript的交互

    原文首发于微信公众号:jzman-blog,欢迎关注交流! Android 开发过程中 WebView 的使用比较广泛,常用来加载网页,比如使用 WebView 加载新闻页面.使用 WebView 打 ...

  9. Android安全开发之WebView中的地雷

    Android安全开发之WebView中的地雷 0X01 About WebView 在Android开发中,经常会使用WebView来实现WEB页面的展示,在Activiry中启动自己的浏览器,或者 ...

随机推荐

  1. Android(java)学习笔记233: 远程服务的应用场景(移动支付案例)

    一. 移动支付:       用户需要在移动终端提交账号.密码以及金额等数据 到 远端服务器.然后远端服务器匹配这些信息,进行逻辑判断,进而完成交易,返回交易成功或失败的信息给移动终端.用户提交账号. ...

  2. C#解leetcode 53.Maximum Subarray

    Find the contiguous subarray within an array (containing at least one number) which has the largest ...

  3. LINQ to SQL 基础

    取得数据库Gateway 要操作数据库,我们首先要获得一个DataContext对象,这个对象相当于一个数据 库的Gateway,所有的操作都是通过它进行的.这个对象的名字是“Linq to SQL ...

  4. Html5 Canvas Text

    html5 canvas中支持对text文本进行渲染;直接的理解就是把text绘制在画布上,并像图形一样处理它(可以加shadow.gradient.pattern.color fill等等):既然它 ...

  5. ueditor asp.net版本更改图片保存路径

    目的:把本地上传的图片放置到跟目录下的Images/Upload文件夹下. 修改步骤: 1.ueditor.config.js文件中的, imagePath: URL + "net/&quo ...

  6. photoshop mac版下载及破解

    1.下载 直接百度photoshop,就可以找到百度的下载源: 2.破解 http://zhidao.baidu.com/question/581955095.html

  7. vs2010-error LNK1123: failure during conversion to COFF: file invalid or corrupt

    在项目上右键->Properties-> configuration Properties->Enable Incremental Linking(设置为No). ref: Link ...

  8. web.xml配置文件中<async-supported>true</async-supported>报错的解决方案

    为什么用到这个: ssh集成了cxf,当登录系统后,发现系统报错,控制台不断输出下面信息: 2016-05-05 11:05:06 - [http-bio-8080-exec-4] - WARN - ...

  9. cas sso单点登录系列5_cas单点登录增加验证码功能完整步骤

    转:http://blog.csdn.net/ae6623/article/details/8919718 本篇教程cas-server端下载地址:解压后,直接放到tomcat的webapp目录下就能 ...

  10. LIKE匹配关键字

    1.% 匹配任意数目字符(包括零个字符) SELECT * FROM edo_user WHERE true_name LIKE '王%' 应用:关键字查询 WHERE u.true_name lik ...