[译]再谈如何安全地在 Android 中存储令牌

做为本文的序言,我想对读者作一个简短的声明。下面的引言对本文的后续内容而言十分重要。html

没有绝对的安全。所谓的安全是指利用一系列措施的堆积和组合,来试图延缓必然发生的事情。前端

大约 3 年前,我写了一篇文章,给出了几种方法来防止潜在攻击者反编译咱们 Android 应用窃取字符串令牌。为了便于回忆,也为了防止不可避免的网络瘫痪,我将会在此从新列出一些章节。java

客户端应用与服务端的交互是最多见的场景之一。数据交换时的敏感度差异很大,而且登陆请求、用户数据更改请求等之间交换的数据类型也变化多样。react

首先要提到并应用的技术是使用 SSL(安全套接层)连接客户端与服务端。再看一下文章开头的引言。尽管这样作是一个良好的开端,但这并不能确保绝对的隐私和安全。android

当你使用 SSL 链接时(也就是当你看到浏览器上有一个小锁时),这意味着你与服务器之间的链接被加密了。理论上讲,没有什么可以访问到你请求里的信息(*)ios

(*)我说过绝对的安全不存在吧?SSL 链接仍然能够被攻破。本文不打算提供全部可能的攻击手段列表,只想让你了解几种攻击的可能性。好比,能够伪造 SSL 证书,或者进行中间人攻击。git

咱们继续。假设客户端正在经过加密的 SSL 通道与后台连接,它们在愉快的交换有用的数据,执行业务逻辑。可是咱们还想提供一个额外的安全层。github

接下来要采起的措施是在通讯中使用受权令牌或 API 密钥。当后台收到一个请求时,咱们如何判断该请求是来自认证的客户端而不是任意一个想要获取咱们 API 数据的家伙?后台会检查该客户端是否提供了一个有效的 API 密钥。若是密钥有效,则执行请求操做,不然拒绝该请求并根据业务需求采起一些措施(当出现此状况时,我通常会纪录他们的 IP 地址和客户端 ID,看一下他们的访问频率。若是频率高于个人忍受范围,我会考虑禁止并观察一下这个无礼的家伙想要获得什么)。后端

让咱们从头开始构建咱们的城堡吧。在咱们的应用中,添加一个叫作 API_KEY 的变量,该变量会自动注入到每次的请求(若是是 Android 应用,可能会是你的 Retrofit 客户端)中。浏览器

private final static String API_KEY = “67a5af7f89ah3katf7m20fdj202”复制代码

很好,这样能够帮助咱们鉴定客户端。但问题在于它自己并无提供一个十分有效的安全保证。

若是你使用 apktool 反编译该应用,而后搜索该字符串,你会在其中一个 .smali 文件中发现:

const-string v1, “67a5af7f89ah3katf7m20fdj202”复制代码

是的,我知道。这并不能保证是一个有效的令牌,因此咱们仍然须要经过一个精确的验证来决定如何找到那个字符串,和它是否能够用来经过验证。可是你知道我要表达什么:这一般只是时间和资源的问题。

Proguard 是否会能咱们保证该字符串的安全呢?并不能。Proguard 在常见问题中提到了字符串的加密是彻底不可能的。

那将字符串保存到 Android 提供的其余存储机制中呢,好比说 SharedPreferences?这并非一个好方法。在模拟器或者 root 过的设备中能够轻易的访问到 SharedPreferences。几年前,一个叫 Srinivas 的伙计向咱们证实了如何更改一个视频游戏中的得分。跑题了!

原生开发工具包 (NDK)

我将会更新我提出的初始模型,不断迭代它,以提供更安全的替代方案。咱们假设有两个函数分别负责加密和解密数据:

private static byte[] encrypt(byte[] raw, byte[] clear) throws Exception {
        SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
        Cipher cipher = Cipher.getInstance("AES");
        cipher.init(Cipher.ENCRYPT_MODE, skeySpec);
        byte[] encrypted = cipher.doFinal(clear);
        return encrypted;
    }

    private static byte[] decrypt(byte[] raw, byte[] encrypted) throws Exception {
        SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
        Cipher cipher = Cipher.getInstance("AES");
        cipher.init(Cipher.DECRYPT_MODE, skeySpec);
        byte[] decrypted = cipher.doFinal(encrypted);
        return decrypted;
    }复制代码

代码没啥好说的。这两个函数会使用一个密钥值和一个被用来编/解码的字符串做为入参。它们会返回相应的加密或解密过的字符串。咱们会用以下方式调用它们:

ByteArrayOutputStream baos = new ByteArrayOutputStream();
bm.compress(Bitmap.CompressFormat.PNG, 100, baos);
byte[] b = baos.toByteArray();

byte[] keyStart = "encryption key".getBytes();
KeyGenerator kgen = KeyGenerator.getInstance("AES");
SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
sr.setSeed(keyStart);
kgen.init(128, sr);
SecretKey skey = kgen.generateKey();
byte[] key = skey.getEncoded();

// encrypt
byte[] encryptedData = encrypt(key,b);
// decrypt
byte[] decryptedData = decrypt(key,encryptedData);复制代码

猜到为何要这么作了吗?是的,咱们能够根据需求来加/解密令牌。这就为咱们提供了一个额外的安全层:当代码混淆后,寻找令牌再也不像执行字符串搜索和检查字符串周围的环境那样简单了。可是,你能指出还有一个须要解决的问题吗?

找到了吗?

若是还没找到就多花点时间。

是的。咱们仍然有一个加密密钥以字符串的形式存储。虽然这种隐晦的作法增长了更多的安全层,但无论这个令牌是用于加密或它自己就是一个令牌,咱们仍然有一个以明文形式存在的令牌。

如今,咱们将使用 NDK 来继续迭代咱们的安全机制。

NDK 容许咱们在 Android 代码中访问 C++ 代码库。首先咱们来想一下要作什么。咱们能够在一个 C++ 函数中存放 API 密钥或者敏感数据。该函数能够在以后的代码中调用,避免了在 Java 文件中存储字符串。这就提供了一个自动的保护机制来防止反编译技术。

C++ 函数以下:

Java_com_example_exampleApp_ExampleClass_getSecretKey( JNIEnv* env,
                                                  jobject thiz )
{
    return (*env)->NewStringUTF(env, "mySecretKey".");
}复制代码

在 Java 代码中调用它也很简单:

static {
        System.loadLibrary("library-name");
    }

public native String getSecretKey();复制代码

在加/解密函数中会这样调用:

byte[] keyStart = getSecretKey().getBytes();复制代码

此时咱们生成 APK,混淆它,而后反编译并尝试在原生函数 getSecretKey() 中查找该字符串,没法找到!胜利了吗?

并无!NDK 代码其实也能够被反汇编和检查。只是难度较高,须要更高级的工具和技术。虽然这样能够摆脱掉 95% 的脚本小子,但一个有充足资源和动机的团队让然能够拿到令牌。还记得这句话吗?

没有绝对的安全。所谓的安全是指利用一系列措施的堆积和组合,来试图延缓必然发生的事情。

你仍然能够在反汇编代码中找到该字符串字面值。Hex Rays 在反编译原生文件方面就作的很好。我很确信有一大堆的工具能够解构 Android 生成的任意原生代码(我跟 Hex Rays 并无关系,也没有从他们那里拿到任何形式的资金酬劳)。

那么,咱们要使用哪一种方案来避免后台与客户端的通讯被标记呢?

在设备上实时生成密钥。

你的设备不须要存储任何形式的密钥并处理各类保护字符串字面值的麻烦!这是在服务中用到的很是古老的技术,好比远程密钥验证。

  1. 客户端知道有个函数会返回一个密钥。
  2. 后台知道在客户端中实现的那个函数。
  3. 客户端经过该函数生成一个密钥,并发送到服务器上。
  4. 服务器验证密钥,并根据请求执行相应的操做。

抓到重点了吗?为何不使用返回三个随机素数( 1~100 之间)之和的函数来代替返回一个字符串(很容易被识别)的原生函数呢?或者拿到当天的 UNIX 时间,而后给每一位数字加 1?经过设备的一些上下文相关信息(如正在使用的内存量)来提供一个更高程度的熵值?

上面这段包含了一些想法,但愿读者们已经获得重点了。

总结

  1. 绝对的安全是不存在的。
  2. 多种保护手段的结合是达到高安全度的关键。
  3. 不要在代码中存储字符串明文。
  4. 使用 NDK 来建立自生成的密钥。

还记得开头的那段话吧?

没有绝对的安全。所谓的安全是指利用一系列措施的堆积和组合,来试图延缓必然发生的事情。

我想再强调一次,你的目标是尽量的保护你的代码,同时不要忘记 100% 的安全是不可能的。可是,若是你能保证解密你代码中任意的敏感信息都须要耗费大量的资源,你就能安心睡觉啦。

一个小小的免责声明

我知道,读到此处,纵观整文,你会纳闷“这家伙怎么讲了全部麻烦的方法而没有提到 Dexguard 呢?”。是的 Dexguard 能够混淆字符串,他们在这方面作的很好。然而 Dexguard 的售价让人望而却步。我在以前的公司的关键安全系统中使用过 Dexguard,但这也许并非一个适合全部人的选择。再说了,像生活同样,在软件开发中选择越多世界越丰富多彩。

愉快的编码吧!

我会在 Twitter 上写一些关于软件工程和生活点滴的思考。若是你喜欢此文,或者它能帮到你,请随意分享,点赞或者留言。这是业余做者写做的动力。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划

相关文章
相关标签/搜索