这篇文章来总结下 Android app 的安全防护手段。
目录:
- 资源混淆
- 代码混淆
- 签名校验
- 反调试
- 组件安全
- Webview 的代码执行漏洞
- 加固
- 编码安全
- 动态加载
- hook
- 数据存储安全
- 数据传输安全
- 内存数据安全
1. 资源混淆
资源混淆可以使用微信团队的 AndResGuard,这样能保护我们设计师辛苦设计的成果。AndResGuard 教程:APP瘦身大法--AndResGuard的使用。
2. 代码混淆
这个就不多说了,基本都会做。教程:Android 代码混淆 混淆方案
3. 签名校验
签名验证,就是在 APP 中写入自己私钥的 hash 值,和一个获取当前签名中的私钥 hash 值的函数两个值相一致,那么就说明 APP 没有被改动允许 APP 运行。如果两值不一致那么说明 APP 是被二次打包的,APP 就自我销毁进程。
可以在两个地方进行校验:
- app 启动类,校验代码是放在 Java 层的 (虽然私钥 hash 放在 so 中),但是还是容易被调试出 hash 值。
- 在 JNI_OnLoad 中做,缺点是比较复杂。原理是通过载入 so (Java 层),然后在 build_gradle 加入预编译语句,最后会自动触发 JNI_OnLoad() 函数,然后在里面做签名校验。但是这种方式,如果破解者找到了代码,再通过各种手段注释掉加载 so 的代码,也可以破解。只不过在代码混淆后难度大了很多。
4. 反调试
有两种方式:
- 一个进程同时最多只能被一个进程所调试,所以可以自己使用 ptrace() 函数假装自己在调试自己,占住调试的位置以此来拒绝别的进程的调试请求。
- 查看 /proc/{pid}/status 文件如果发现 TracerPid 的值不等于 0 (TracerPid 是调试进程的 pid,如果不为 0 则表示有进程在调试),则 kill 掉自己。
要强调两点,一个是这里是反调试只有 ida 进行动态分析时才能起到防护效果,ida 静态打开还是不能阻止的。第二个是这里是反调试,自己开发过程中使用 IDE debug 也是调试,如果加了以下代码那 IDE debug 时进程也会自我销毁的(实际发现 IDE 中 run 也是不行的)。
5. 组件安全
(1) Activity
首先是访问权限控制,组件处理不好会导致你的应用被恶意程序进行恶意的页面调用。作为研发人员,我们可以从以下三点来预防和避免这个问题:
- 私有的 Activity 不应该被其他应用启动且应该确保相对是安全的。
- 关于 Intent 的使用要谨慎处理接受的 Intent,以及其携带的信息尽量不发送敏感信息,并进行数据校验。
- 设置 Android:exported 属性,不需要被外部程序调用的组件应该添加 android:exported="false" 属性。
(2) Service
常见的漏洞有 Java 空指针异常导致拒绝服务和类型转换异常导致的拒绝服务两种。应对 Service 带来的安全问题给大家三点建议:
- 我们应用在内部尽量使用 Service 设为私有,如果确认这个服务是自己私有的服务,就把它确认为私有,不要作为一个公开的服务。
- 针对 Service 接收到的数据应该进行校验。
- 内部的 Service 需要使用签名级别的 protectionle。
(3) BroadcastReceiver
BroadcastReceiver 会处理 Intent 发起的异步请求,要求注意以下安全要点:
- 声明 exported 属性。默认情况下,接收器会被导出,而且可以由任何其他应用调用,类似于 Service,要求显示地声明android:exported 属性。
- 设置收发权限。如果 BroadcastReceiver 预期供其他应用使用,应该在应用清单中向接收器设置安全权限。这样可防止没有相应权限的应用向 BroadcastReceiver 发送 intent。
6. Webview 的代码执行漏洞
关于这个,可以看看我这篇文章:Android应用篇 - WebView 与 JS 全解与实战
7. 加固
可以使用第三方加固平台进行加固,当然加固也有被脱壳的风险,不过也是一层保护。
8. 编码安全
- 重要的字符不要硬编码在 Java 层,因为 String 内容是不会被混淆的,可以放在 JNI 中,JNI 函数也可以再加上一层保护措施。
- 限制对变量的访问,让每个量的方法都成为 final,尽量使你的类不要被克隆,如果一旦允许被克隆,它可能会绕过这个类轻易地复制类的属性。
- Android 中很多会使用到序列化,如果自己觉得这个类是有风险的,或者说安全性是很高的,尽量不要让它序列化。
9. 动态加载
Android 官方表示,强烈建议不要从应用 APK 外部加载代码。这样做不仅会明显加大应用因代码注入或代码篡改产生问题的可能性,还会增加版本管理和应用测试的难度。这最终会导致无法验证应用的行为,因此,某些环境中可能会禁止采用此做法。
因此,尽管插件化和热更新技术在国内表现得比较热门,但是其风险性值得关注。
如果必要适用动态加载,要求:
- 动态加载的代码需要拥有与应用本身相同的安全策略和权限水平。
- 外部代码必须来自可验证的信任来源。
不安全的位置包括:通过未加密的协议从网络上下载,外部存储设备。这类代码可能遭到篡改,从而执行某些恶意操作。
10. hook
如何防止应用被 hook,以支付宝防 hook 为例:
- Xposed 框架将 hook 信息存储在字段 fieldCache,methodCache,constructorCache 中, 利用 Java 反射机制获取这些信息,检测 hook 信息中是否含有支付宝 App 中敏感的方法,字段,构造方法。
- 检测进程中使用 so 名中包含关键 "hack|inject|hook|call" 的信息,这个主要是防止有的人不用这个 Xposed 框架,而是直接自己写一个注入功能。
- root 检测原理是:是否含有 su 程序和 ro.secure 是否为 1。
- 防止被 hook 的方式就是可以查看 XposedBridge 这个类,有一个全局的 hook 开关,所有有的应用在启动的时候就用反射把这个值设置成 true,这样 Xposed 的 hook 功能就是失效了。
- 如果应用被 Xposed 进行 hook 操作之后,抛出的异常堆栈信息中就会包含 Xposed 字样,所以可以通过应用自身内部抛出异常来检测是否包含 Xposed 字段来进行防护。
11. 数据存储安全
- SharedPreferences 存储加密解密方式:对 key 和 value 同时加密,存储类型都为 String 类型,数据读取时根据需要进行类型转换。
- File 文件存储加密解密方式:对数据流进行加密解密。
- SQLite 数据库存储加密解密方式:基于 Sqlcipher 进行实现。
数据使用对称加密,肯定会涉及到秘钥的安全问题:
密钥的保存如果将密钥保存到手机文件中,或者通过硬编码的方式写在代码中,容易被逆向出来,在通常情况下,采用对称加密密钥需要保存在用户手机中,这和安全性想违背。通常最好的方式是不要保存密钥,通过固定数据或者字符串做加密密钥因子,例如用户唯一账号属性等。
编码方式 Android 代码主要有 Java 编码,打包文件时 Java 代码打包成 dex 文件防到安装包文件中,但是 dex 文件容易被逆向回 smali 代码或者 Java 文件。虽然目前混淆和加壳甚至是虚拟机保护 (VMP) 技术已经很成熟,简单逆向工作无法获取代码逻辑和硬编码字符串,但是 Java 代码依然存在很高的安全风险。因此,将加解密相关操作通过 Native 代码实现很有必要,不仅保证效率而且在 so 保护技术之上安全性更高。
在 Android 数据存储安全中,由于 Android 系统的安全机制,用户获取 root 权限后可以访问手机所有目录,包括应用私有目录,因此,数据存储要考虑到一个白盒环境,或者非可信环境。这种情况下,数据加密的密钥成为关键。一机一密、动态密钥、密钥白盒等手段各有优缺点。
- 一机一密需要保护密钥生成方法逻辑。可以使用加盐处理,加入随机规则,当然这个盐服务器需要存储。
- 动态密钥需要考虑密钥时效性,有效性以及链路安全。
- 密钥白盒由于目前没有广泛认可,在兼容性安全性方面有待考验。
12. 数据传输安全
数据传输安全主要是网络传输,可以使用 https,关于 https 可以看看这篇文章:网络篇 - https协议中的数据是否需要二次加密
防护办法:
使用 CA 机构颁发证书的方式可行,但是如果与实际情况相结合来看的话,时间和成本太高,所以目前很少有用此办法来做。由于手机应用服务器其实是固定的,所以证书也是固定的,可以使用"证书或公钥锁定"的办法来防护证书有效性未作验证的问题。
- 公钥锁定
将证书公钥写入客户端 apk 中,https 通信时检查服务端传输时证书公钥与 apk 中是否一致 (实现 X509TrustManager接口)。
public final class PubKeyManager implements X509TrustManager{
private static String PUB_KEY = "30820122300d06092a864886f70d0101" + "0105000382010f003082010a0282010100b35ea8adaf4cb6db86068a836f3c85" +"5a545b1f0cc8afb19e38213bac4d55c3f2f19df6dee82ead67f70a990131b6bc" + "ac1a9116acc883862f00593199df19ce027c8eaaae8e3121f7f329219464e657" +"2cbf66e8e229eac2992dd795c4f23df0fe72b6ceef457eba0b9029619e0395b8" + "609851849dd6214589a2ceba4f7a7dcceb7ab2a6b60c27c69317bd7ab2135f50" +"c6317e5dbfb9d1e55936e4109b7b911450c746fe0d5d07165b6b23ada7700b00" + "33238c858ad179a82459c4718019c111b4ef7be53e5972e06ca68a112406da38" + "cf60d2f4fda4d1cd52f1da9fd6104d91a34455cd7b328b02525320a35253147b" + "e0b7a5bc860966dc84f10d723ce7eed5430203010001";
// 锁定证书公钥在apk中
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null) {
throw new IllegalArgumentException("checkServerTrusted: X509Certificate array is null");
}
if (!(chain.length > 0)) {
throw new IllegalArgumentException("checkServerTrusted: X509Certificate is empty");
}
if (!(null != authType && authType.equalsIgnoreCase("RSA"))) {
throw new CertificateException("checkServerTrusted: AuthType is not RSA");
}
// Perform customary SSL/TLS checks
try {
TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
tmf.init((KeyStore) null);
for (TrustManager trustManager : tmf.getTrustManagers()) {
((X509TrustManager) trustManager).checkServerTrusted(chain, authType);
}
} catch (Exception e) {
throw new CertificateException(e);
}
// Hack ahead: BigInteger and toString(). We know a DER encoded Public Key begins
// with 0×30 (ASN.1 SEQUENCE and CONSTRUCTED), so there is no leading 0×00 to drop.
RSAPublicKey pubkey = (RSAPublicKey) chain[0].getPublicKey();
String encoded = new BigInteger(1 /* positive */, pubkey.getEncoded()).toString(16);
// Pin it!
final boolean expected = PUB_KEY.equalsIgnoreCase(encoded);
if (!expected) {
throw new CertificateException("checkServerTrusted: Expected public key: " + PUB_KEY + ", got public key:" + encoded);
}
}
}
公钥不要硬编码在 Java 代码中,可以放在 JNI 中。
- 证书锁定
即为客户端颁发公钥证书存放在手机客户端中 (使用 keystore),在 https 通信时,在客户端代码中固定去取证书信息,不是从服务端中获取,当然应用得先加固保证证书安全。
13. 内存数据安全
对于重要的内存数据,比如密码,最好不要长时间驻留在内存中,用完立即销毁。如果非要长时间驻留,可以考虑加密加盐方式。