How to safely call JS and Java in WebView

How to safely call JS and Java in WebView

[[120265]]

In the current Android native application development, in order to pursue development efficiency and porting convenience, using WebView as the main carrier for business content display and interaction is a good compromise. In such a hybrid app, it is inevitable that the page JS needs to call Java and call Java methods to perform functions that the web page JS cannot complete.

The methods on the Internet can tell us that we can use addjavascriptInterface to inject native interfaces into JS at this time, but in systems below Android 4.2, this solution brings great security risks to our applications. If the attacker executes some illegal JS on the page (to induce users to open some phishing websites to enter the risky page), it is very likely to rebound and obtain the shell permissions of the user's mobile phone. Then the attacker can quietly install the Trojan in the background and completely penetrate the user's mobile phone. The detailed attack process can be seen in this report of Wuyun Platform: Interface hidden dangers in WebView and mobile phone Trojan use.

For Android 4.2 and above (API >= 17), add @JavascriptInterface annotation to the callable method in the injection class. The method without annotation cannot be called. This method can prevent injection vulnerabilities. So is there a safe way that can fully take into account Android versions below 4.2? The answer is to use prompt, that is, the WebChromeClient input box pop-up mode.

We refer to the solution given in the article Solution to Js Object Injection Vulnerability in Android WebView, but its JS method is a bit clumsy, the process of dynamically generating JS files is not clear, and the timing of loading JS files is not accurately grasped. So how can we modify it to conveniently call Java methods in JS code, and safely and reliably?

The source code and project mentioned below can be found here Safe Java-JS Bridge In Android WebView [Github].

1. Dynamically generate the JS code to be injected

When constructing JsCallJava, it takes out the public and static methods of the class to be injected, generates the signatures of the methods one by one, caches the methods according to the method signatures, and dynamically generates a string to be injected into the webview by combining the method name with the static HostApp-JS code.

  1. public JsCallJava (String injectedName, Class injectedCls) {
  2. try {
  3. mMethodsMap = new HashMap<String, Method>();
  4. //Get all methods declared by itself (including public and private protected), getMethods will get all inherited and non-inherited methods  
  5. Method[] methods = injectedCls.getDeclaredMethods();
  6. 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]}}};" );
  7.  
  8. for (Method method : methods) {
  9. String sign;
  10. if (method.getModifiers() != (Modifier.PUBLIC | Modifier.STATIC) || (sign = genJavaMethodSign(method)) == null ) {
  11. continue ;
  12. }
  13. mMethodsMap.put(sign, method);
  14. sb.append(String.format( "a.%s=" , method.getName()));
  15. }
  16.  
  17. 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);" );
  18. mPreloadInterfaceJS = sb.toString();
  19. } catch (Exception e) {
  20. Log.e(TAG, "init js error:" + e.getMessage());
  21. }
  22. }
  23.  
  24. private String genJavaMethodSign (Method method) {
  25. String sign = method.getName();
  26. Class[] argsTypes = method.getParameterTypes();
  27. int len ​​= argsTypes.length;
  28. if (len < 1 || argsTypes[ 0 ] != WebView. class ) {
  29. Log.w(TAG, "method(" + sign + ") must use webview to be first parameter, will be pass" );
  30. return   null ;
  31. }
  32. for ( int k = 1 ; k < len; k++) {
  33. Class cls = argsTypes[k];
  34. if (cls == String.class ) {
  35. sign += "_S" ;
  36. } else   if (cls == int . class ||
  37. cls == long . class ||
  38. cls == float . class ||
  39. cls == double . class ) {
  40. sign += "_N" ;
  41. } else   if (cls == boolean . class ) {
  42. sign += "_B" ;
  43. } else   if (cls == JSONObject. class ) {
  44. sign += "_O" ;
  45. } else   if (cls == JsCallback. class ) {
  46. sign += "_F" ;
  47. } else {
  48. sign += "_P" ;
  49. }
  50. }
  51. return sign;
  52. }

As can be seen above, the names of the various methods of the class are spliced ​​into the two statically compressed JS codes. So what does the complete and clear HostApp-JS fragment generated in this way look like? Assuming that only three public static methods, toast, alert, and getIMSI, are defined in the HostJsScope class, the complete fragment is as follows:

  1. (function(global){
  2. console.log( "HostApp initialization begin" );
  3. var hostApp = {
  4. queue: [],
  5. callback: function () {
  6. var args = Array.prototype.slice.call(arguments, 0 );
  7. var index = args.shift();
  8. var isPermanent = args.shift();
  9. this .queue[index].apply( this , args);
  10. if (!isPermanent) {
  11. delete this .queue[index];
  12. }
  13. }
  14. };
  15. hostApp.toast = hostApp.alert = hostApp.getIMSI = function () {
  16. var args = Array.prototype.slice.call(arguments, 0 );
  17. if (args.length < 1 ) {
  18. throw   "HostApp call error, message:miss method name" ;
  19. }
  20. var aTypes = [];
  21. for (var i = 1 ;i < args.length;i++) {
  22. var arg = args[i];
  23. var type = typeof arg;
  24. aTypes[aTypes.length] = type;
  25. if (type == "function" ) {
  26. var index = hostApp.queue.length;
  27. hostApp.queue[index] = arg;
  28. args[i] = index;
  29. }
  30. }
  31. var res = JSON.parse(prompt(JSON.stringify({
  32. method: args.shift(),
  33. types: aTypes,
  34. args: args
  35. })));
  36.  
  37. if (res.code != 200 ) {
  38. throw   "HostApp call error, code:" + res.code + ", message:" + res.result;
  39. }
  40. return res.result;
  41. };
  42.  
  43. //Sometimes, we want to insert some other behaviors before this method is executed to check the current state or monitor  
  44. //Code behavior, this requires the use of interception (Interception) or injection (Injection) technology  
  45. /**
  46. * Object.getOwnPropertyName returns an array containing all the properties of the specified object
  47. *
  48. * Then traverse this array and do the following processing:
  49. * 1. Back up original properties;
  50. * 2. Check if the attribute is a function (i.e., a method);
  51. * 3. If you redefine the method, do what you need to do, and then apply the original method body.
  52. */  
  53. Object.getOwnPropertyNames(hostApp).forEach(function (property) {
  54. var original = hostApp[property];
  55.  
  56. if (typeof original === 'function' &&property!== "callback" ) {
  57. hostApp[property] = function () {
  58. return original.apply(hostApp, [property].concat(Array.prototype.slice.call(arguments, 0 )));
  59. };
  60. }
  61. });
  62. global.HostApp = hostApp;
  63. console.log( "HostApp initialization end" );
  64. })(window);

In fact, when JsCallJava is initialized, we only splice the above line 15 hostApp.toast = hostApp.alert = hostApp.getIMSI = function () . The purpose is to graft all JS layer call functions into an anonymous function 1, and then use interception technology to traverse all functions under hostApp, take out the corresponding function name, and then graft all function calls under hostApp into another anonymous function 2. The purpose of this is to first execute anonymous function 2 when calling a function under hostApp, and anonymous function 2 will use the corresponding function name as the first parameter and then call anonymous function 1, so that anonymous function 1 can distinguish the call source during execution. A unified JS layer call entry and a unified return exit structure system are realized.

2. HostApp JS fragment injection timing

Step 1 explains how to splice the HostApp-JS fragment. The JS fragment splicing is completed in the JsCallJava initialization, which is initiated when the InjectedChromeClient object is instantiated.

  1. public InjectedChromeClient (String injectedName, Class injectedCls) {
  2. mJsCallJava = new JsCallJava(injectedName, injectedCls);
  3. }

From the code in step 1, we know that the JS code spliced ​​by JsCallJava is temporarily stored in the mPreloadInterfaceJS field. So when do we inject this code string into the page space of Webview? The answer is when the page loading progress changes.

  1. @Override  
  2. public   void onProgressChanged (WebView view, int newProgress) {
  3. //Why inject JS here?  
  4. //1 Injection in OnPageStarted may fail globally, causing all interfaces on the page script to be unavailable at any time  
  5. //2 Inject in OnPageFinished. Although the global injection will be successful in the end, the completion time may be too late. When the page is initialized and calls the interface function, it will wait too long.  
  6. //3 Inject when the progress changes, just to get a compromise between the above two problems  
  7. //Why is the injection performed only when the progress is greater than 25%? Because from the test, only when the progress is greater than this number can the page actually get the framework refresh and load, ensuring 100% injection success  
  8. if (newProgress <= 25 ) {
  9. mIsInjectedJS = false ;
  10. } else   if (!mIsInjectedJS) {
  11. view.loadUrl(mJsCallJava.getPreloadInterfaceJS());
  12. mIsInjectedJS = true ;
  13. Log.d(TAG, " inject js interface completely on progress " + newProgress);
  14.  
  15. }
  16. super .onProgressChanged(view, newProgress);
  17. }

From the above, we can see that the timing of injection is to accurately grasp when the progress is greater than 25%. If injected in OnPageFinished, the initial callback of the page document.ready will wait too long. We will talk about the detailed reasons later.

3. The process of page calling Java method execution

OK, the above two steps solve the two major problems of dynamic generation and successful injection. Next, we need to deal with the specific JS calling process. As mentioned above, when the page calls the Java method, the anonymous js function prompts the json data after concatenating the parameters. The prompt message is intercepted by WebChromeClient.onJsPrompt in the Java layer.

  1. @Override  
  2. public   boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
  3. result.confirm(mJsCallJava.call(view, message));
  4. return   true ;
  5. }

The specific implementation of JsCallJava.call is as follows.

  1. public String call(WebView webView, String jsonStr) {
  2. if (!TextUtils.isEmpty(jsonStr)) {
  3. try {
  4. JSONObject callJson = new JSONObject(jsonStr);
  5. String methodName = callJson.getString( "method" );
  6. JSONArray argsTypes = callJson.getJSONArray( "types" );
  7. JSONArray argsVals = callJson.getJSONArray( "args" );
  8. String sign = methodName;
  9. int len ​​= argsTypes.length();
  10. Object[] values ​​= new Object[len + 1 ];
  11. int numIndex = 0 ;
  12. String currType;
  13.  
  14. values[ 0 ] = webView;
  15.  
  16. for ( int k = 0 ; k < len; k++) {
  17. currType = argsTypes.optString(k);
  18. if ( "string" .equals(currType)) {
  19. sign += "_S" ;
  20. values[k + 1 ] = argsVals.isNull(k) ? null : argsVals.getString(k);
  21. } else   if ( "number" .equals(currType)) {
  22. sign += "_N" ;
  23. numIndex = numIndex * 10 + k + 1 ;
  24. } else   if ( "boolean" .equals(currType)) {
  25. sign += "_B" ;
  26. values[k + 1 ] = argsVals.getBoolean(k);
  27. } else   if ( "object" .equals(currType)) {
  28. sign += "_O" ;
  29. values[k + 1 ] = argsVals.isNull(k) ? null : argsVals.getJSONObject(k);
  30. } else   if ( "function" .equals(currType)) {
  31. sign += "_F" ;
  32. values[k + 1 ] = new JsCallback(webView, argsVals.getInt(k));
  33. } else {
  34. sign += "_P" ;
  35. }
  36. }
  37.  
  38. Method currMethod = mMethodsMap.get(sign);
  39.  
  40. // Method matching failed  
  41. if (currMethod == null ) {
  42. return getReturn(jsonStr, 500 , "not found method(" + methodName + ") with valid parameters" );
  43. }
  44. //Digital type segmentation matching  
  45. if (numIndex > 0 ) {
  46. Class[] methodTypes = currMethod.getParameterTypes();
  47. int currIndex;
  48. Class currCls;
  49. while (numIndex > 0 ) {
  50. currIndex = numIndex - numIndex / 10 * 10 ;
  51. currCls = methodTypes[currIndex];
  52. if (currCls == int . class ) {
  53. values[currIndex] = argsVals.getInt(currIndex - 1 );
  54. } else   if (currCls == long . class ) {
  55. //WARN: argsJson.getLong(k + defValue) will return a bigger incorrect number  
  56. values[currIndex] = Long.parseLong(argsVals.getString(currIndex - 1 ));
  57. } else {
  58. values[currIndex] = argsVals.getDouble(currIndex - 1 );
  59. }
  60. numIndex /= 10 ;
  61. }
  62. }
  63.  
  64. return getReturn(jsonStr, 200 , currMethod.invoke( null , values));
  65. } catch (Exception e) {
  66. // Return detailed error information first  
  67. if (e.getCause() != null ) {
  68. return getReturn(jsonStr, 500 , "method execute error:" + e.getCause().getMessage());
  69. }
  70. return getReturn(jsonStr, 500 , "method execute error:" + e.getMessage());
  71. }
  72. } else {
  73. return getReturn(jsonStr, 500 , "call data empty" );
  74. }
  75. }

This is a complete parsing and matching process. It will generate a method signature again based on the method name and parameter type list passed in by the js layer, and match it with the method in the cache object that was initialized and constructed before. After a successful match, it will determine whether there is a number type in the js call parameter type. If there is, it will determine whether to take an int, long, or double type value based on the definition of the Java layer method. Finally, the call value list and method object are used for reflection execution, and the result of the function execution is returned. There are a few points to note here:

  • When the method is executed by reflection, the current WebView instance will be placed in the first parameter, so that the HostJsScope static method can get some relevant context information based on Context;
  • The parameter definition of the static method of the injection class (such as HostJsScope) can use the types of int/long/double, String, boolean, JSONObject, and JsCallback. The corresponding types passed in by the js layer are number, string, boolean, object, and function. Note that when the number is too large (such as a timestamp), it may need to be converted to a string type first (the parameters in the Java method must also be defined as String) to avoid precision loss ;
  • The return value of a Java method can be void or a type that can be converted to a string (such as int, long, String, double, float, etc.) or a serializable custom type ;
  • If the execution fails or the calling method cannot be found, the Java layer will pass the exception information to the JS layer, and an error will be thrown in the JS anonymous function;

4. Use of HostApp on the page

With the above preparations, we can now use HostApp in the page easily without loading any dependent files. For example, when clicking on the li tag:

  1. < ul   class = "entry" >  
  2. <   onclick = "HostApp.alert('HostApp.alert');" > HostApp.alert </ li >  
  3. <   onclick = "HostApp.toast('HostApp.toast');" > HostApp.toast </ li >  
  4. <   onclick = "HostApp.testLossTime(new Date().getTime() + '');" > HostApp.testLossTime </ li >   <!-- Timestamp long integer is converted to string before calling -->  
  5. <   onclick = "HostApp.toast(HostApp.getIMSI());" > HostApp.getIMSI </ li >  
  6. </ ul >  

But at the same time, there is a business scenario where the call should be triggered immediately when the page is initially loaded. If we write:

  1. document.addEventListener('DOMContentLoaded', function() {
  2. HostApp.toast('document ready now');;
  3. }, false);

Then the call to HostApp is very likely to fail, because the timing of injecting the HostApp-JS fragment may be before or after document.ready. So how to solve this contradiction?

If the HostApp JS has been injected successfully when document.ready is called, this is OK. If the HostApp JS has not been injected yet when document.ready is called, our JS script layer needs to be changed, that is, polling the status until the injection is successful or the timeout (1.5s) occurs, and then a callback occurs. The specific implementation is as follows (the following is an example of the modification of the $.ready() function of zepto.js).

  1. //Some operations on DOM  
  2. // Define methods that will be available on all  
  3. // Zepto collections  
  4. $.fn = {
  5. //DOM Ready  
  6. ready: function(callback, jumpHostAppInject) {
  7. var originCb = callback;
  8. var mcounter = 0 ;
  9. //Try to wait (1500ms timeout) for the client to inject HostApp Js  
  10. callback = function () {
  11. if (!window.HostApp && mcounter++ < 150 )setTimeout(callback, 10 ); else originCb($);
  12. };
  13. //Whether to skip waiting for HostApp injection  
  14. if (jumpHostAppInject) {
  15. callback = originCb;
  16. }
  17. if (readyRE.test(document.readyState)) callback($); else document.addEventListener( 'DOMContentLoaded' , function() {
  18. callback($)
  19. }, false );
  20. return   this  
  21. },
  22. ...
  23. ...
  24. };

This mechanism also explains why the JS injection of the Java layer is not placed in OnPageFinish. If that is the case, the number of page polling will increase, the waiting time will become longer, and there may be a timeout. Well, with the above changes, the call to HostApp needs to be triggered immediately when the page is initially loaded, as follows:

  1. < script   type = "text/javascript" >  
  2. $(function () {
  3. HostApp.alert("HostApp ready now");
  4. });
  5. </ script >  

For more instructions and complete source code, see: Safe Java-JS Bridge In Android WebView [Github]

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

<<:  A different approach to building mobile apps: iOS and Android code sharing

>>:  iPhone 6: The pain of mobile phone design revealed

Recommend

The method of operating data activities is that simple!

E-commerce platform A has been established for ne...

Why are your conversions always so low?

As bidders, we may also be familiar with the term...

Learn these 7 techniques, and even beginners can play Douyin in minutes

Recently, I often see complaints about Douyin in ...

Lovely owls? It turns out this mysterious species is not simple!

Owls have fascinated humans for thousands of year...

4 channels and 5 tools for Taobao promotion

Four major channels: official Taobao activities, ...