跳过内容

MASTG-TEST-0011: 测试内存中的敏感数据

此测试即将更新

此测试目前可使用,但将作为新的 OWASP MASTG v2 指南 的一部分进行全面修订。

请提交 PR 来帮助我们:MASTG v1->v2 MASTG-TEST-0011: 测试敏感数据内存 (android)

发送反馈

概述

分析内存可以帮助开发人员识别几个问题的根本原因,例如应用程序崩溃。但是,它也可以用于访问敏感数据。本节介绍如何检查通过进程内存泄露的数据。

首先识别存储在内存中的敏感信息。敏感资产可能已经在某个时间点加载到内存中。目的是验证此信息尽可能短时间地暴露。

要调查应用程序的内存,您必须首先创建一个内存转储。您也可以实时分析内存,例如,通过调试器。无论您采用哪种方法,内存转储在验证方面都是一个非常容易出错的过程,因为每个转储都包含已执行函数的输出。您可能会错过执行关键场景。此外,除非您知道数据的大小(确切的值或数据格式),否则很可能在分析期间忽略数据。例如,如果应用程序使用随机生成的对称密钥进行加密,则您可能无法在内存中找到它,除非您可以识别另一种上下文中的密钥值。

因此,您最好从静态分析开始。

静态分析

当执行静态分析以识别内存中暴露的敏感数据时,您应该

  • 尝试识别应用程序组件并映射数据的使用位置。
  • 确保敏感数据由尽可能少的组件处理。
  • 确保一旦不再需要包含敏感数据的对象,就正确删除对象引用。
  • 确保在删除引用后请求垃圾回收。
  • 确保敏感数据在不再需要时立即被覆盖。
    • 不要使用不可变数据类型(例如 StringBigInteger)来表示此类数据。
    • 避免使用非原始数据类型(例如 StringBuilder)。
    • 在删除引用之前覆盖它们,在 finalize 方法之外。
    • 注意第三方组件(库和框架)。公共 API 是很好的指标。确定公共 API 是否按照本章中的描述处理敏感数据。

以下部分描述了内存中数据泄漏的陷阱以及避免这些陷阱的最佳实践。

不要使用不可变结构(例如 StringBigInteger)来表示密钥。使这些结构无效将是无效的:垃圾收集器可能会收集它们,但它们可能会在垃圾收集后保留在堆上。尽管如此,您应该在每次关键操作之后请求垃圾收集(例如,加密、解析包含敏感信息的服务器响应)。当信息的副本没有被正确清理时(如下所述),您的请求将有助于减少这些副本在内存中可用的时间长度。

要从内存中正确清除敏感信息,请将其存储在原始数据类型中,例如字节数组(byte[])和字符数组(char[])。您应避免将信息存储在可变非原始数据类型中。

确保在不再需要关键对象时覆盖其内容。用零覆盖内容是一种简单且非常流行的方法

Java 示例

byte[] secret = null;
try{
    //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        Arrays.fill(secret, (byte) 0);
    }
}

Kotlin 示例

val secret: ByteArray? = null
try {
     //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        Arrays.fill(secret, 0.toByte())
    }
}

但是,这并不能保证内容将在运行时被覆盖。为了优化字节码,编译器将分析并决定不覆盖数据,因为它之后不会被使用(即,这是一个不必要的操作)。即使代码在编译的 DEX 中,优化也可能发生在 VM 中的即时或提前编译期间。

这个问题没有万能的解决方案,因为不同的解决方案会产生不同的后果。例如,您可以执行额外的计算(例如,将数据 XOR 到虚拟缓冲区中),但您无法知道编译器优化分析的范围。另一方面,在编译器范围外使用覆盖的数据(例如,将其序列化到临时文件中)可以保证它将被覆盖,但显然会影响性能和维护。

然后,使用 Arrays.fill 来覆盖数据是一个坏主意,因为该方法是一个明显的挂钩目标(有关更多详细信息,请参见 方法挂钩)。

上述示例的最后一个问题是内容仅用零覆盖。您应该尝试用随机数据或来自非关键对象的内容覆盖关键对象。这将使构造可以基于其管理识别敏感数据的扫描器变得非常困难。

以下是先前示例的改进版本

Java 示例

byte[] nonSecret = somePublicString.getBytes("ISO-8859-1");
byte[] secret = null;
try{
    //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        for (int i = 0; i < secret.length; i++) {
            secret[i] = nonSecret[i % nonSecret.length];
        }

        FileOutputStream out = new FileOutputStream("/dev/null");
        out.write(secret);
        out.flush();
        out.close();
    }
}

Kotlin 示例

val nonSecret: ByteArray = somePublicString.getBytes("ISO-8859-1")
val secret: ByteArray? = null
try {
     //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        for (i in secret.indices) {
            secret[i] = nonSecret[i % nonSecret.size]
        }

        val out = FileOutputStream("/dev/null")
        out.write(secret)
        out.flush()
        out.close()
        }
}

有关更多信息,请查看 在 RAM 中安全存储敏感数据

在“静态分析”部分中,我们提到了在使用 AndroidKeyStoreSecretKey 时处理加密密钥的正确方法。

为了更好地实现 SecretKey,请查看下面的 SecureSecretKey 类。虽然此实现可能缺少一些样板代码,这些代码将使该类与 SecretKey 兼容,但它解决了主要的安全问题

  • 没有敏感数据的跨上下文处理。密钥的每个副本都可以从创建它的范围内清除。
  • 本地副本根据上述建议清除。

Java 示例

  public class SecureSecretKey implements javax.crypto.SecretKey, Destroyable {
      private byte[] key;
      private final String algorithm;

      /** Constructs SecureSecretKey instance out of a copy of the provided key bytes.
        * The caller is responsible of clearing the key array provided as input.
        * The internal copy of the key can be cleared by calling the destroy() method.
        */
      public SecureSecretKey(final byte[] key, final String algorithm) {
          this.key = key.clone();
          this.algorithm = algorithm;
      }

      public String getAlgorithm() {
          return this.algorithm;
      }

      public String getFormat() {
          return "RAW";
      }

      /** Returns a copy of the key.
        * Make sure to clear the returned byte array when no longer needed.
        */
      public byte[] getEncoded() {
          if(null == key){
              throw new NullPointerException();
          }

          return key.clone();
      }

      /** Overwrites the key with dummy data to ensure this copy is no longer present in memory.*/
      public void destroy() {
          if (isDestroyed()) {
              return;
          }

          byte[] nonSecret = new String("RuntimeException").getBytes("ISO-8859-1");
          for (int i = 0; i < key.length; i++) {
            key[i] = nonSecret[i % nonSecret.length];
          }

          FileOutputStream out = new FileOutputStream("/dev/null");
          out.write(key);
          out.flush();
          out.close();

          this.key = null;
          System.gc();
      }

      public boolean isDestroyed() {
          return key == null;
      }
  }

Kotlin 示例

class SecureSecretKey(key: ByteArray, algorithm: String) : SecretKey, Destroyable {
    private var key: ByteArray?
    private val algorithm: String
    override fun getAlgorithm(): String {
        return algorithm
    }

    override fun getFormat(): String {
        return "RAW"
    }

    /** Returns a copy of the key.
     * Make sure to clear the returned byte array when no longer needed.
     */
    override fun getEncoded(): ByteArray {
        if (null == key) {
            throw NullPointerException()
        }
        return key!!.clone()
    }

    /** Overwrites the key with dummy data to ensure this copy is no longer present in memory. */
    override fun destroy() {
        if (isDestroyed) {
            return
        }
        val nonSecret: ByteArray = String("RuntimeException").toByteArray(charset("ISO-8859-1"))
        for (i in key!!.indices) {
            key!![i] = nonSecret[i % nonSecret.size]
        }
        val out = FileOutputStream("/dev/null")
        out.write(key)
        out.flush()
        out.close()
        key = null
        System.gc()
    }

    override fun isDestroyed(): Boolean {
        return key == null
    }

    /** Constructs SecureSecretKey instance out of a copy of the provided key bytes.
     * The caller is responsible of clearing the key array provided as input.
     * The internal copy of the key can be cleared by calling the destroy() method.
     */
    init {
        this.key = key.clone()
        this.algorithm = algorithm
    }
}

安全的用户提供数据是通常在内存中找到的最终安全信息类型。这通常通过实现自定义输入法来管理,您应该遵循此处的建议。但是,Android 允许通过自定义 Editable.Factory 部分擦除 EditText 缓冲区。

EditText editText = ...; //  point your variable to your EditText instance
EditText.setEditableFactory(new Editable.Factory() {
  public Editable newEditable(CharSequence source) {
  ... // return a new instance of a secure implementation of Editable.
  }
});

有关示例 Editable 实现,请参阅上面的 SecureSecretKey 示例。请注意,如果您提供您的工厂,您将能够安全地处理 editText.getText 制作的所有副本。您也可以尝试通过调用 editText.setText 来覆盖内部 EditText 缓冲区,但不能保证该缓冲区尚未被复制。如果您选择依赖默认输入法和 EditText,您将无法控制使用的键盘或其他组件。因此,您应该仅将此方法用于半机密信息。

在所有情况下,请确保在用户退出应用程序时清除内存中的敏感数据。最后,确保在 Activity 或 Fragment 的 onPause 事件被触发时立即清除高度敏感的信息。

请注意,这可能意味着用户每次恢复应用程序时都必须重新进行身份验证。

动态分析

静态分析将帮助您识别潜在问题,但它无法提供有关数据在内存中暴露多长时间的统计信息,也无法帮助您识别闭源依赖项中的问题。这就是动态分析发挥作用的地方。

有多种方法可以分析进程的内存,例如通过调试器/动态检测进行实时分析以及分析一个或多个内存转储。

检索和分析内存转储

无论您使用的是 root 设备还是非 root 设备,都可以使用 objection Fridump 转储应用程序的进程内存。您可以在“篡改和逆向工程 Android”一章中的 进程探索中找到此过程的详细说明。

在内存转储(例如,转储到名为“memory”的文件)之后,根据您要查找的数据的性质,您需要一组不同的工具来处理和分析该内存转储。例如,如果您专注于字符串,那么执行来自 rabin2 的命令 stringsrabin2 -zz 对您来说可能就足够了。

# using strings
$ strings memory > strings.txt

# using rabin2
$ rabin2 -ZZ memory > strings.txt

在您喜欢的编辑器中打开 strings.txt 并仔细检查以识别敏感信息。

但是,如果您想检查其他类型的数据,您宁愿使用 radare2 及其搜索功能。有关更多信息和选项列表,请参见 radare2 关于搜索命令的帮助 (/?)。以下仅显示其中的一个子集

$ r2 <name_of_your_dump_file>

[0x00000000]> /?
Usage: /[!bf] [arg]  Search stuff (see 'e??search' for options)
|Use io.va for searching in non virtual addressing spaces
| / foo\x00                    search for string 'foo\0'
| /c[ar]                       search for crypto materials
| /e /E.F/i                    match regular expression
| /i foo                       search for string 'foo' ignoring case
| /m[?][ebm] magicfile         search for magic, filesystems or binary headers
| /v[1248] value               look for an `cfg.bigendian` 32bit value
| /w foo                       search for wide string 'f\0o\0o\0'
| /x ff0033                    search for hex string
| /z min max                   search for strings of given size
...

运行时内存分析

或者,您可以使用 r2frida。使用它,您可以在应用程序运行时分析和检查应用程序的内存。例如,您可以从 r2frida 运行先前的搜索命令,并在内存中搜索字符串、十六进制值等。执行此操作时,请记住在使用 r2 frida://usb//<name_of_your_app> 启动会话后,使用反斜杠 : 预先添加搜索命令(和任何其他 r2frida 特定命令)。

有关更多信息、选项和方法,请参阅 进程探索 以获取更多信息。

显式转储和分析 Java 堆

对于基本分析,您可以使用 Android Studio 的内置工具。它们位于Android Monitor选项卡上。要转储内存,请选择要分析的设备和应用程序,然后单击Dump Java Heap。这将在应用程序项目路径上的captures目录中创建一个 .hprof 文件。

要浏览保存在内存转储中的类实例,请在显示 .hprof 文件的选项卡中选择 Package Tree View。

要对内存转储进行更高级的分析,请使用 Eclipse Memory Analyzer Tool (MAT)。它可以作为 Eclipse 插件和独立应用程序使用。

要在 MAT 中分析转储,请使用随 Android SDK 提供的 hprof-conv 平台工具。

./hprof-conv memory.hprof memory-mat.hprof

MAT 提供了几个用于分析内存转储的工具。例如,Histogram 提供了从给定类型捕获的对象数量的估计值,Thread Overview 显示了进程的线程和堆栈帧。Dominator Tree 提供了有关对象之间保持活动依赖关系的信息。您可以使用正则表达式来过滤这些工具提供的结果。

Object Query Language studio 是一种 MAT 功能,允许您使用类似 SQL 的语言查询内存转储中的对象。该工具允许您通过调用 Java 方法来转换简单对象,并提供一个 API 用于在 MAT 之上构建复杂的工具。

SELECT * FROM java.lang.String

在上面的示例中,将选择内存转储中存在的所有 String 对象。结果将包括对象的类、内存地址、值和保留计数。要过滤此信息并仅查看每个字符串的值,请使用以下代码

SELECT toString(object) FROM java.lang.String object

SELECT object.toString() FROM java.lang.String object

SQL 也支持原始数据类型,因此您可以执行类似以下操作来访问所有 char 数组的内容

SELECT toString(arr) FROM char[] arr

如果您得到与先前结果相似的结果,请不要感到惊讶;毕竟,String 和其他 Java 数据类型只是原始数据类型的包装器。现在让我们过滤结果。以下示例代码将选择包含 RSA 密钥的 ASN.1 OID 的所有字节数组。这并不意味着给定的字节数组实际上包含 RSA(相同的字节序列可能是其他内容的一部分),但这很可能。

SELECT * FROM byte[] b WHERE toString(b).matches(".*1\.2\.840\.113549\.1\.1\.1.*")

最后,您不必选择整个对象。考虑一个 SQL 类比:类是表,对象是行,字段是列。如果要查找所有具有“password”字段的对象,您可以执行类似以下操作

SELECT password FROM ".*" WHERE (null != password)

在您的分析过程中,搜索

  • 指示性字段名称:“password”、“pass”、“pin”、“secret”、“private”等。
  • 字符串、字符数组、字节数组等中的指示性模式(例如,RSA 大小)。
  • 已知的密钥(例如,您已输入的信用卡号或后端提供的身份验证令牌)
  • 等等

重复测试和内存转储将有助于您获得有关数据暴露时长的统计信息。此外,观察特定内存段(例如,字节数组)的更改方式可能会使您获得一些其他无法识别的敏感数据(有关这方面的更多信息,请参见下面的“补救”部分)。