Hybrid(混合式) Appz之WebView中如何让JS与Java安全地互相调用

标签: hybrid appz webview | 发表时间:2015-03-24 14:15 | 作者:u011730649
分享到:
出处:http://blog.csdn.net

在现在安卓应用原生开发中,为了追求开发的效率以及移植的便利性,使用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方法,并且安全可靠呢?

一、动态地生成将注入的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);

二、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>
但同时有一种业务情景时,页面初始加载完备时就应立即触发的调用,如果我们这样写:

那么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>

作者:u011730649 发表于2015/3/24 16:49:08 原文链接
阅读:45 评论:0 查看评论

相关 [hybrid appz webview] 推荐:

Hybrid(混合式) Appz之WebView中如何让JS与Java安全地互相调用

- - CSDN博客推荐文章
在现在安卓应用原生开发中,为了追求开发的效率以及移植的便利性,使用WebView作为业务内容展示与交互的主要载体是个不错的折中方案. 那么在这种Hybrid(混合式) App中,难免就会遇到页面JS需要与Java相互调用,调用Java方法去做那部分网页JS不能完成的功能. 网上的方法可以告诉我们这个时候我们可以使用addjavascriptInterface来注入原生接口到JS中,但是在安卓4.2以下的系统中,这种方案却我们的应用带来了很大的安全风险.

WebView JS 交互

- - ITeye博客
WebView加jquery做页面会怎么样呢. // 创建WebView对象. // 把programList添加到js的全局对象window中,. // 这样就可以使用window.programList来获取数据. * 定义js回调java函数. // 绑定键盘的向上,向下按钮事件触发相应的js事件.

Android WebView 用法

- - 移动开发 - ITeye博客
Android和iOS系统都提供了标准的浏览器控件,在Android中是WebView,iOS中为UIWebView. 在iOS中你实例化一 个UIWebView即可调用loadRequest来加载一个网页,但是在Android中你不仅需要创建一个WebView,还需要做一些其他的事 情,建议初次使用WebView的读者按照以下步骤使用:.

WebView使用总结

- - ITeye博客
        以下是使用WebView过程中的部分场景和解决方案. 如果WebView是放在TabHost中的Activity, 而且WebView中包含html的select,会在点击其中的Item时候崩溃,异常信息如下: android.view.WindowManager$BadTokenException: Unable to add window.

移动端Hybrid应用与响应式

- - SegmentFault 最新的文章
前端是一个非常庞大和复杂的领域. 如果说多年前的前端只是需要学习几个 HTML 标签,看到别的网站用了狂拽酷炫的特效就 copy 下来,稍微懂点 jQuery 做日常使用,再了解几个 Prototype 和 MooTools(貌似都不再维护了)等高冷脱俗的库做装X用就能显得很“专家”了. 那么现在要还是持这样的想法,就不适合搞前端.

Hybrid APP架构设计思路

- - SegmentFault 最新的文章
关于Hybrid模式开发app的好处,网络上已有很多文章阐述了,这里不展开. 本文将从以下几个方面阐述Hybrid app架构设计的一些经验和思考. 原文及讨论请到 github issue. 作为一种跨语言开发模式,通讯层是Hybrid架构首先应该考虑和设计的,往后所有的逻辑都是基于通讯层展开.

Android WebView的坑总结

- - CSDN博客推荐文章
       最近把做好的iPad HybridApp向Android迁移,碰到的坑太多了,让我这个折腾过Android接近4年的老鸟都头疼. 现在把前人遇到的都列出来,再慢慢解决自己的,目前已经解决了android键盘覆盖问题,下面最棘手的问题就是屏幕高度的适配问题了. 1、 Andrid4.1事件穿透BUG 原因不明.

WebView中的视频播放

- - 移动开发 - ITeye博客
activity_main.xml如下:. private String videoUrl = "视频地址.mp4";. private String imgUrl = "视频封面图片地址.png";. private String html = "<html>视频如下:<br/><a href='" + videoUrl.

WebView的使用总结

- - CSDN博客移动开发推荐文章
1)       添加权限:AndroidManifest.xml中必须使用许可"android.permission.INTERNET",否则会出Web page not available错误. 2)       在要Activity中生成一个WebView组件:WebView webView = new WebView(this);或者可以在activity的layout文件里添加webview控件:.

webView使用方法小结

- - CSDN博客推荐文章
1、添加权限:AndroidManifest.xml中必须使用许可"android.permission.INTERNET",否则会出Web page not available错误. 2、在要Activity中生成一个WebView组件:WebView webView = new WebView(this);.