本文字数:2369字
预计阅读时间:10分钟
作者介绍
本期特邀作者:
毕业于武汉大学,具有5年Android开发经验,在App通信安全、Android相关技术架构与选型方面有一定见解,擅长Flutter、Vue等跨端框架。
导读
本文主要介绍混合式架构App当中的通信安全。由于通信报文是不能保证不被截取的,所以本文主要的措施就是防破解、防篡改和防重放,以Android原生+Web混合式开发框架为例进行说明。
一、加密方案
App客户端在与服务器进行通信的过程中,数据有可能被中间人攻击。如果有一道加密算法做保证的话,能够减小中间人破解数据的可能性,或者说增大他破解数据的代价转而去攻击更容易的目标。但是如果仅仅只是普通的加密方案,那在此描述的价值就不是很大了。
大家都知道,对于Android apk,破解so库的难度要远远大于反编译Java代码。所以我们的第一想法是把通信过程中的加密算法,下沉到使用C/C++实现,然后编译成so库。Android应用在进行网络通信的时候,Java层代码通过JNI调用C/C++层的加密算法。
关于密钥生成则使用动态密钥的方式,通过增加一个可变因子,增加逆向难度和破解后的应对措施。
这里给出java层调用C/C++的native接口参考:
public class NewSign { /** * 用过程密钥对data进行3DES加密,并返回加密后的密文数据 * @param signType 迷糊 * @param timeStam 迷糊 * @param random 迷糊 * @param data N字节明文数据 * @return 16字节随机数+"N字节明文数据"的密文 */ public static native byte[] encodeData(int signType, long timeStam, long random, byte[] data); /** * 用过程密钥对data进行3DES解密,并返回解密后的密文数据 * @param signType 迷糊 * @param timeStam 迷糊 * @param random 迷糊 * @param data 16字节随机数+"N字节明文数据"的密文 * @return N字节明文数据 */ public static native byte[] decodeData(int signType, long timeStam, long random, byte[] code); static{ System.loadLibrary("newsign"); }}
二、Web通信安全
在混合式架构App中,很多业务逻辑都是由Web开发完成的。Web不可避免地要与服务器进行频繁的网络通信。那Web请求是否也有必要实现一套相同的加密算法呢?
我们觉得没有必要:一方面是因为js实现的加密算法反破解能力还没有C/C++编译形成so库好;另一方面如前所述,Android原生已经实现过一套加密算法了,如果js再实现一遍,简直是重复开发。
那么我们的思路,是Web通信都由原生来进行转发,原生给Web提供安全的网络通信框架。而具体的JS层怎么调用原生的,本文不表,它是混合式开发框架App的基础。
这里给出网络转发的实现参考:
/** * 为Web提供的网络转发方法 * * @param type * @param actionName * @param url * @param jsonStr * @param callback * @param showType * 默认为0显示dialog,为1不显示dialog */ @JavascriptInterface public void sendRequest(int type, final String actionName, String url, String jsonStr, final String callback, final int showType) { TraceLogUtil.logCallWapJs("sendRequest", "type:" + type + "|actionName:" + actionName + "|url:" + url + "|jsonStr:" + jsonStr + "|callback:" + callback + "|showType:" + showType, AppConfig.currentToken); RequestListener requestListener = new RequestListener() { @Override public void onRequest() { if (showType == 1) { } else { showDialog("正在处理,请稍后..."); } } @Override public void onSuccess(String response, String url, int actionId) { dismissDialog(); try { JSONObject result = StringUtils.stringToJSONObject(response); if (!AppUtils.isDataError(result, url, "Sencha Touch " + actionName)) { final String json = result.optString("ACTION_INFO"); String deJson = SecurityUtil.decode(json); JSONObject data = StringUtils.stringToJSONObject(deJson); try { result.put("ACTION_INFO", data); } catch (JSONException e) { e.printStackTrace(); } response = result.toString(); openUrl("javascript:" + callback + "(" + response + ")"); } else { final JSONObject root = new JSONObject(); try { root.put("ACTION_RETURN_CODE", result.optString("ACTION_RETURN_CODE")); root.put("ACTION_RETURN_MESSAGE", result.optString("ACTION_RETURN_MESSAGE")); } catch (JSONException e) { e.printStackTrace(); } openUrl("javascript:" + callback + "(" + root.toString() + ")"); } } catch (Exception e) { e.printStackTrace(); final JSONObject root = new JSONObject(); try { root.put("ACTION_RETURN_CODE", "000012"); root.put("ACTION_RETURN_MESSAGE", ResourceUtil.getAppStringById(NewWebViewHostActivity.this, "R.string.parse_network_result_error")); } catch (JSONException e1) { e1.printStackTrace(); } openUrl("javascript:" + callback + "(" + root.toString() + ")"); } } @Override public void onError(String errorMsg, String url, int actionId) { dismissDialog(); showToast(ResourceUtil.getAppStringById(NewWebViewHostActivity.this, "R.string.network_error")); final JSONObject root = new JSONObject(); try { root.put("ACTION_RETURN_CODE", "000011"); root.put("ACTION_RETURN_MESSAGE", ResourceUtil.getAppStringById(NewWebViewHostActivity.this, "R.string.network_error")); } catch (JSONException e) { e.printStackTrace(); } openUrl("javascript:" + callback + "(" + root.toString() + ")"); } }; switch (type) { case AppConstants.POST: JSONObject jsonObject = DataToUtils.stringToJson(jsonStr); String params0 = AppUtils.buildRequest(mContext, DataToUtils.jsonObjectToMap(jsonObject), actionName, true); Map<String, String> headers = new HashMap<String, String>(); headers.put("Content-Type", "application/json"); mLoadControler = RequestManager.getInstance().request(mInstance, Method.POST, url, params0, headers, requestListener, false, 30000, 0, 0); break; case AppConstants.GET: mLoadControler = RequestManager.getInstance().get(mInstance, url, requestListener, 1); break; case AppConstants.FILEUPLOAD: RequestMap params2 = new RequestMap(); File uploadFile = new File(uploadFileName); params2.put("uploadFile", uploadFile); mLoadControler = RequestManager.getInstance().post(mInstance, url, params2, requestListener, 2); break; default: break; } }
三、Web资源防破解
我们都知道,对于原生Android代码有混淆和加固两种常规的保护方式,默认读者已经会使用了。那么在混合式开发框架的App中,Web层资源如何保护呢?常规的压缩、混淆和加密这里不表,仅仅是Web层的资源包(包括html、appcache、javascript、json、图片等),如何防止被外界破解呢?
我们在将Web资源包下发的时候,并不是文件夹的形式,而是通过将其压缩成zip包,并设置密码。
在加载的时候,以流的形式读入HttpServer。除了随apk发版的Web资源包是一个约定的密码,后续的Hotfix形式的资源包都是动态密码下发,即密码在资源包之前下发。之所以不选择与资源包一起下发,是降低密码与资源包一起被截获的可能,虽然这里的密码传输也使用了前面所述的加密。
使用zip4j读取密码zip包参考:
public void initRootFile(ZipFile zf, String psw) throws IOException, ZipException{// zf = new ZipFile(rootFile); if(zf.isEncrypted()){ zf.setPassword(psw); } List<FileHeader> files=zf.getFileHeaders(); for(FileHeader fh:files){ System.out.println(fh.getFileName()); fileIndex.put(fh.getFileName(), fh); zipFileMap.put(fh.getFileName(), zf); } }
HttpServer中的HttpStaticZipHandler实现参考:
public class HttpStaticZipHandler implements HttpRequestHandler { private ZipVFS vfs=null; /** * Construct a new static file server * * @param documentRoot * The document root * @throws ZipException * @throws IOException */ public HttpStaticZipHandler(String zipFile,String psw) throws IOException, ZipException { vfs=ZipVFS.getInstance(); ZipFile zf = new ZipFile(zipFile); vfs.initRootFile(zf, psw); } @Override public HttpResponse handleRequest(HttpRequest request) { String uri = request.getUri(); try { uri = URLDecoder.decode(uri, "UTF-8"); } catch (UnsupportedEncodingException e1) { uri = uri.replace("%20", " "); } ZipInputStream zis=null; String path=uri.toString(); try { if(path.startsWith("/")){ path=path.substring(1); } zis=vfs.getFileInputStream(path); } catch (IOException e1) { e1.printStackTrace(); return new HttpResponse(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.toString()); } catch (ZipException e1) { e1.printStackTrace(); return new HttpResponse(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.toString()); } if (zis!=null) { try { HttpResponse res = new HttpResponse(HttpStatus.OK, zis); res.setResponseLength(vfs.getFileSize(path)); if (uri.endsWith(".css")) { res.addHeader("Content-Type", "text/css"); } res.addHeader("Access-Control-Allow-Origin", "*"); res.addHeader("Access-Control-Allow-Headers", "X-Requested-With"); res.addHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); return res; } catch (IOException e) { e.printStackTrace(); } } return null; }}
四、Https通信
大家都知道Https要比Http安全,现在几乎讲究一点的App通信都将Http切换到了Https上。
但是由于Android的共享证书机制,需要在应用里放一张与服务器对应的证书并进行校验。通过对网络请求框架(比如Volley)的改造,传入不同的证书rawId,可以达到多域名证书校验的效果(针对一个App需要请求不同业务后台的Https域名)。
public static RequestQueue newRequestQueue(Context context, HttpStack stack, boolean selfSignedCertificate, int rawId) { File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR); String userAgent = "volley/0"; try { String packageName = context.getPackageName(); PackageInfo info = context.getPackageManager().getPackageInfo( packageName, 0); userAgent = packageName + "/" + info.versionCode; } catch (NameNotFoundException e) { } if (stack == null) { if (Build.VERSION.SDK_INT >= 9) { if (selfSignedCertificate) { stack = new HurlStack(null, buildSSLSocketFactory(context, rawId)); } else { stack = new HurlStack(); } } else { // Prior to Gingerbread, HttpUrlConnection was unreliable. // See: // http://android-developers.blogspot.com/2011/09/androids-http-clients.html if (selfSignedCertificate) stack = new HttpClientStack(getHttpClient(context, rawId)); else { stack = new HttpClientStack( AndroidHttpClient.newInstance(userAgent)); } } } Network network = new BasicNetwork(stack); RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheDir), network); queue.start(); return queue; }
另外需要将域名证书强校验打开:
1protected HttpURLConnection createConnection(URL url) throws IOException {2 //访问https,信任SSL开关3 if (url.toString().toLowerCase(Locale.CHINA).startsWith("https")) {4// HTTPSTrustManager.allowAllSSL();5 //证书&域名强验证 6HttpsURLConnection.setDefaultHostnameVerifier(org.apache.http.conn.ssl.SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);7 }8 return (HttpURLConnection) url.openConnection();9 }
五、防篡改和防重放
那么如何去做呢?sessionId在登录的时候下发。要知道单纯地使用sessionId也就是token机制,并不能防篡改和防重放攻击。
这里我们对两个约定的参数(sessionId+时间戳)+密文再MD5,服务端通过相同方法比对。也就是常说的签名和解签机制。
客户端签名参考:
/** * 请求报文的ACTION_TOKEN部分 * @param actionInfo * @return */ public static JSONObject getSignToken(String actionInfo){ JSONObject signToken = new JSONObject(); String timeStamp = System.currentTimeMillis() + ""; addData(signToken, "USERID", PayCommonInfo.userId); addData(signToken, "TIMESTAMP", timeStamp); addData(signToken, "SIGN", AppUtils.encryptMD5(PayCommonInfo.sessionId + timeStamp + actionInfo)); return signToken; }
服务端接到这个请求的处理逻辑:
先验证SIGN签名是否合理,证明请求参数没有被中途篡改,再验证这个MD5值是否已经有了,证明这个请求不是一段时间内(比如1个小时)的重放请求。
总结
我上面只列出了,当前混合式架构App中有关安全通信方面的一些关键技术点,特别是一二三四点具有一定的创新性。除与Web相关的技术点外,其它都可用于纯原生架构的App。当然呢,安全通信还有一些其它的小细节不详细罗列了。前述所有实践经过了行业多年的时间检验,这包括经多次专业机构(如安恒信息)的渗透测试与代码整改。
参考文章:
[1]https://mp.weixin.qq.com/s/1lOvKBjL2qlRlLHP4rHONg
[2]https://mp.weixin.qq.com/s/gKt-p1xutxl9KH9F9iBbMg
[3]https://www.cnblogs.com/lexiaofei/p/7297400.html
[4]《Android高级进阶》
也许你还想看
(▼点击文章标题或封面查看)
2018-08-30
2019-04-18
2018-08-16
加入搜狐技术作者天团
千元稿费等你来!
戳这里!☛