干货!混合式架构App当中的通信安全

阅读 8
收藏 1
2019-10-18
原文链接:mp.weixin.qq.com

本文字数: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

新闻推荐系统的CTR预估模型

2019-04-18

互联网架构演进之路

2018-08-16

加入搜狐技术作者天团

千元稿费等你来!

戳这里!☛

评论