Android应用安全解决方案

856 阅读6分钟

前言

防止第三方反编译篡改应用,防止数据隐私泄露,防止二次打包欺骗用户。

1、一些必要的基础知识

我们在加密的时候会用到一些加密或者编码方法。常见的有,非对称加密算法 RSA 等;对称加密算法 DES、3DES 和 AES 等;不可逆的加密 MD5、SHA256 等。

另外,我们会把重要的加密逻辑放到 Native 层来实现,所以一些 JNI 编程的方法也是需要的。不过,如果仅仅是用来作加密的话,对 C/C++ 的要求是没那么高的。对在 Android 中使用 JNI,可以参考我之前的文章《在 Android 中使用 JNI 的总结》。

2、签名校验

2.1 基础签名校验

在应用和 so 中作签名校验可以说是最基本的安全策略。在应用中作签名校验可以防止应用被二次打包。因为如果别人修改你的代码,肯定要重新打包,此时签名必然会改变。对 so 作签名校验是很有必要的,除了防止应用被打包,也可以防止你的 so 被别人盗用。

可以使用如下的代码在 java 中进行签名校验,

private static String getAppSignatureHash(final String packageName, final String algorithm) {    if (StringUtils.isSpace(packageName)) return "";    Signature[] signature = getAppSignature(packageName);    if (signature == null || signature.length <= 0) return "";    return StringUtils.bytes2HexString(EncryptUtils.hashTemplate(signature[0].toByteArray(), algorithm))            .replaceAll("(?<=[0-9A-F]{2})[0-9A-F]{2}", ":$0");}

对于在 Native 层作签名校验,将上述方法翻译成对应的 JNI 调用即可,这里就不赘述了。

上面是签名校验的逻辑,看似美好,实际上稍微碰到有点破解的经验的就顶不住了。我之前遇到的一种破解上述签名校验的方法是,在自定义 Application 的 onCreate() 方法中读取 APK 的签名并存储到全局变量中,然后 Hook 获取应用签名的方法,并把上述读取到的真实的签名信息返回,以此绕过签名校验逻辑。

2.2 Application 类型校验

针对上述这种破解方式,我想到的第一个方法是对当前应用的 Application 类型作校验。因为他们加载 Hook 的逻辑是在自定义的 Application 中完成的,如果他们的 Application 和我们自己的 Application 类路径不一致,那么可以认定应用为破解版。

不过,这种方式作用也有限。我当时采用这种策略是考虑到有的破解者可能就是用一个脚本破解所有应用,所以改动一下可以防止这类破解者。但是,后来我也遇到一些“狠人”。因为我的软件用了 360 加固,所以如果加固壳工程的 Application 也认为是合法的。于是,我就看到了有的破解者在我的加固包之上又做了一层加固…

2.3 另一种签名校验方法

上述签名校验容易被 Hook 绕过,我们还可以采用另一种签名校验方法。

记得之前在《使用 APT 开发组件化框架的若干细节问题》 这篇文章中提到过,ARouter 在加载 APT 生成的路由信息的时候,一种方式是获取软件的 APK,然后从 APK 的 dex 中获取指定包名下的类文件。那么,我们是不是也可以借鉴这种方式来直接对 APK 进行签名校验呢?

首先,你可以采用下面的方法获取软件的 APK,

ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);File sourceApk = new File(applicationInfo.sourceDir);

获取 APK 签名信息的方法比较多,这里我提供的是 Android 源码中的打包文件的签名代码,代码位置是:

android.googlesource.com/platform/to…

这样,当我们拿到 APK 之后,使用上述方法直接对 APK 的签名信息进行校验即可。

2.4 Janus签名机制漏洞

打包时选择v1和v2签名

应用签名未校验

增加签名证书的校验代码,降低App被二次打包的几率。

    /**    * 检测签名    */    private boolean checkSignature() {        Context context = WXApplication.getInstance();        try {            PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);            Signature[] signatures = packageInfo.signatures;            if (signatures != null) {                for (Signature signature : packageInfo.signatures) {                    //获取MD5或者SHA1                    MessageDigest md = MessageDigest.getInstance("SHA1");                    md.update(signature.toByteArray());                    String currentSignature = bytesToHexString(md.digest()).toUpperCase();                    if ("YOUR SIGENATURE".equals(currentSignature)) {                        return true;                    }                }            } else {                LogUtil.i("signatures ==null");            }        } catch (NameNotFoundException e) {            e.printStackTrace();            LogUtil.e(e);        } catch (NoSuchAlgorithmException e) {            e.printStackTrace();            LogUtil.e(e);        }        return false;    }    /**     * byte16进制String     *     * @param src 数据源     * @return string     */    public String bytesToHexString(byte[] src) {        StringBuilder stringBuilder = new StringBuilder();        if (src == null || src.length <= 0) {            return "";        }        for (byte by : src) {            int v = by & 0xFF;            String hv = Integer.toHexString(v);            if (hv.length() < 2) {                stringBuilder.append(0);            }            stringBuilder.append(hv);        }        return stringBuilder.toString();    }

3、对重要信息的加密

上述我们提到了一些常用的加密方法,这里介绍下我在设计软件和系统的时候是如何对用户的重要信息作加密处理的。

3.1 使用签名字段防止伪造信息

首先,我的应用在做用户鉴权的时候是通过服务器下发的字段来验证的。为了防止服务器返回的信息被篡改以及在本地被用户篡改,我为返回的鉴权信息增加了签名字段。逻辑是这样的,

  • 服务器查询用户信息之后根据预定义的规则拼接一个字符串,然后使用 SHA256 算法对拼接后的字符串做不可逆向的加密

  • 从服务器拿到用户信息之后会直接丢到 SharedPreference 中(最好加密之后再存储)

  • 当需要做用户鉴权的时候,首先根据之前预定义的规则,对签名字段做校验以判断鉴权信息是否给篡改

  • 如果鉴权信息被篡改,则默认为普通用户权限

除了上述方法之外,为服务器配置 SSL 证书也是必不可少的。现在很多云平台都会提供一年免费的 Trust Asia 的证书(到期可再续费),免费使用即可。

3.2 对写入到本地的键值对做处理

为了防止应用的逻辑被破解,当某些重要的信息(比如上面的鉴权信息)写入到本地的时候,除了做上述处理,我对存储到 SharedPreference 中的键也做了一层处理。主要是使用设备 ID 和键名称拼接,做 SHA256 加密之后作为键值对的键。这里的设备 ID 就是 ANDROID_ID. 虽然 ANDROID_ID 用作设备 ID 并不可靠,但是在这个场景中它可以保证大部分用户存储到本地的键值对中的键是不同的,也就增加了破解者针对某个键值对进行破解的难度。

3.3 重要信息不要直接使用字符串

在代码中直接使用字符串很容易被别人搜索到,一般对于重要的字符串信息,我们可以将其先转换为整数数组。然后再在代码中通过数组得到最终的字符串。比如下面的代码用来将字符串转换为 short 类型的数组,

static short[] getShortsFromBytes(String from) {    byte[] bytesFrom = from.getBytes();    int size = bytes.length%2==0 ? bytes.length/2 : bytes.length/2+1;    short[] shorts = new short[size];    int i = 0;    short s = 0;    for (byte b : bytes) {        if (i % 2 == 0) {            s = (short) (b << 8);        } else {            s = (short) (s | b);        }        shorts[i/2] = s;        i++;    }    return shorts;}

3.4 Jetpack 中的数据安全

除了上面的一些方法之外,Android 的 Jetpack 对数据安全开发了 Security 库,适用于运行 Android 6.0 和更高版本的设备。Security 库针对的是 Android 应用中读写文件的安全性。详情可以阅读官方文档相关的内容:

更安全地处理数据:developer.android.com/topic/secur…

4、增强混淆字典及日志关闭

混淆之后可以让别人反编译我们的代码之后阅读起来更加困难。这在一定程度上可以增强应用的安全性。默认的混淆字典是 abc 等英文字母组成,还是具有一定的可读性的。我们可以通过配置混淆字典进一步增加阅读的难度:使用特殊符号、0oO 这种相近的字符甚至 java 的关键字来增加阅读的难度。配置的方式是,

# 方法名等混淆指定配置-obfuscationdictionary dict.txt# 类名混淆指定配置-classobfuscationdictionary dict.txt# 包名混淆指定配置-packageobfuscationdictionary dict.txt

一般来说,当我们自定义混淆字典的时候需要从下面两个方面考虑,

  1. 混淆字典增加反编译识别难度使代码可读性变差

  2. 减小方法和字段名长度从而减小包体积

对于 o0O 这种虽然可读性变差了,但是代码长度相比于默认混淆字典要长一些,这会增加我们应用的包体积。我在选择混淆字典的时候使用的是比较难以记忆的字符。我把混淆字典放到了 Github 上面,需要的可以自取,

混淆字典:github.com/Shouheng88/…

这既可以保证包体积不会增大,又增加了阅读的难度。不过当我们反混淆的时候可能会遇到反混淆乱码的问题,比如 SDK 默认的反混淆工具就有这个问题(工具本身的问题)。

5、Webview明文存储密码风险

解决方案

    WebView.getSettings().setSavePassword(false)

6、Webview File同源策略绕过漏洞

将不必要导出的组件设置为不导出,并显式设置所注册组件的“android:exported”属性为false如果需要导出组件,禁止使用File域WebView.getSettings.setAllowFileAccess(false);

7、应用数据任意备份风险

AndroidManifest.xml内关闭数据允许备份

application android:allowBackup=false

8、敏感函数调用风险

审核包含敏感行为的函数调用,确保其使用是必要且限制于授权用户的。

restartPackage - 关闭进程

9、随机数不安全使用漏洞

禁止在生成随机数之前调用setSeed()方法设置随机种子或调用SecureRandom类的构造函数SecureRandom(byte[] seed),建议通过/dev/urandom或者/dev/random获取的熵值来初始化伪随机数生成器。

10、URL信息检测

在移动应用的程序代码内部,可能存在大量开发人员或其他工作人员无意识留下的信息内容。URL信息检测就是通过检测移动应用程序代码内部所存在的URL地址信息,尽可能呈现出应用中所有的URL信息,便于应用开发者查看并评估其安全性。移动应用发布包中的URL地址信息,可能会被盗取并恶意利用在正式服务器上进行攻击,攻击安全薄弱的测试服务器以获取服务器安全漏洞或者逻辑漏洞。

解决方案
1、核查并评估所有的URL信息,判断是否存在涉及内部业务等敏感信息的URL地址,进行删除;
2、尽量不要将与客户端业务相关的URL信息以硬编码的方式写在应用客户端中,建议以动态的方式生成所需要请求的URL

11、残留账户密码信息检测

  1. 核查所有残留的账户和密码信息,删除与业务无关的账户和密码。

  2. 尽量不要将与客户端业务相关的账户密码信息以硬编码的方式写在应用客户端中。

12、截屏攻击风险

开发者审查应用中显示或者输入关键信息的界面,在此类Activity创建时设置WindowManager.LayoutParams.FLAG_SECURE属性,该属性能防止屏幕被截图和录制

public class DemoActivity extends Activity {    @Override    public void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE,  WindowManager.LayoutParams.FLAG_SECURE);        setContentView(R.layout.main);    }}

13、未移除有风险的Webview系统隐藏接口漏洞

开发者显式移除有风险的Webview系统隐藏接口。
以下为修复代码示例:
在使用Webview加载页面之前,执行

webView.removeJavascriptInterface("searchBoxJavaBridge_");webView.removeJavascriptInterface("accessibility");webView.removeJavascriptInterface("accessibilityTraversal");

14、Root设备运行风险

开发者应在应用启动时增加对应用运行环境的检测,当发现运行设备为Root设备时,应禁止应用启动。

 @Override    protected void onCreate(Bundle savedInstanceState){        
super.onCreate(savedInstanceState);       
 setContentView(R.layout.activity_splash);       
 if(checkRootPathSU() || checkRootWhichSU()){           
 ToastUtils.showLong(getResources().getString(R.string.illegal_root));           
 finish();            return;        }       
 if(!checkSignature()){            
ToastUtils.showLong(getResources().getString(R.string.illegal_apk_signatures));       
     finish();            return;        }   
 }//通过检测指定目录下是否存在su程序来检测运行环境是否为Root设备//当CheckRootPathSU返回值为true时,禁止应用启动public static boolean checkRootPathSU(){    File f=null;    final String kSuSearchPaths[] = {"/system/bin/", "/system/xbin/", "/system/sbin/", "/sbin/","/vendor/bin/"};    try{        for(int i=0;i<kSuSearchPaths.length;i++)        {            f=new File(kSuSearchPaths[i]+"su");            if(f!=null&&f.exists())            {            return true;            }        }    }catch(Exception e)    {        e.printStackTrace();    }    return false;}//通过which命令检测系统PATH变量指定的路径下是否存在su程序来检测运行环境是否为Root设备//当CheckRootWhichSU返回值为true时,禁止应用启动public static boolean checkRootWhichSU() {    String[] strCmd = new String[] {"/system/xbin/which","su"};    ArrayList<String> execResult = executeCommand(strCmd);    if (execResult != null){        return true;    }else{        return false;    }}public static ArrayList<String> executeCommand(String[] shellCmd){    String line = null;    ArrayList<String> fullResponse = new ArrayList<String>();    Process localProcess = null;    try {        localProcess = Runtime.getRuntime().exec(shellCmd);    } catch (Exception e) {        return null;    }    BufferedWriter out = new BufferedWriter(new OutputStreamWriter(localProcess.getOutputStream()));    BufferedReader in = new BufferedReader(new InputStreamReader(localProcess.getInputStream()));    try {        while ((line = in.readLine()) != null) {            fullResponse.add(line);        }    } catch (Exception e) {        e.printStackTrace();    }    return fullResponse;}

15、不安全的浏览器调用漏洞

开发者需要在应用调用外部浏览器时对引擎版本进行检测,当发现调用Chrome V8引擎并且版本低于4.2时停止调用外部浏览器并且提示用户对调用的系统浏览器进行升级或者修改系统默认的浏览器。

关注我获取更多知识或者投稿