安卓应用常被用于处理非常敏感的数据。开发者有责任确保用户提供的信息不被居心不良者轻易截取。开放式 Web 应用安全项目(OWASP)[9,10]尝试着列举移动应用潜在的安全问题。
其中一些是系统架构师的责任(例如弱服务器端控制有关的问题),一些是后端开发者的责任(授权检查相关问题),最后一些就是纯粹与移动应用本身有关。本文我们将关注通过安卓开发者努力可以解决的问题。
因此我们将在这里提出三个潜在的漏洞源:使用网络服务(WS)通信时的风险、在存储设备上存储数据时潜在的信息泄漏以及第三方软件能轻易编辑应用程序的漏洞。
一、安全的网络服务调用
对于使用WS的敏感应用,最重要的就是保证用户分享在后台的数据是安全的。事实上,如果网络上的请求能被轻易截取,最安全的应用程序也是毫无用处的。
威胁:中间人攻击(MITM)
应用程序遭受中间人攻击有两个主要的风险。
1.信息泄漏
如果窃取者控制了用户使用应用的本地网络,他暗地里轻易就能截取到 app 和 WS 的所有通信。
2.网络服务(WS)模仿
对 WS 有一定认识的人可以阻塞应用调用并且提供伪造的回应,这时用户认为他们的请求已经执行,然而请求根本没有到达后台。
测试应用在中间人攻击时的脆弱性相当简单:你只需要使用一个代理软件(例如CharlesProxy[12]),然后建立设备来使用安装了这个代理的机器。如果应用不能阻止中间人攻击,你将能看到它执行的每个请求。现在,想象你的 app 用户通过“不安全”的网络连接到你的网络服务:窃取者可以毫不费力地将代理安装在网络路由器上,就能嗅到所有不加密的请求。
攻击源:TLS/SSL证书链
保证通信安全至少要使用 HTTPS 协议,也就是说使用安全传输层协议(TLS)或是它的前身安全套接层协议(SSL)加密的通信。
然而,如果有必要,这并不是系统需要遵守的唯一条件。为了理解清楚,我们来看看 SSL 协议的工作原理。
SSL 证书(至少)包括三个证书:
- 根证书。这是由证书认证机构(CA)颁发的,也就是一个可以确保整个通信时安全的值得信任的组织。
- 中间证书。有许多个中间证书。它们建立终端用户证书和根证书的连接,是为提供WS的服务器服务,由根证书签名的证书。
- 终端用户证书。终端用户证书是适用于WS物理服务器的证书。
安卓 SSL 本地保护:
安卓网络层有一个内置的CA 证书列表(超过一百个,你可以在设备参数中检查这个列表)。每个HTTPS网络调用需要在证书链上有一个CA证书。
然而,没有办法确保其余的链适合我们想要连接的服务器。例如一个窃取者可以向CA购买中间证书实施中间人攻击。所有的网络事务都将被系统视作无效。这种漏洞非常常见:一个研究表明[1]73%使用HTTPS 协议的应用并没有用正确的方式检查证书。
怎样保证连接的是我们的后台并且这个连接是安全?
解决上述问题的方式是手动检查中间证书(适用于特定服务器)是已知证书。这意味着我们需要将特定的服务器证书存储在应用中,可以将它作为常数存储在资源文件或直接放在源代码中来实现。
我们可能会疑惑为什么需要检查中间证书而不是终端用户。这里有两个理由,第一,终端用户证书生存期很短。第二点是安全理由:想象一下,一个黑客完全控制了系统,那他将拥有你的私钥(服务器需要私钥签名请求)。应用会认为请求是由正确的终端用户私钥签名,并且会允许连接。如果认证在中间服务器检查时实现,那就有可能通过联系中间CA远程废除证书。
SSLSocketFactory 类可以验证一个 SSL 连接是否安全。创建一个执行中间证书检查的 SSLSocketFactory,我们需要执行以下步骤。
1.创建一个继承于 X509TrustManger 的类。这是一个 java.net.ssl 包中的抽象类,用于检查服务器端 SSL socket 的合法性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
public class MyX509TrustManager implements X509TrustManager { private X509Certificate certificate; public MyX509TrustManager (InputStream knownIntermediateCertificate) throws CertificateException { CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); certificate = certFactory.generateCertificate(knownIntermediateCertificate); } @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { // Do nothing. We only want to check server side certificate. } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { // Verify that the certificate domain name matches the expected one if (!chain[0].getIssuerDN().equals(certificate.getSubjectDN())) { throw new CertificateException("Parent certificate of server was different than expected signing certificate"); } try { // Verifiy that the certificate key matches the expected one chain[0].verify(certificate.getPublicKey()); // Verify that the certificate has not expired chain[0].checkValidity(); } catch (Exception e) { throw new CertificateException("Parent certificate of server was different than expected signing certificate"); } } @Override public X509Certificate[] getAcceptedIssuers() { // Do nothing return new X509Certificate[0]; } } |
2.设置一个新的默认 SSLSocketFactory。代码需要在网络调用前运行。
1 2 3 4 5 6 7 8 9 10 11 12 |
TrustManager[] trustManagerArray = new TrustManager[1]; MyTrustManager trustManager = new MyTrustManager(TRUSTED_CERTIFICATE); trustManagerArray[0] = trustManager; final SSLContext sslc; // TLS is the last SSL protocol and is used by all the CA sslc = SSLContext.getInstance("TLS"); // We only need to give a TrustManager list as we don't need to perform client authentification sslc.init(null, trustManagers, null); HttpsURLConnection.setDefaultSSLSocketFactory(sslc.getSocketFactory()); |
证书检查潜在缺陷
1.中间证书会过期(其生存期大概是10年)。在证书过期前将新的证书添加到白名单可以有效检测证书的变化。
2.中间证书认证机构会失效。如果中间证书认证机构失效,它所提供的安全机制将会完全无效。事实上,如果黑客保留了中间 CA ,他将能够伪造证书链,拥有和你的证书链相同的中间证书。此时他就能实施中间人攻击。尽管CA理论上是安全的,依然可能发生像2011年Dignotar证书失效的事件。一旦发生这种情况,唯一能做的事情就是改变服务器上的SSL证书链,并且发布一个内嵌新的中间证书的版本。
3.SSLSocketFactory 认为它的策略适用于应用中的所有网络调用。如果sdk是内置的,那就有必要为远程服务器的这些sdk嵌入中间证书。存在的不确定性是无法轻易察觉服务器证书的变化。
动态植入证书会遇到这种问题。应用只允许一个证书(主服务器一个)并且在应用运行时获取一个授权中间证书的动态列表,然后将这些证书添加到 SSLContext 的 trust manager中。
总而言之,在大多数情况下,中间检查机制能够防御中间人攻击。黑客窃听通信时,他必须将自己的证书列入证书链, TrustManager不能识别这个证书,于是拒绝 HTTPS 连接。
二、设备上的存储安全
多亏了SharedPreferences 接口,安卓平台提供了一种方便的方式存储参数以及大文件。尽管存储在shared preferences中的数据隐藏在隐藏目录中,如果设备获得了root权限,那就可以轻而易举地恢复数据。
因此,如果存储在应用中的是敏感数据,有必要加密shared preferences中的数据。有两种方式:
1.使用密码库加密/解密 SharedPreferences 的 values(最后是key)。有许多形式的 java 密码库,例如 javax.crypto、Bouncycastle[2] 和 Concealed[3]。
2.使用提供 SharedPreferences 包装类的库。这些密码库使用很方便,开发者不需要关心使用什么算法。然而,使用这些库会失去灵活性并且有些并不使用安全算法。因此,它们并不适合存储敏感的数据。SecurePreference[4] 是其中一个最常用的提供这种包装特性的库。如果你选择这种方式,你可以用非常直接的方式实例化一个继承自SharedPreferences 的 SecurePreferences 类:
1 |
SecurePreferences securePreferences = new SecurePreferences(context, "MyPassword", null); |
这两种方法是基于例如AES(有一个合适的key 大小)的对称密码学。带来的疑问是:我们应该使用哪种key?事实上,如果我们使用一个静态key,可以通过反编译应用解密参数。所以,最好的解决方式是在应用启动时使用用户输入的pin码/通行码。或者使用 Fingerprint API [15](需要API 23以上版本),可以提供一种安全流畅的认证方式。
不幸的是,这种方法不能满足每个应用的用户体验。例如如果我们想要在pin码输入前显示存储的信息,那我们就不能使用安全加密系统。
幸好,安卓提供了一种安全的方式来为一些应用程序/设备生成特定的key: KeyStore。安卓 KeyStore 是为了允许应用将私钥放在不能被其他应用的地方,或者是不能通过实质性访问存储在设备上的数据获取私钥的地方。
机制非常简单:首先,运行应用检查应用相关的私钥是否存在。如果不存在,就生成一个并存储在KeyStore 中。如果私钥存在,它可以成为安全密钥来解析SharedPreferences 的数据,这多亏了上文描述的算法。
Obaro Ogbo写了一篇详细的文章深刻描绘了如何使用 KeyStore 生成私钥/公钥对。KeyStore 主要的缺点是只允许API18以上版本使用。但是有一个兼容API14以上版本的补丁库(这不是“官方”的布丁,所以你必须自己承担后果)。
因此,我们建议当需要决定优先使用哪种系统时可以参考下面的策略图:
三、防止应用被源代码分析和修改
有时,安卓开发者不希望应用被其他人分析,解读,最后被修改。这种要求有各种理由:
- 我们不希望黑客移除用于阻止非付费用户使用某些功能的应用锁。
- 我们开发的敏感应用时的风险是,黑客会将应用修改成所有输入信息都会返回给他。尽管应用商店不容易发生这种情况,但是用户可以去许多其他地方下载到伪造的应用,这些应用会用完全透明的方式偷取他所有的数据。
每个安卓开发者都应该注意,当开发敏感应用时,经验丰富的人反编译一个安卓应用是相当简单的。特别是使用“本地”应用配置的情况。事实上,因为大多数安卓app的特性(使用 Java 字节码),反编译字节码后解读,改正,最后重建一个修改过的应用非常简单。
这部分我们会强调一些可以规避风险的技术工具和构建原则。同时,我们需要注意,因为应用程序运行在客户端设备上,我们并没有百分之百确信的方法来规避这些风险。
1.有价值的算法写在服务器端
下面是构建指南。如果你的应用程序价值是以算法为基础的,你当然不希望有人能轻易解读、复制并且将你的算法嵌入自己的应用程序中。此时,最好的解决方式是在服务器端实现算法。应用程序只需提供待处理的数据给服务器,然后获取算法的返回值。这种结构的明显缺点是,在离线状态下无法使用app 的核心功能。
2.防止WS完全开放
如果应用程序的功能是借助 WS 获取数据,你可以发送在认证阶段获得的session token 或者在每个请求中授予user / password 来保证WS 安全。如果仅仅是在app 参数中使用认证标志,将这个标志设置成“always connected”就可以轻松修改应用程序代码。这么做的风险是用户需要定期输入使用过的id和密码来延长会话。
3.使用Proguard混淆代码
Proguard 是 Java 工程中常用的工具。Proguard 工具执行三个操作:压缩步骤(移除无用代码)、优化步骤(内联方法、移除无用方法等)、混淆步骤。在最后一个步骤中,Proguard 会重命名Java 文件中所有类、属性、方法名,为了使它们在字节码被反编译后难以辨认。Proguard 当然也确保 JVM 能够区分不同的编译元素。
这个工具的有趣之处在于它使反编译字节码可读性变差了很多。然而,尽管代码元素被重命名,通过逆向工程化应用的方式依然可能猜测出混淆后的方法和属性的功能。Proguard 也生成了一个 mapping 文件,可以将混淆的 stacktrace 转换成可读状态[6]。
网上有许多详细解释如何配置Proguard 的指导,比如在安卓系统文档中的配置[7]。
4.使用编译库
通过Java本地接口(JNI),我们可以使用 C/C++ 语言写的本地代码(已编译代码)并且与Java 代码交互。开发安卓程序时会更简单,因为 NDK 提供了可以在应用程序中使用已编译代码的工具。整体机制很简单:编译你的 C/C++代码(必须包含标准的JNI 入口点),获得一个 .so 文件。这时你就在应用程序工程中包含了库和java 接口。
编译库的主要优势是,反编译代码可读性更差,因为 .so 文件使用本地机器语言而不是java 字节码。用 C/C++ 开发应用程序高度敏感的部分(例如最高机密算法或是安全层)然后与其余用传统 Java 编写的部分交互,这将是一个不错的实践(如果实际上很方便)。
使用 NDK 依然有许多缺点:我们必须为应用程序针对的不同类型的硬件结构编译本地库,这样就放弃了产生 crash 时得到适当stacktrace 的可能性,同时它也大大增加了代码结构。
总结
在本文,我们建议的解决方法覆盖了 3 个 OWASP 中排名前十的手机安全问题[9]。像我们介绍中提到的,只有当系统结构是安全的,应用程序才是安全的。一个人可以开发技术上安全的应用,但如果服务器没有授权良好的认证系统,所有的努力都是无意义的。同时,确保完美的安全边界是手机开发者的责任,这篇文章给出了覆盖安全边界的解决方法。
参考
[2] http://www.bouncycastle.org/
[4] https://github.com/scottyab/secure-preferences
[5] http://geeknizer.com/decompile-reverse-engineer-android-apk/
[6] http://proguard.sourceforge.net/manual/retrace/examples.html
[7] http://developer.android.com/tools/help/proguard.html
[9] https://www.owasp.org/index.php/OWASP_Mobile_Security_Project#tab=Top_10_Mobile_Risks
[10] https://www.owasp.org/index.php/About_OWASP
[11] http://www.androidauthority.com/use-android-keystore-store-passwords-sensitive-information-623779/
[12] http://www.charlesproxy.com/
[13] https://threatpost.com/final-report-diginotar-hack-shows-total-compromise-ca-servers-103112/77170/
[14] https://github.com/pprados/android-keychain-backport
[15] https://developer.android.com/about/versions/marshmallow/android-6.0.html#fingerprint-authentication