查看原文
其他

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

姑苏流风 搜狐技术产品 2021-01-15

本文字数: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<StringString> headers = new HashMap<StringString>();
            headers.put("Content-Type""application/json");
            mLoadControler = RequestManager.getInstance().request(mInstance, Method.POST, url, params0, headers, requestListener, false3000000);
            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


加入搜狐技术作者天团

千元稿费等你来!

戳这里!☛


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存