跳过内容

Android 数据存储

概述

本章讨论了保护敏感数据(如身份验证令牌和私人信息)的重要性,这对于移动安全至关重要。我们将研究 Android 的本地数据存储 API,并分享最佳实践。

虽然最好限制本地存储上的敏感数据,或者尽可能完全避免,但实际用例通常需要用户数据存储。例如,为了改善用户体验,应用程序会在本地缓存身份验证令牌,从而避免了每次应用程序启动时都需要复杂的密码输入。应用程序可能还需要存储个人身份信息 (PII) 和其他敏感数据。

如果保护不当,敏感数据可能会变得脆弱,可能会存储在各种位置,包括设备或外部 SD 卡。重要的是要识别移动应用程序处理的信息,并对哪些信息算作敏感数据进行分类。请查看 “移动应用安全测试” 章节中的 “识别敏感数据” 部分,了解数据分类的详细信息。有关全面的见解,请参阅 Android 开发者指南中的 存储数据的安全提示

敏感信息泄露风险包括潜在的信息解密、社交工程攻击(如果泄露 PII)、帐户劫持(如果泄露会话信息或身份验证令牌)以及具有支付选项的应用程序漏洞利用。

除了数据保护之外,验证和清理来自任何存储源的数据。这包括检查正确的数据类型和实现密码控制,例如用于数据完整性的 HMAC。

Android 提供了各种 数据存储 方法,专为用户、开发人员和应用程序量身定制。常见的持久存储技术包括

  • Shared Preferences(共享偏好设置)
  • SQLite 数据库
  • Firebase 数据库
  • Realm 数据库
  • 内部存储
  • 外部存储
  • KeyStore

此外,其他可能导致数据存储并应进行测试的 Android 功能包括

  • 日志记录功能
  • Android 备份
  • 进程内存
  • 键盘缓存
  • 屏幕截图

理解每个相关的数据存储功能对于执行适当的测试用例至关重要。本概述简要介绍了这些数据存储方法,并指导测试人员进一步查阅相关文档。

Shared Preferences(共享偏好设置)

SharedPreferences API 通常用于永久保存小的键值对集合。

自从 Android 4.2(API 级别 17)以来,SharedPreferences 对象只能声明为私有(而不是世界可读,即所有应用程序都可以访问)。但是,由于存储在 SharedPreferences 对象中的数据会写入纯文本 XML 文件,因此其滥用通常会导致敏感数据泄露。

考虑以下示例

var sharedPref = getSharedPreferences("key", Context.MODE_PRIVATE)
var editor = sharedPref.edit()
editor.putString("username", "administrator")
editor.putString("password", "supersecret")
editor.commit()

一旦调用了该活动,将使用提供的数据创建文件 key.xml。此代码违反了几项最佳实践。

  • 用户名和密码以明文形式存储在 /data/data/<package-name>/shared_prefs/key.xml 中。
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
  <string name="username">administrator</string>
  <string name="password">supersecret</string>
</map>

MODE_PRIVATE 使该文件只能由调用应用程序访问。请参阅 “在私有模式下使用 SharedPreferences”

其他不安全模式存在,例如 MODE_WORLD_READABLEMODE_WORLD_WRITEABLE,但自 Android 4.2(API 级别 17)以来已被弃用,并且 在 Android 7.0(API 级别 24)中已删除。因此,只有在较旧的操作系统版本(android:minSdkVersion 小于 17)上运行的应用程序才会受到影响。否则,Android 将抛出 SecurityException。如果应用程序需要与其他应用程序共享私有文件,最好使用 FileProviderFLAG_GRANT_READ_URI_PERMISSION。有关更多详细信息,请参阅 共享文件

您也可以使用 EncryptedSharedPreferences,它是 SharedPreferences 的包装器,可以自动加密存储到共享偏好设置的所有数据。

警告

包括 EncryptedFileEncryptedSharedPreferences 类在内的 Jetpack 安全加密库 已被 弃用。但是,由于官方替代品尚未发布,我们建议您在有替代品之前使用这些类。

var masterKey: MasterKey? = null
masterKey = Builder(this)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(
    this,
    "secret_shared_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

val editor = sharedPreferences.edit()
editor.putString("username", "administrator")
editor.putString("password", "supersecret")
editor.commit()

数据库

Android 平台提供了许多数据库选项,如前一个列表所述。每个数据库选项都有其自身的怪癖和需要理解的方法。

SQLite 数据库(未加密)

SQLite 是一个 SQL 数据库引擎,它将数据存储在 .db 文件中。Android SDK 对 SQLite 数据库具有 内置支持。用于管理数据库的主要软件包是 android.database.sqlite。例如,您可以使用以下代码在活动中存储敏感信息

Java 示例

SQLiteDatabase notSoSecure = openOrCreateDatabase("privateNotSoSecure", MODE_PRIVATE, null);
notSoSecure.execSQL("CREATE TABLE IF NOT EXISTS Accounts(Username VARCHAR, Password VARCHAR);");
notSoSecure.execSQL("INSERT INTO Accounts VALUES('admin','AdminPass');");
notSoSecure.close();

Kotlin 示例

var notSoSecure = openOrCreateDatabase("privateNotSoSecure", Context.MODE_PRIVATE, null)
notSoSecure.execSQL("CREATE TABLE IF NOT EXISTS Accounts(Username VARCHAR, Password VARCHAR);")
notSoSecure.execSQL("INSERT INTO Accounts VALUES('admin','AdminPass');")
notSoSecure.close()

一旦调用了该活动,将使用提供的数据创建数据库文件 privateNotSoSecure,并将其存储在纯文本文件 /data/data/<package-name>/databases/privateNotSoSecure 中。

除了 SQLite 数据库之外,数据库的目录可能还包含几个文件

  • 日志文件:这些是用于实现原子提交和回滚的临时文件。
  • 锁定文件:锁定文件是锁定和日志记录功能的一部分,该功能旨在提高 SQLite 并发性并减少写入器饥饿问题。

敏感信息不应存储在未加密的 SQLite 数据库中。

SQLite 数据库(已加密)

使用库 SQLCipher,您可以使用密码加密 SQLite 数据库。

Java 示例

SQLiteDatabase secureDB = SQLiteDatabase.openOrCreateDatabase(database, "password123", null);
secureDB.execSQL("CREATE TABLE IF NOT EXISTS Accounts(Username VARCHAR,Password VARCHAR);");
secureDB.execSQL("INSERT INTO Accounts VALUES('admin','AdminPassEnc');");
secureDB.close();

Kotlin 示例

var secureDB = SQLiteDatabase.openOrCreateDatabase(database, "password123", null)
secureDB.execSQL("CREATE TABLE IF NOT EXISTS Accounts(Username VARCHAR,Password VARCHAR);")
secureDB.execSQL("INSERT INTO Accounts VALUES('admin','AdminPassEnc');")
secureDB.close()

检索数据库密钥的安全方法包括

  • 一旦应用程序打开,要求用户使用 PIN 码或密码解密数据库(弱密码和 PIN 码容易受到暴力攻击)
  • 将密钥存储在服务器上,并允许仅从 Web 服务访问它(以便该应用程序只能在设备在线时使用)

Firebase 实时数据库

Firebase 是一个开发平台,拥有 15 多个产品,其中之一是 Firebase 实时数据库。应用程序开发人员可以利用它来存储数据并将其与 NoSQL 云托管数据库同步。数据以 JSON 格式存储,并实时同步到每个连接的客户端,即使应用程序离线,数据仍然可用。

可以通过进行以下网络调用来识别配置错误的 Firebase 实例

https://_firebaseProjectName_.firebaseio.com/.json

firebaseProjectName 可以通过反向工程应用程序从移动应用程序中检索。或者,分析师可以使用 Firebase Scanner,这是一个 Python 脚本,可以自动执行上述任务,如下所示

python FirebaseScanner.py -p <pathOfAPKFile>

python FirebaseScanner.py -f <commaSeparatedFirebaseProjectNames>

Realm 数据库

适用于 Java 的 Realm 数据库 在开发人员中越来越受欢迎。数据库及其内容可以使用存储在配置文件中的密钥进行加密。

//the getKey() method either gets the key from the server or from a KeyStore, or is derived from a password.
RealmConfiguration config = new RealmConfiguration.Builder()
  .encryptionKey(getKey())
  .build();

Realm realm = Realm.getInstance(config);

对数据的访问取决于加密:未加密的数据库很容易访问,而加密的数据库需要调查密钥的管理方式——无论是硬编码还是以不安全的方式(例如共享偏好设置)未加密存储,还是安全地存储在平台的 KeyStore 中(这是最佳实践)。

但是,如果攻击者有足够的设备访问权限(例如 root 访问权限)或者可以重新打包应用程序,他们仍然可以使用 Frida 等工具在运行时检索加密密钥。以下 Frida 脚本演示了如何拦截 Realm 加密密钥并访问加密数据库的内容。

'use strict';

function modulus(x, n){
    return ((x % n) + n) % n;
}

function bytesToHex(bytes) {
    for (var hex = [], i = 0; i < bytes.length; i++) { hex.push(((bytes[i] >>> 4) & 0xF).toString(16).toUpperCase());
        hex.push((bytes[i] & 0xF).toString(16).toUpperCase());
    }
    return hex.join("");
}

function b2s(array) {
    var result = "";
    for (var i = 0; i < array.length; i++) {
        result += String.fromCharCode(modulus(array[i], 256));
    }
    return result;
}

// Main Modulus and function.

if(Java.available){
    console.log("Java is available");
    console.log("[+] Android Device.. Hooking Realm Configuration.");

    Java.perform(function(){
        var RealmConfiguration = Java.use('io.realm.RealmConfiguration');
        if(RealmConfiguration){
            console.log("[++] Realm Configuration is available");
            Java.choose("io.realm.Realm", {
                onMatch: function(instance)
                {
                    console.log("[==] Opened Realm Database...Obtaining the key...")
                    console.log(instance);
                    console.log(instance.getPath());
                    console.log(instance.getVersion());
                    var encryption_key = instance.getConfiguration().getEncryptionKey();
                    console.log(encryption_key);
                    console.log("Length of the key: " + encryption_key.length); 
                    console.log("Decryption Key:", bytesToHex(encryption_key));

                }, 
                onComplete: function(instance){
                    RealmConfiguration.$init.overload('java.io.File', 'java.lang.String', '[B', 'long', 'io.realm.RealmMigration', 'boolean', 'io.realm.internal.OsRealmConfig$Durability', 'io.realm.internal.RealmProxyMediator', 'io.realm.rx.RxObservableFactory', 'io.realm.coroutines.FlowFactory', 'io.realm.Realm$Transaction', 'boolean', 'io.realm.CompactOnLaunchCallback', 'boolean', 'long', 'boolean', 'boolean').implementation = function(arg1)
                    {
                        console.log("[==] Realm onComplete Finished..")

                    }
                }

            });
        }
    });
}

内部存储

您可以将文件保存到设备的 内部存储。默认情况下,保存到内部存储的文件会被容器化,并且设备上的其他应用程序无法访问它们。当用户卸载您的应用程序时,这些文件将被删除。

例如,以下 Kotlin 代码片段将敏感信息以纯文本形式存储到位于内部存储上的文件 sensitive_info.txt 中。

val fileName = "sensitive_info.txt"
val fileContents = "This is some top-secret information!"
File(filesDir, fileName).bufferedWriter().use { writer ->
    writer.write(fileContents)
}

您应该检查文件模式,以确保只有应用程序可以访问该文件。您可以使用 MODE_PRIVATE 设置此访问权限。诸如 MODE_WORLD_READABLE(已弃用)和 MODE_WORLD_WRITEABLE(已弃用)之类的模式可能会构成安全风险。

Android 安全指南:Android 强调内部存储中的数据对应用程序是私有的,其他应用程序无法访问它。它还建议避免对 IPC 文件使用 MODE_WORLD_READABLEMODE_WORLD_WRITEABLE 模式,而应使用 内容提供程序。请参阅 Android 安全指南。Android 还提供了一个关于如何安全使用内部存储的 指南

外部存储

Android 设备支持 共享外部存储。此存储可以是可移动的(例如 SD 卡)或模拟的(不可移动的)。在 Android 10 或更低版本上运行的具有适当权限的恶意应用程序可以访问您写入“外部”应用程序特定目录 的数据。启用 USB 大容量存储后,用户也可以修改这些文件。

卸载您的应用程序时,存储在这些目录中的文件将被删除。

外部存储必须小心使用,因为它存在许多风险。例如,攻击者可能能够检索敏感数据或 获得对应用程序的任意控制

Android 安全指南:Android 建议不要将敏感数据存储在外部存储上,并对存储在外部存储上的所有数据执行输入验证。请参阅 Android 安全指南。Android 还提供了一个关于如何安全使用外部存储的 指南

作用域存储

为了让用户更好地控制他们的文件并限制文件混乱,默认情况下,以 Android 10(API 级别 29)及更高版本为目标的应用程序被赋予对外部存储的作用域访问权限,或 作用域存储。启用作用域存储后,应用程序无法访问属于其他应用程序的应用程序特定目录。

Android 开发人员文档提供了一个详细的指南,重点介绍了常见的 存储用例和最佳实践,区分了处理媒体文件和非媒体文件,并考虑了作用域存储。

选择退出:以 Android 10(API 级别 29)或更低版本为目标的应用程序可以使用其应用程序清单中的 android:requestLegacyExternalStorage="true" 暂时选择退出作用域存储。一旦应用程序以 Android 11(API 级别 30)为目标,系统在 Android 11 设备上运行时将忽略 requestLegacyExternalStorage 属性。

媒体文件的应用程序归属(Android Developers):当为以 Android 10 或更高版本为目标的应用程序启用 作用域存储 时,系统会将应用程序归属给每个媒体文件,这决定了您的应用程序在未请求任何存储权限时可以访问的文件。每个文件只能归属给一个应用程序。因此,如果您的应用程序创建了一个存储在照片、视频或音频文件媒体集合中的媒体文件,则您的应用程序可以访问该文件。

但是,如果用户卸载并重新安装您的应用程序,您必须请求 READ_EXTERNAL_STORAGE 才能访问您的应用程序最初创建的文件。需要此权限请求,因为系统认为该文件归属于该应用程序的先前安装版本,而不是新安装的版本。

例如,尝试使用 content:// URI(如 content://media/external_primary)访问使用 MediaStore API 存储的文件只能在图像属于调用应用程序时有效(由于 MediaStore 中的 owner_package_name 属性)。如果应用程序调用不属于该应用程序的 content:// URI,它将失败并出现 SecurityException

Cannot open content uri: content://media/external_primary/images/media/1000000041
java.lang.SecurityException: org.owasp.mastestapp has no access to content://media/external_primary/images/media/1000000041

您可以通过 adb 查询 MediaStore 来验证这一点,例如

  • adb shell content query --uri content://media/external_primary/images/media
  • adb shell content query --uri content://media/external_primary/file

为了能够访问内容,该应用程序必须具有必要的权限,例如,在 Android 10 API 级别 29 之前为 READ_EXTERNAL_STORAGE,从 Android 10 API 级别 29 开始为 READ_MEDIA_IMAGESMANAGE_EXTERNAL_STORAGE

当以 Android 13(API 级别 33)及更高版本为目标时,READ_EXTERNAL_STORAGE 已弃用(并且未授予)。如果您需要查询或与共享存储上的 MediaStore 或媒体文件进行交互,则应改用一个或多个新的存储权限:READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO

从 Android 10(API 级别 29)开始强制执行作用域存储(如果使用 requestLegacyExternalStorage,则为 Android 11)。特别是,WRITE_EXTERNAL_STORAGE 将不再提供对所有文件的写入访问权限;它将改为提供相当于 READ_EXTERNAL_STORAGE 的权限。

从 Android 13(API 级别 33)开始,如果您需要查询或与共享存储上的 MediaStore 或媒体文件进行交互,则应改为使用一个或多个新的存储权限:READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO

在清单中声明权限后,您可以使用 adb 授予它

adb shell pm grant org.owasp.mastestapp android.permission.READ_MEDIA_IMAGES

您可以使用以下命令撤销权限

adb shell pm revoke org.owasp.mastestapp android.permission.READ_MEDIA_IMAGES

外部存储 API

存在诸如 getExternalStoragePublicDirectory 之类的 API,它们返回其他应用程序可以访问的共享位置的路径。应用程序可以获取到“外部”位置的路径,并将敏感数据写入其中。此位置被认为是“不需要用户交互的共享存储”,这意味着具有适当权限的第三方应用程序可以读取此敏感数据。

例如,以下 Kotlin 代码片段将敏感信息以纯文本形式存储到位于外部存储上的文件 password.txt 中。

val password = "SecretPassword"
val path = context.getExternalFilesDir(null)
val file = File(path, "password.txt")
file.appendText(password)

MediaStore API

MediaStore API 提供了一种应用程序与设备上存储的两种类型的文件交互的方式

  • 媒体文件,包括图像 (MediaStore.Images)、视频 (MediaStore.Video)、音频 (MediaStore.Audio) 和下载 (MediaStore.Downloads) 和
  • 存储在 MediaStore.Files 集合中的非媒体文件(例如,文本、HTML、PDF 等)。

使用此 API 需要从应用程序的 Context 检索的 ContentResolver 对象。请参阅 Android Developers 文档中的示例。

在 Android 9(API 级别 28)或更低版本上运行的应用程序

  • 如果他们选择退出作用域存储并请求了 READ_EXTERNAL_STORAGE 权限,他们可以访问属于其他应用程序的应用程序特定文件。
  • 要修改文件,应用程序还必须请求 WRITE_EXTERNAL_STORAGE 权限。

在 Android 10(API 级别 29)或更高版本上运行的应用程序

  • 访问自己的媒体文件

    • 应用程序始终可以 访问其自己的媒体文件,这些文件使用 MediaStore API 存储,而无需任何存储相关权限。这包括外部存储(作用域存储)中的应用程序特定目录中的文件,以及应用程序创建的 MediaStore 中的文件。
  • 访问其他应用程序的媒体文件

    • 应用程序需要某些权限和 API 才能 访问属于其他应用程序的媒体文件
    • 如果启用了作用域存储,应用程序将无法访问属于其他应用程序的应用程序特定媒体文件。但是,如果禁用了作用域存储,应用程序可以使用 MediaStore.Files 查询访问属于其他应用程序的应用程序特定媒体文件。
  • 访问下载 (MediaStore.Downloads 集合)

    • 要访问来自其他应用程序的下载,应用程序必须使用 存储访问框架

Manifest 权限

Android 定义了以下 用于访问外部存储的权限READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGEMANAGE_EXTERNAL_STORAGE

应用程序必须在 Android Manifest 文件中声明写入共享位置的意图。下面您可以找到此类 Manifest 权限的列表

  • READ_EXTERNAL_STORAGE:允许应用程序从外部存储读取。

    • 在 Android 4.4(API 级别 19)之前,此权限未强制执行,所有应用程序都可以访问以读取整个外部存储(包括来自其他应用程序的文件)。
    • 从 Android 4.4(API 级别 19)开始,应用程序不需要请求此权限即可访问其自己在外部存储中的应用程序特定目录。
    • 从 Android 10(API 级别 29)开始,默认情况下应用 作用域存储
      • 应用程序无法读取属于其他应用程序的应用程序特定目录(这在之前授予 READ_EXTERNAL_STORAGE 时是可能的)。
      • 应用程序无需拥有此权限即可读取其自己在外部存储(作用域存储)中的应用程序特定目录中的文件,或其自己在 MediaStore 中的文件。
    • 从 Android 13(API 级别 33)开始,此权限无效。如果需要访问来自其他应用程序的媒体文件,应用程序必须请求以下权限中的一个或多个:READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO
  • WRITE_EXTERNAL_STORAGE:允许应用程序将文件写入“外部存储”,而不管实际存储来源如何(外部磁盘或系统在内部模拟)。

    • 从 Android 4.4(API 级别 19)开始,应用程序不需要请求此权限即可访问其自己在外部存储中的应用程序特定目录。
    • 从 Android 10(API 级别 29)开始,默认情况下应用 作用域存储
      • 应用程序无法写入属于其他应用程序的应用程序特定目录(这在之前授予 WRITE_EXTERNAL_STORAGE 时是可能的)。
      • 应用程序无需此权限即可在其自己在外部存储中的应用程序特定目录中写入文件。
    • 从 Android 11(API 级别 30)开始,此权限已弃用且无效,但可以使用 requestLegacyExternalStoragepreserveLegacyExternalStorage 保留。
  • MANAGE_EXTERNAL_STORAGE:某些应用程序需要 对所有文件的广泛访问

    • 此权限仅适用于以 Android 11.0(API 级别 30)或更高版本为目标的应用程序。
    • 此权限的使用受到 Google Play 的限制,除非该应用程序满足 某些要求,并且需要称为 “所有文件访问”特殊应用程序访问权限
    • 当具有此权限时,作用域存储不会影响应用程序访问应用程序特定目录的能力。
  • READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO:允许应用程序从 MediaStore 集合中读取媒体文件。

    • 从 Android 13(API 级别 33)开始,由于 READ_EXTERNAL_STORAGE 无效,因此需要这些权限才能分别从 MediaStore.ImagesMediaStore.VideoMediaStore.Audio 集合中访问媒体文件。

KeyStore

Android KeyStore 支持相对安全的凭据存储。从 Android 4.3(API 级别 18)开始,它提供了用于存储和使用应用程序私有密钥的公共 API。应用程序可以使用公钥创建新的私钥/公钥对,用于加密应用程序机密,并且可以使用私钥解密机密。

您可以使用确认凭据流中的用户身份验证来保护存储在 Android KeyStore 中的密钥。用户的锁屏凭据(图案、PIN、密码或指纹)用于身份验证。

您可以使用两种模式之一中使用存储的密钥

  1. 用户在身份验证后的有限时间内被授权使用密钥。在此模式下,一旦用户解锁设备,所有密钥都可以使用。您可以为每个密钥自定义授权期限。只有启用安全锁屏时才能使用此选项。如果用户禁用安全锁屏,所有存储的密钥将永久失效。

  2. 用户被授权使用与一个密钥关联的特定加密操作。在此模式下,用户必须为涉及密钥的每个操作请求单独的授权。目前,指纹身份验证是请求此类授权的唯一方法。

Android KeyStore 提供的安全级别取决于它的实现,这取决于设备。大多数现代设备都提供 硬件支持的 KeyStore 实现:密钥在可信执行环境 (TEE) 或安全元件 (SE) 中生成和使用,并且操作系统无法直接访问它们。这意味着加密密钥本身不容易检索,即使是从已 root 的设备也是如此。您可以使用 密钥证明 验证硬件支持的密钥。您可以通过检查 isInsideSecureHardware 方法的返回值来确定密钥是否在安全硬件内部,该方法是 KeyInfo 的一部分。

请注意,相关的 KeyInfo 指示,尽管私钥已正确存储在安全硬件上,但密钥和 HMAC 密钥在多个设备上是不安全地存储的。

纯软件实现的密钥使用每个用户的加密主密钥进行加密。攻击者可以访问存储在已root设备的 /data/misc/keystore/ 文件夹中的所有密钥。由于用户锁屏密码/密码用于生成主密钥,因此当设备锁定时,Android KeyStore不可用。为了提高安全性,Android 9(API级别28)引入了 unlockedDeviceRequired 标志。通过将 true 传递给 setUnlockedDeviceRequired 方法,应用程序可以防止存储在 AndroidKeystore 中的密钥在设备锁定时被解密,并且需要先解锁屏幕才能允许解密。

硬件支持的 Android KeyStore

硬件支持的 Android KeyStore 为 Android 的深度防御安全概念提供了另一层保护。Keymaster 硬件抽象层 (HAL) 在 Android 6(API级别23)中引入。应用程序可以验证密钥是否存储在安全硬件内部(通过检查 KeyInfo.isinsideSecureHardware 是否返回 true)。运行 Android 9(API级别28)及更高版本的设备可以具有 StrongBox Keymaster 模块,这是 Keymaster HAL 的一种实现,它位于硬件安全模块中,该模块具有自己的CPU、安全存储、真正的随机数生成器和抵抗篡改的机制。要使用此功能,在使用 AndroidKeystore 生成或导入密钥时,必须将 true 传递给 KeyGenParameterSpec.Builder 类或 KeyProtection.Builder 类中的 setIsStrongBoxBacked 方法。为了确保在运行时使用 StrongBox,请检查 isInsideSecureHardware 是否返回 true,并且系统不会抛出 StrongBoxUnavailableException,如果 StrongBox Keymaster 对于与密钥关联的给定算法和密钥大小不可用,则会抛出该异常。有关基于硬件的密钥库的功能描述,请参见AOSP 页面

Keymaster HAL 是硬件支持组件(可信执行环境 (TEE) 或安全元件 (SE))的接口,Android Keystore 使用该接口。这种硬件支持组件的一个例子是Titan M

密钥证明

对于严重依赖 Android Keystore 进行业务关键操作的应用程序,例如通过加密原语进行多因素身份验证、在客户端安全存储敏感数据等,Android 提供了密钥证明功能,该功能有助于分析通过 Android Keystore 管理的加密材料的安全性。从 Android 8.0(API 级别 26)开始,密钥证明对于所有需要对 Google 应用进行设备认证的新设备(Android 7.0 或更高版本)都是强制性的。此类设备使用由 Google 硬件证明根证书签名的证明密钥,并且可以通过密钥证明过程进行验证。

在密钥证明期间,我们可以指定密钥对的别名,并返回一个证书链,我们可以使用该证书链来验证该密钥对的属性。如果链的根证书是Google 硬件证明根证书,并且进行了与硬件密钥对存储相关的检查,则可以确保该设备支持硬件级别的密钥证明,并且该密钥位于 Google 认为安全的硬件支持的密钥库中。或者,如果证明链具有任何其他根证书,则 Google 不会对硬件的安全性做任何声明。

尽管密钥证明过程可以直接在应用程序中实现,但建议为了安全起见,应在服务器端实现。以下是安全实施密钥证明的高级指南

  • 服务器应通过使用 CSPRNG(密码安全随机数生成器)安全地创建一个随机数来启动密钥证明过程,并且应将该随机数作为质询发送给用户。
  • 客户端应使用从服务器收到的质询来调用 setAttestationChallenge API,然后应使用 KeyStore.getCertificateChain 方法检索证明证书链。
  • 证明响应应发送到服务器进行验证,并且应执行以下检查以验证密钥证明响应
    • 验证证书链,直至根证书,并执行证书完整性检查,例如有效性、完整性和可信度。检查 Google 维护的证书吊销状态列表,以确定链中的任何证书是否被吊销。
    • 检查根证书是否使用 Google 证明根密钥签名,这使得证明过程值得信赖。
    • 提取证明证书扩展数据,该数据出现在证书链的第一个元素中,并执行以下检查
      • 验证证明质询是否具有与服务器在启动证明过程时生成的相同的值。
      • 验证密钥证明响应中的签名。
      • 验证 Keymaster 的安全级别,以确定设备是否具有安全密钥存储机制。Keymaster 是在安全上下文中运行的一段软件,它提供了所有安全的密钥库操作。安全级别将是 SoftwareTrustedEnvironmentStrongBox 之一。如果安全级别为 TrustedEnvironmentStrongBox 并且证明证书链包含使用 Google 证明根密钥签名的根证书,则客户端支持硬件级别的密钥证明。
      • 验证客户端的状态以确保完整的信任链 - 经过验证的启动密钥、锁定的引导加载程序和经过验证的启动状态。
      • 此外,您可以验证密钥对的属性,例如用途、访问时间、身份验证要求等。

请注意,如果由于任何原因该过程失败,则意味着该密钥不在安全硬件中。这并不意味着该密钥已泄露。

Android Keystore 证明响应的典型示例如下所示

{
    "fmt": "android-key",
    "authData": "9569088f1ecee3232954035dbd10d7cae391305a2751b559bb8fd7cbb229bd...",
    "attStmt": {
        "alg": -7,
        "sig": "304402202ca7a8cfb6299c4a073e7e022c57082a46c657e9e53...",
        "x5c": [
            "308202ca30820270a003020102020101300a06082a8648ce3d040302308188310b30090603550406130...",
            "308202783082021ea00302010202021001300a06082a8648ce3d040302308198310b300906035504061...",
            "3082028b30820232a003020102020900a2059ed10e435b57300a06082a8648ce3d040302308198310b3..."
        ]
    }
}

在上面的 JSON 代码段中,密钥具有以下含义

  • fmt:证明语句格式标识符
  • authData:它表示证明的身份验证器数据
  • alg:用于签名的算法
  • sig:签名
  • x5c:证明证书链

注意:sig 通过连接 authDataclientDataHash(服务器发送的质询)并使用 alg 签名算法通过凭据私钥进行签名来生成。相同的内容在服务器端通过使用第一个证书中的公钥进行验证。

为了更好地理解实施指南,您可以参考Google 示例代码

从安全分析的角度来看,分析师可以执行以下检查以安全地实施密钥证明

  • 检查密钥证明是否完全在客户端实现。在这种情况下,通过篡改应用程序、方法挂钩等可以更容易地绕过它。
  • 检查服务器在启动密钥证明时是否使用随机质询。未能做到这一点会导致不安全的实施,从而使其容易受到重放攻击。此外,还应执行与质询的随机性相关的检查。
  • 检查服务器是否验证密钥证明响应的完整性。
  • 检查服务器是否对链中的证书执行基本检查,例如完整性验证、信任验证、有效性等。

安全密钥导入到 KeyStore

Android 9(API 级别 28)增加了将密钥安全地导入到 AndroidKeystore 的功能。首先,AndroidKeystore 使用 PURPOSE_WRAP_KEY 生成一个密钥对,该密钥对也应受到证明证书的保护。该对旨在保护导入到 AndroidKeystore 的密钥。加密密钥以 SecureKeyWrapper 格式生成为 ASN.1 编码的消息,其中还包含对允许使用导入密钥的方式的描述。然后,密钥在属于生成包装密钥的特定设备的 AndroidKeystore 硬件内部解密,因此它们永远不会以明文形式出现在设备的主机内存中。

Secure key import into Keystore

Java 示例

KeyDescription ::= SEQUENCE {
    keyFormat INTEGER,
    authorizationList AuthorizationList
}

SecureKeyWrapper ::= SEQUENCE {
    wrapperFormatVersion INTEGER,
    encryptedTransportKey OCTET_STRING,
    initializationVector OCTET_STRING,
    keyDescription KeyDescription,
    secureKey OCTET_STRING,
    tag OCTET_STRING
}

上面的代码介绍了在 SecureKeyWrapper 格式中生成加密密钥时要设置的不同参数。有关更多详细信息,请查看 Android 文档中的 WrappedKeyEntry

在定义 KeyDescription AuthorizationList 时,以下参数将影响加密密钥的安全性

  • algorithm 参数指定密钥使用的加密算法
  • keySize 参数指定密钥的大小(以位为单位),以密钥算法的正常方式进行测量
  • digest 参数指定可与密钥一起使用的摘要算法,以执行签名和验证操作

旧版 KeyStore 实现

旧版本的 Android 不包含 KeyStore,但它们确实包含 JCA(Java 加密体系结构)中的 KeyStore 接口。您可以使用实现此接口的 KeyStore 来确保与 KeyStore 一起存储的密钥的保密性和完整性;建议使用 BouncyCastle KeyStore (BKS)。所有实现都基于文件存储在文件系统上的事实;所有文件都受密码保护。要创建一个,请使用 KeyStore.getInstance("BKS", "BC") method,其中“BKS”是 KeyStore 名称(BouncyCastle Keystore),“BC”是提供程序(BouncyCastle)。您还可以使用 SpongyCastle 作为包装器,并按如下方式初始化 KeyStore:KeyStore.getInstance("BKS", "SC")

请注意,并非所有 KeyStore 都能正确保护存储在 KeyStore 文件中的密钥。

存储加密密钥:技术

为了减轻 Android 设备上密钥的未经授权使用,Android KeyStore 允许应用程序在生成或导入密钥时指定其密钥的授权用途。一旦做出授权,就无法更改。

存储密钥 - 从最安全到最不安全

  • 密钥存储在硬件支持的 Android KeyStore 中
  • 所有密钥都存储在服务器上,并且在强身份验证后可用
  • 主密钥存储在服务器上,用于加密其他密钥,这些密钥存储在 Android SharedPreferences 中
  • 每次都使用足够长度和盐的强用户提供的密码短语派生密钥
  • 密钥存储在 Android KeyStore 的软件实现中
  • 主密钥存储在 Android KeyStore 的软件实现中,用于加密其他密钥,这些密钥存储在 SharedPreferences 中
  • [不建议] 所有密钥都存储在 SharedPreferences 中
  • [不建议] 在源代码中硬编码加密密钥
  • [不建议] 基于稳定属性的可预测的混淆函数或密钥派生函数
  • [不建议] 将生成的密钥存储在公共场所(例如 /sdcard/

使用硬件支持的 Android KeyStore 存储密钥

如果设备运行的是 Android 7.0(API 级别 24)及更高版本,并且具有可用的硬件组件(可信执行环境 (TEE) 或安全元件 (SE)),则可以使用硬件支持的 Android KeyStore。您甚至可以使用为安全实施密钥证明提供的指南来验证密钥是否由硬件支持。如果没有硬件组件可用和/或需要支持 Android 6.0(API 级别 23)及更低版本,则您可能希望将密钥存储在远程服务器上,并在身份验证后使其可用。

在服务器上存储密钥

可以在密钥管理服务器上安全地存储密钥,但是应用程序需要在线才能解密数据。对于某些移动应用程序用例,这可能是一个限制,应该仔细考虑,因为这会成为应用程序架构的一部分,并且可能会对可用性产生很大的影响。

从用户输入派生密钥

从用户提供的密码短语派生密钥是一种常见的解决方案(取决于您使用的 Android API 级别),但它也会影响可用性,可能会影响攻击面并可能引入其他弱点。

每次应用程序需要执行加密操作时,都需要用户的密码短语。要么每次都提示用户输入,这不是理想的用户体验,要么只要用户通过身份验证,密码短语就会保存在内存中。将密码短语保存在内存中不是最佳实践,因为任何加密材料都只能在使用时保存在内存中。如“清理密钥材料”中所述,归零密钥通常是一项非常具有挑战性的任务。

此外,请考虑从密码短语派生的密钥有其自身的弱点。例如,密码或密码短语可能会被用户重复使用或容易猜到。有关更多信息,请参阅“测试密码学”一章。

清理密钥材料

密钥材料应在不再需要时立即从内存中清除。在具有垃圾收集器(Java)和不可变字符串(Swift、Objective-C、Kotlin)的语言中,可靠地清理秘密数据存在某些限制。Java 加密体系结构参考指南建议使用 char[] 而不是 String 来存储敏感数据,并在使用后将数组置为空。

请注意,某些密码无法正确清理其字节数组。例如,BouncyCastle 中的 AES 密码并不总是清理其最新的工作密钥,从而在内存中留下一些字节数组的副本。接下来,基于 BigInteger 的密钥(例如私钥)无法从堆中删除,也无法在不进行额外努力的情况下将其归零。可以通过编写实现Destroyable的包装器来实现清除字节数组。

使用 Android KeyStore API 存储密钥

更用户友好和推荐的方式是使用 Android KeyStore API 系统(本身或通过 KeyChain)来存储密钥材料。如果可能,应使用硬件支持的存储。否则,应回退到 Android Keystore 的软件实现。但是,请注意,AndroidKeyStore API 在 Android 版本中已发生了重大更改。在早期版本中,AndroidKeyStore API 仅支持存储公钥/私钥对(例如,RSA)。对称密钥支持仅在 Android 6.0(API 级别 23)之后添加。因此,开发人员需要处理不同的 Android API 级别才能安全地存储对称密钥。

通过使用其他密钥加密密钥来存储密钥

为了安全地在运行 Android 5.1(API 级别 22)或更低版本的设备上存储对称密钥,我们需要生成一个公钥/私钥对。我们使用公钥加密对称密钥,并将私钥存储在 AndroidKeyStore 中。加密的对称密钥可以使用 base64 进行编码并存储在 SharedPreferences 中。每当我们需要对称密钥时,应用程序都会从 AndroidKeyStore 中检索私钥并解密对称密钥。

信封加密或密钥包装是一种类似的方法,它使用对称加密来封装密钥材料。数据加密密钥 (DEK) 可以使用安全存储的密钥加密密钥 (KEK) 进行加密。加密的 DEK 可以存储在 SharedPreferences 中或写入文件。需要时,应用程序读取 KEK,然后解密 DEK。有关加密加密密钥的更多信息,请参阅 OWASP 加密存储备忘单

此外,作为这种方法的说明,请参考来自 Jetpack 安全加密库EncryptedSharedPreferences

警告

包括 EncryptedFileEncryptedSharedPreferences 类在内的 Jetpack 安全加密库 已被 弃用。但是,由于官方替代品尚未发布,我们建议您在有替代品之前使用这些类。

不安全的密钥存储选项

存储加密密钥的一种不太安全的方式是在 Android 的 SharedPreferences 中。当使用 SharedPreferences 时,该文件仅可由创建它的应用程序读取。但是,在已 root 的设备上,任何具有 root 访问权限的其他应用程序都可以读取其他应用程序的 SharedPreferences 文件。对于 AndroidKeyStore,情况并非如此,因为 AndroidKeyStore 访问是在内核级别管理的,这需要更多的工作和技能才能在不清除或销毁 AndroidKeyStore 密钥的情况下绕过。

最后三个选项是在源代码中使用硬编码的加密密钥,具有基于稳定属性的可预测的混淆函数或密钥派生函数,以及将生成的密钥存储在公共场所,例如 /sdcard/。硬编码的加密密钥是一个问题,因为这意味着应用程序的每个实例都使用相同的加密密钥。攻击者可以对应用程序的本地副本进行逆向工程以提取加密密钥,并使用该密钥解密应用程序在任何设备上加密的任何数据。

接下来,当您具有基于其他应用程序可访问的标识符的可预测密钥派生函数时,攻击者只需要找到 KDF 并将其应用于设备即可找到密钥。最后,强烈建议不要公开存储加密密钥,因为其他应用程序可能有权读取公共分区并窃取密钥。

使用第三方库进行数据加密

有几个不同的开源库提供特定于 Android 平台的加密功能。

  • Java AES Crypto - 一个用于加密和解密字符串的简单 Android 类。
  • SQL Cipher - SQLCipher 是 SQLite 的一个开源扩展,它提供对数据库文件的透明 256 位 AES 加密。
  • Themis - 一个跨平台的高级加密库,它在许多平台上提供相同的 API,用于保护身份验证、存储、消息传递等过程中的数据。

请记住,只要密钥未存储在 KeyStore 中,就始终可以在已 root 的设备上轻松检索密钥,然后解密您尝试保护的值。

KeyChain

KeyChain 类用于存储和检索系统范围的私钥及其对应的证书(链)。如果首次将某些内容导入 KeyChain,将提示用户设置锁屏密码或密码以保护凭据存储。请注意,KeyChain 是系统范围的,每个应用程序都可以访问存储在 KeyChain 中的材料。

检查源代码以确定本机 Android 机制是否识别敏感信息。敏感信息应加密,而不是以明文形式存储。对于必须存储在设备上的敏感信息,有几个 API 调用可用于通过 KeyChain 类保护数据。完成以下步骤

  • 确保应用程序使用 Android KeyStore 和 Cipher 机制安全地在设备上存储加密信息。查找模式 AndroidKeystoreimport java.security.KeyStoreimport javax.crypto.Cipherimport java.security.SecureRandom 和相应的用法。
  • 使用 store(OutputStream stream, char[] password) 函数使用密码将 KeyStore 存储到磁盘。确保密码由用户提供,而不是硬编码。

日志

在移动设备上创建日志文件有很多合理的理由,例如跟踪崩溃、错误和使用情况统计信息。日志文件可以在应用程序离线时本地存储,并在应用程序在线后发送到端点。但是,记录敏感数据可能会将数据暴露给攻击者或恶意应用程序,并且还可能违反用户保密性。您可以通过多种方式创建日志文件。以下列表包括 Android 提供的两个类

备份

Android 备份通常包括所有已安装应用程序的数据和设置的副本。鉴于其多样化的生态系统,Android 支持许多备份选项

  • Stock Android 具有内置的 USB 备份设施。启用 USB 调试后,使用 adb backup 命令(自 Android 12 起受到限制,需要在 AndroidManifest.xml 中使用 android:debuggable=true)创建完整的数据备份和应用程序数据目录的备份。

  • Google 提供了一个“备份我的数据”功能,该功能会将所有应用程序数据备份到 Google 的服务器。

  • 应用程序开发人员可以使用两个备份 API

    • 键/值备份(Backup API 或 Android Backup Service)上传到 Android Backup Service 云。

    • 应用程序自动备份:在 Android 6.0(API 级别 23)及更高版本中,Google 添加了“应用程序自动备份功能”。此功能会自动将最多 25MB 的应用程序数据与用户的 Google Drive 帐户同步。

  • OEM 可以提供其他选项。例如,HTC 设备有一个“HTC Backup”选项,该选项在激活时每天将备份执行到云端。

应用程序必须仔细确保敏感用户数据不会进入这些备份中,因为这可能允许攻击者提取它。

ADB 备份支持

Android 提供了一个名为 allowBackup 的属性来备份您的所有应用程序数据。此属性在 AndroidManifest.xml 文件中设置。如果此属性的值为 true,则设备允许用户通过 Android 调试桥 (ADB) 使用命令 $ adb backup 备份应用程序(在 Android 12 中受到限制)。

要防止应用程序数据备份,请将 android:allowBackup 属性设置为 false。当此属性不可用时,默认情况下启用 allowBackup 设置,并且必须手动停用备份。

注意:如果设备已加密,则备份文件也将被加密。

进程内存

Android 上的所有应用程序都使用内存来执行正常的计算操作,就像任何常规的现代计算机一样。因此,有时会在进程内存中执行敏感操作也就不足为奇了。因此,重要的是,一旦相关的敏感数据被处理,就应尽快将其从进程内存中删除。

可以通过内存转储和通过调试器实时分析内存来完成应用程序内存的调查。

有关数据暴露的可能来源的概述,请检查文档并在检查源代码之前识别应用程序组件。例如,来自后端的敏感数据可能位于 HTTP 客户端、XML 解析器等中。您希望尽快从内存中删除所有这些副本。

此外,了解应用程序的架构以及该架构在系统中的作用将有助于您识别根本不必在内存中暴露的敏感信息。例如,假设您的应用程序从一台服务器接收数据并将其传输到另一台服务器,而无需进行任何处理。该数据可以用加密格式处理,从而防止在内存中暴露。

但是,如果您需要在内存中暴露敏感数据,则应确保您的应用程序的设计旨在尽可能少地暴露数据副本,并尽可能短地暴露数据副本。换句话说,您希望敏感数据的处理是集中的(即,尽可能少的组件),并且基于原始的可变数据结构。

后一个要求使开发人员可以直接访问内存。确保他们使用此访问权限用虚拟数据(通常为零)覆盖敏感数据。首选数据类型的示例包括 byte []char [],但不包括 StringBigInteger。每当您尝试修改不可变对象(如 String)时,您都会创建和更改该对象的副本。

使用非原始可变类型(如 StringBufferStringBuilder)可能是可以接受的,但它是指示性的,需要小心。诸如 StringBuffer 之类的类型用于修改内容(这正是您想要做的)。但是,要访问此类类型的值,您将使用 toString 方法,这将创建数据的不可变副本。有几种方法可以在不创建不可变副本的情况下使用这些数据类型,但这比使用原始数组需要更多的努力。安全内存管理是使用诸如 StringBuffer 之类的类型的一个好处,但这可能是一把双刃剑。如果您尝试修改其中一种类型的内容,并且副本超出了缓冲区容量,则缓冲区大小将自动增加。缓冲区内容可能会被复制到不同的位置,从而使旧内容没有用于覆盖它的引用。

不幸的是,很少有库和框架的设计允许覆盖敏感数据。例如,销毁密钥(如下所示)不会从内存中删除密钥

Java 示例

SecretKey secretKey = new SecretKeySpec("key".getBytes(), "AES");
secretKey.destroy();

Kotlin 示例

val secretKey: SecretKey = SecretKeySpec("key".toByteArray(), "AES")
secretKey.destroy()

覆盖来自 secretKey.getEncoded 的后备字节数组也不会删除密钥;基于 SecretKeySpec 的密钥会返回后备字节数组的副本。请参阅下面的部分,了解从内存中删除 SecretKey 的正确方法。

RSA 密钥对基于 BigInteger 类型,因此在首次在 AndroidKeyStore 之外使用后驻留在内存中。某些密码(例如 BouncyCastle 中的 AES Cipher)不会正确清理其字节数组。

用户提供的数据(凭据、社会安全号码、信用卡信息等)是另一种可能在内存中暴露的数据类型。无论您是否将其标记为密码字段,EditText 都会通过 Editable 接口将内容传递到应用程序。如果您的应用程序没有提供 Editable.Factory,则用户提供的数据可能会在内存中暴露的时间超过必要的时间。默认的 Editable 实现 SpannableStringBuilder 会导致与 Java 的 StringBuilderStringBuffer 相同的问题(如上所述)。

嵌入在应用程序中的第三方服务

第三方服务提供的功能可能涉及跟踪服务以监控用户在使用应用程序时的行为、销售横幅广告或改善用户体验。

缺点是开发人员通常不知道通过第三方库执行的代码的详细信息。因此,不应将超出必要的信息发送到服务,也不应披露任何敏感信息。

大多数第三方服务以两种方式实现

  • 使用独立的库
  • 使用完整的 SDK

用户界面

UI 组件

在某些时候,用户将不得不将敏感信息输入到应用程序中。此数据可能是财务信息(例如信用卡数据或用户帐户密码)或医疗保健数据。如果应用程序在键入数据时未正确屏蔽数据,则可能会暴露数据。

为了防止泄露并减轻诸如肩窥之类的风险,您应验证是否没有敏感数据通过用户界面暴露,除非明确要求(例如,正在输入密码)。对于需要存在的数据,应正确屏蔽它,通常通过显示星号或点而不是纯文本。

屏幕截图

制造商希望为设备用户提供在应用程序启动和退出时具有美学愉悦感的体验,因此他们引入了屏幕截图保存功能,以便在应用程序进入后台时使用。此功能可能存在安全风险。如果用户在显示敏感数据时故意截取应用程序的屏幕截图,则敏感数据可能会暴露。在设备上运行并能够持续捕获屏幕的恶意应用程序也可能会暴露数据。屏幕截图会被写入本地存储,恶意应用程序(如果设备已 root)或窃取设备的人可能会从本地存储中恢复屏幕截图。

例如,捕获银行应用程序的屏幕截图可能会泄露有关用户的帐户、信用、交易等信息。

应用通知

重要的是要理解,通知绝不应被视为私密的。当通知被 Android 系统处理时,它会在系统范围内广播,并且任何运行 NotificationListenerService 的应用程序都可以侦听这些通知以接收完整内容,并可以随意处理它们。

有许多已知的恶意软件样本,例如 JokerAlien,它们滥用 NotificationListenerService 来侦听设备上的通知,然后将它们发送到攻击者控制的 C2 基础设施。通常,这样做是为了侦听在设备上显示为通知的双重身份验证 (2FA) 代码,然后将其发送给攻击者。对于用户而言,更安全的替代方法是使用不生成通知的 2FA 应用程序。

此外,Google Play 商店中还有许多应用程序提供通知日志记录功能,可以在本地记录 Android 系统上的任何通知。这突出表明,通知在 Android 上绝不是私密的,并且可以被设备上的任何其他应用程序访问。

因此,应检查所有通知的使用情况,以查找可能被恶意应用程序利用的机密或高风险信息。

键盘缓存

当用户在输入字段中输入信息时,键盘软件通常会根据以前输入的数据提供建议。此自动完成功能对于消息应用程序和其他场景非常有用。但是,默认情况下,Android 键盘可能会保留(或“缓存”)输入历史记录,以提供建议和自动完成。在输入敏感数据(例如密码或 PIN 码)的情况下,这种缓存行为可能会无意中暴露敏感信息。

应用程序可以通过适当配置文本输入字段上的 inputType 属性来控制此行为。有几种方法可以做到这一点

XML 布局

在应用程序的 XML 布局文件(通常位于解压 APK 后的 /res/layout 目录中)中,可以使用 android:inputType 属性直接在 <EditText> 元素中定义输入类型。例如,将输入类型设置为 "textPassword" 会自动禁用自动建议和缓存

<EditText
    android:id="@+id/password"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="@string/password_hint"
    android:inputType="textPassword" />

使用传统的 Android 视图系统

使用传统的 Android 视图系统在代码中创建输入字段时,可以以编程方式设置输入类型。例如,在 Kotlin 中使用 EditText

val input = EditText(context).apply {
    hint = "Enter PIN"
    inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
}

使用 Jetpack Compose

如果您正在使用 Jetpack Compose 进行开发,则不会直接使用 EditText。相反,您可以使用可组合函数,例如 TextFieldOutlinedTextField,以及诸如 keyboardOptionsvisualTransformation 之类的参数来实现类似的行为。例如,要创建没有建议的密码字段

OutlinedTextField(
    value = password,
    onValueChange = { password = it },
    label = { Text("Enter Password") },
    visualTransformation = PasswordVisualTransformation(),
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Password,
        autoCorrect = false
    ),
    modifier = Modifier.fillMaxWidth()
)

在此 Compose 示例中,PasswordVisualTransformation() 会屏蔽输入,并且具有 KeyboardType.PasswordkeyboardOptions 有助于指定密码输入类型。autoCorrect 参数设置为 false 以防止建议。

在内部,Jetpack Compose 中的 KeyboardType 枚举映射到 Android inputType 值。例如,KeyboardType.Password 对应于以下 inputType

KeyboardType.Password -> {
    this.inputType =
        InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
}
非缓存输入类型

无论使用哪种方法,应用程序都可以使用以下 inputType 属性,当应用于 <EditText> 元素时,会指示系统禁用这些输入字段的建议并防止缓存

XML android:inputType 代码 InputType API 级别
textNoSuggestions TYPE_TEXT_FLAG_NO_SUGGESTIONS 3
textPassword TYPE_TEXT_VARIATION_PASSWORD 3
textVisiblePassword TYPE_TEXT_VARIATION_VISIBLE_PASSWORD 3
numberPassword TYPE_NUMBER_VARIATION_PASSWORD 11
textWebPassword TYPE_TEXT_VARIATION_WEB_PASSWORD 11

注意:在 MASTG 测试中,我们不会检查 Android Manifest minSdkVersion 中的最低必需 SDK 版本,因为我们正在考虑测试现代应用程序。如果您正在测试旧版应用程序,则应检查它。例如,textWebPassword 需要 Android API 级别 11。否则,编译后的应用程序将不会遵循使用的输入类型常量,从而允许键盘缓存。

inputType 属性是标志和类的按位组合。InputType 类包含标志和类的常量。这些标志定义为 TYPE_TEXT_FLAG_*,这些类定义为 TYPE_CLASS_*。这些常量的值在 Android 源代码中定义。您可以在此处找到 InputType 类的源代码。

Android 中的 inputType 属性是这些常量的按位组合

  • 类常量 (TYPE_CLASS_*):输入类型(文本、数字、电话等)
  • 变体常量 (TYPE_TEXT_VARIATION_* 等):特定行为(密码、电子邮件、URI 等)
  • 标志常量 (TYPE_TEXT_FLAG_*):其他修饰符(无建议、多行等)

例如,以下 Kotlin 代码

inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD

其中

  • TYPE_CLASS_TEXT = 1
  • TYPE_TEXT_VARIATION_PASSWORD = 128

结果为 1 or 128 = 129,这是您将在反编译的代码中看到的值。

反向工程后如何解码输入类型属性

要解码 inputType 值,可以使用以下掩码

您可以使用掩码和按位 AND 运算(例如在 Python 中)快速解码 inputType

129 & 0x0000000F  #   1 (TYPE_CLASS_TEXT)
129 & 0x00000FF0  # 128 (TYPE_TEXT_VARIATION_PASSWORD)

如何查找缓存的数据

如果您在密码字段中输入例如 "OWASPMAS" 几次,该应用程序将缓存它,您将能够在缓存数据库中找到它

adb shell 'strings /data/data/com.google.android.inputmethod.latin/databases/trainingcachev3.db' | grep -i "OWASPMAS"
OWASPMAS@
OWASPMAS@
OWASPMAS%