在现在安卓应用原生开发中,为了追求开发的效率以及移植的便利性,使用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代码
这个时候,我们利用Java反射机制,将要注入的类(cn.pedant.SafeJava4WebviewJS.webview.bridge.HostJsScope)的public且static方法拿出来,并获取该方法的参数个数,结合静态的HostApp Javascript代码动态生成一段将要注入到webview中的字符串。
JsCallJava.java1 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 |
public static void init () { try { Class> SCOPECLASS = Class.forName("cn.pedant.SafeJava4WebviewJS.webview.bridge.HostJsScope"); mMethodMap = new HashMap |
从上面可以看出,类的方法及参数信息被拼接到前后两段静态JS代码当中,那么这样生成的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 |
(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"; } for (var i = 1;i < args.length;i++) { var arg = args[i]; if (typeof arg == "function") { var index = hostApp.queue.length; hostApp.queue[index] = arg; args[i] = index; } } var res = JSON.parse(prompt(JSON.stringify({ method: args.shift(), 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); |
其实初始化时我们拼动态生成的只是上面第15行 hostApp.toast = hostApp.alert = hostApp.getIMSI = function () 这段。目的是将所有JS层调用函数嫁接到一个匿名函数1中,而后利用拦截技术,遍历hostApp下所有的函数,拿出对应的函数名,然后将函数嫁接到另一个匿名函数2,这样做的目的是hostApp下函数调用时首先执行匿名函数2,匿名函数2将对应的函数名作为第一个参数然后再调用匿名函数1,这样匿名函数1中就能区分执行时调用来源。实现了JS层调用入口统一,返回出口统一的结构体系。
二、HostApp JS片段注入时机
步骤一说明了HostApp JS片段的拼接方法,而HostApp JS片段拼接的时机是在Application启动时。
MainApp.java1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class MainApp extends Application { @Override public void onCreate() { if (gMainApp != null) { return; } super.onCreate(); TaskExecutor.executeTask(new Runnable() { @Override public void run() { // 拼接HostApp JS片段 JsCallJava.init(); } }); } } |
从上面的代码,我们知道HostApp JS拼接出来的字符串暂时被放到PRELOAD_INTERFACE_JS静态变量中的。那么我们何时把这段拼接好的字符串注入到Webview的执行空间内呢?答案是页面加载进度变化的过程中。
BaseWebChromeClient.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Override public void onProgressChanged (WebView view, int newProgress) { BaseWebView baseWebView = (BaseWebView)view; //为什么要在这里注入JS //1 OnPageStarted中注入有可能全局注入不成功,导致页面脚本上所有接口任何时候都不可用 //2 OnPageFinished中注入,虽然最后都会全局注入成功,但是完成时间有可能太晚,当页面在初始化调用接口函数时会等待时间过长 //3 在进度变化时注入,刚好可以在上面两个问题中得到一个折中处理 //为什么是进度大于25%才进行注入,因为从测试看来只有进度大于这个数字页面才真正得到框架刷新加载,保证100%注入成功 if (newProgress > 25 && !baseWebView.isInjectedJS()) { baseWebView.loadJS(JsCallJava.PRELOAD_INTERFACE_JS); baseWebView.setIsInjectedJS(true); Log.d(" 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拦截到。
BaseWebChromeClient.java1 2 3 4 5 |
@Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { result.confirm(JsCallJava.call(view, message)); return true; } |
而JsCallJava.call的具体实现如下。
JsCallJava.java1 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 |
public static String call(WebView webView, String jsonStr) { if (!TextUtils.isEmpty(jsonStr)) { try { JSONObject callJson = new JSONObject(jsonStr); String methodName = callJson.getString("method"); JSONArray argsJson = callJson.getJSONArray("args"); //带上默认的第一个参数WebView int argsLen = argsJson.length() + 1; Method currMethod = mMethodMap.get(keyConcat(methodName, argsLen)); if (currMethod == null) { return getReturn(jsonStr, 500, "not found method " + methodName + " with " + argsLen + " parameters"); } Object[] args = new Object[argsLen]; Class[] types = currMethod.getParameterTypes(); int defValue = 0; for (int k = 0;k < argsLen;k++) { Class currType = types[k]; if (currType == WebView.class) { args[k] = webView; defValue = -1; } else if (currType == int.class) { args[k] = argsJson.getInt(k + defValue); } else if (currType == long.class) { //WARN: argsJson.getLong(k + defValue) will return a bigger incorrect number args[k] = Long.parseLong(argsJson.getString(k + defValue)); } else if (currType == boolean.class) { args[k] = argsJson.getBoolean(k + defValue); } else if (currType == Double.class) { args[k] = argsJson.getDouble(k + defValue); } else if (currType == JSONObject.class) { args[k] = argsJson.getJSONObject(k + defValue); } else if (currType == JsCallback.class) { args[k] = new JsCallback(webView, argsJson.getInt(k + defValue)); } else { //其他类型统一转换为字符串 args[k] = argsJson.getString(k + defValue); } } return getReturn(jsonStr, 200, currMethod.invoke(null, args)); } 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"); } } private static String keyConcat (String name, int len) { return name + "_" + len; } private static String getReturn (String reqJson, int stateCode, Object result) { String insertRes = String.valueOf(result); if (result instanceof String) { insertRes = "\"" + insertRes + "\""; } String resStr = String.format(RETURN_RESULT_FORMAT, stateCode, insertRes); Log.d("HostApp call json: " + reqJson + " result:" + resStr); return resStr; } |
这是一个完整的解析匹配过程,会将js层传入的参数个数、参数次序及类型与HostJsScope类的方法匹配,匹配成功则反射执行java方法,并返回函数执行的结果。这里有几点需要注意:
- 方法反射执行时会将当前调用来源WebView的实例放到第一个参数,方便在HostJsScope静态方法拿到一些Context相关上下文;
- JS层可传入的参数类型是多样的,可以是number、string、boolean、json、function,其中number数字为长整型时(如时间戳),必须先转为string类型,以避免数字传递误差;
- Java方法的返回值可以是void 或 能被String.valueOf转换的类型(如int、long、String、double、float等);
- 如果执行失败或找不到调用方法时,Java层会将异常信息传递到JS层, JS匿名函数中会throw抛出错误;
四、HostApp在页面的使用
有了上面的准备工作,现在我们在页面中就可以很方便地使用HostApp了,而不需要加载任何依赖文件。如li标签的点击:
test.html1 2 3 4 5 6 |
<ul class="entry"> <li onclick="HostApp.alert('HostApp.alert');">HostApp.alertli> <li onclick="HostApp.toast('HostApp.toast');">HostApp.toastli> <li onclick="HostApp.testLossTime(new Date().getTime() + '');">HostApp.testLossTimeli> <li onclick="HostApp.toast(HostApp.getIMSI());">HostApp.getIMSIli> ul> |
但同时有一种业务情景时,页面初始加载完备时就应立即触发的调用,如果我们这样写:
test.html1 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.js1 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.html1 2 3 4 5 6 7 8 9 10 |
|