另类保护 Android 应用程序
默认情况下,Google为Android开发人员提供用于数据加密的本机工具,例如JetPack Security库。Android 还会加密设备上用户数据分区中的所有文件。但是,为了确保特定应用程序的数据加密,应用程序开发人员必须定义应加密哪些文件以及每个文件的加密参数。这样做需要开发人员花费大量时间。它还为人为错误留下了空间,以防开发人员忘记指定某个文件或在加密参数中出错。在我们的一个 Android 开发项目中,我们决定创建一个自定义库,我们只需要调用一次即可加密数据。它建立了一个自动数据加密框架,使加密过程清晰,并允许我们专注于应用程序开发。与任何项目一样,构建透明加密框架的第一件事就是一个可靠的计划。规划框架Android 应用程序中的加密过程通常对开发人员隐藏。假设一个 Android 开发人员使用以下代码来保存SharedPreferences 的值: SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); Editor editor = prefs.edit(); editor.put("key", value); editor.commit();在这种情况下,在使用共享首选项写入文件时,此值已加密。读取此文件时,我们取回解密的值。对于开发人员来说,这是一个他们无法改变的不透明过程。更改加密和解密过程的唯一方法是完全控制它们。在Android应用程序中的某个地方,Java函数调用write和readC函数,因此我们可以添加和返回一个新值。如果我们可以在调用写入和读取时将这些函数的执行重定向到我们自己的函数,我们可以在过程中添加加密和解密。但是我们如何重定向函数呢?我们已经在相共享 ELF 库中的重定向函数一文中介绍了如何重定向共享 Linux 库中的函数。由于Android是一个类UNIX系统,我们也可以在这个项目中使用我们在那篇文章中描述的方法。我们的透明加密框架应该拦截读写函数的执行,并将执行重定向到包装函数。拦截读取功能将启动文件数据的读取和解密,拦截写入功能将启动对加密数据的初步加密和记录到文件中。以下是我们开发Android透明加密框架所需的内容:1. 定义调用读取和写入的库的位置2. 打开库文件。3. 在库文件的 .dynsym 部分找到与读取和写入函数对应的符号,并保存此符号的索引。4. 使用保存的索引在库文件的 rel.plt 或 rel.dyn 部分中查找重定位值。5. 查找原始函数地址的位置,即库地址和重定位地址的总和。6. 使用包装器函数的地址重写原始地址。要在 android 应用程序中实现加密框架,我们需要了解以下内容:· 我们需要截获的函数的名称· 我们将原始函数重定向到的包装函数的地址· 库的名称及其在文件系统中的路径· 图书馆的地址我们已经知道我们需要拦截的函数是读写的,我们知道包装函数的对应地址。经过一些研究,我们发现我们可以从libjavacore库中获取其余所需的信息。让我们看看如何实现 Android 应用程序的加密框架,从发现这个库的地址和库文件的路径开始。发现 libjavacore 库的细节经过一番研究,我们发现我们需要修改libjavacore库来拦截读写函数。当应用程序启动时,此库将加载到应用程序的进程内存中。我们可以使用dl_iterate_phdr函数处理加载到应用程序内存的对象: int dl_iterate_phdr( int (*callback) (struct dl_phdr_info *info, size_t size, void *data), void *data);此函数调用应用程序内存中每个对象的回调函数,并将指针传递给在加载的对象上存储数据的dl_phdr_info结构。下面是这个结构的样子: struct dl_phdr_info { ElfW(Addr) dlpi_addr; /* Base address of object */ const char *dlpi_name; /* (Null-terminated) name of object */ const ElfW(Phdr) *dlpi_phdr; /* Pointer to array of ELF program headers for this object */ ElfW(Half) dlpi_phnum;/* # of items in dlpi_phdr */ unsigned long long dlpi_adds; /* Incremented when a new object may have been added */ unsigned long long dlpi_subs; /* Incremented when an object may have been removed */ size_t dlpi_tls_modid; /* If there is a PT_TLS segment, its module ID is as used in TLS relocations, else zero */ void*dlpi_tls_data; /* Address of the calling thread's instance of this module's PT_TLS segment if it has one and it has been allocated in the calling thread, otherwise a null pointer */ };我们对这种结构中的两个字段特别感兴趣:dlpi_addr,其中包含加载的库和dlpi_name的地址,以及文件系统中库文件的路径。使用此数据,我们可以开始拦截函数。截获读写函数让我们从创建名为 ldelib 的 Android 库开始。我们会将其添加到各种应用程序中以加密其本地数据。为此,我们将在 Android Studio 中启动一个新项目,而不使用Activity类。此类可帮助最终用户与应用程序交互,但它在我们的库中没有用处。因此,让我们在应用程序级别删除 build.gradle 文件中的以下行: plugins { id 'com.android.application'}我们需要用以下行替换它: plugins { id 'com.android.library}现在,让我们添加来自 Java 的Lde类。它的公共函数将在我们将 ldelib 库添加到应用程序后变得可用。Lde类加载将截获所需函数的本机库。它只有一个公共方法,称为启用加密。此方法接受带有指向文件目录的路由的条目作为参数。我们将使用此路由来筛选需要加密的文件以及使用 Tink 库。EnableEncryption 还将调用本机Enable函数。要实现库的本机部分,让我们添加一个C++模块,方法是单击“项目”窗口中的应用程序目录,然后从上下文菜单中选择“C++添加到模块”选项。在这个模块中,我们可以定义我们的包装器函数。此时,它们只会记录事件并调用原始函数: ssize_t ReadHook(int fd, void *buf, size_t count){ LOGI("Hooked read function!"); ssize_t res = read(fd, buf, count); return res;}ssize_t WriteHook(int fd, const void *buf, size_t count){ LOGI("Hooked write function!"); ssize_t res = write(fd, buf, count); return res;}dl_iterate_phdr函数还可以帮助我们获取加载到内存中的库的地址。回调函数将检查对象的名称是否与库的名称相对应。如果是这样,该函数会将路由添加到库中,并将加载地址添加到 libInfo 结构中。以下是dl_iterate_phdr函数与库配合使用的方式: namespace{ struct LoadLibInfo { std::string libPath; size_t baseAddress; }; struct DataToCallback { std::string libName; LoadLibInfo info; };}static int Callback(struct dl_phdr_info *info, size_t /*size*/, void *data){ auto libInfo = static_cast<DataToCallback*>(data); if (info->dlpi_name != nullptr) { std::string libName = info->dlpi_name; if (libName.find(libInfo->libName) != std::string::npos) { libInfo->info.libPath = libName; libInfo->info.baseAddress = info->dlpi_addr; } } return 0;}LoadLibInfo GetLibInfo(const std::string& libName){ DataToCallback data = {libName, {"", 0}}; data.libName = libName; dl_iterate_phdr(Callback, &data); return data.info;}现在,让我们回到Enable函数,该函数获取有关 libjavacore 库的信息,并使用elf_hook() 函数将读取和写入函数重新路由到包装函数。我们在共享 ELF 库中重定向函数中详细介绍了此函数。在我们的例子中,elf_hook()函数可以执行拦截计划的所有步骤。以下是我们如何实现它: extern "C" JNIEXPORT jboolean JNICALLJava_com_example_ldelib_Lde_Enable(JNIEnv *env, jclass clazz, jstring pathToAppFiles){ auto info = GetLibInfo("libjavacore"); if (info.libPath.empty()){ LOGE("Failed to set hooks. libjavacore is not loaded into process"); return JNI_FALSE; } LOGI("libjavacore is loaded into the process. Path: "%s". Address: 0x%llx", info.libPath.c_str(), info.baseAddress); void* originalWriteAddress = elf_hook(info.libPath.c_str(), reinterpret_cast<void*>(info.baseAddress), "write", reinterpret_cast<void*>(WriteHook)); if (originalWriteAddress == nullptr){ LOGE("Failed to find "write" function in plt"); return JNI_FALSE; } LOGI("Set hook for "write" function. Original address: 0x%llx", reinterpret_cast<size_t>(originalWriteAddress)); void* originalReadAddress = elf_hook(info.libPath.c_str(), reinterpret_cast<void*>(info.baseAddress), "read", reinterpret_cast<void*>(ReadHook)); if (originalReadAddress == nullptr){ LOGE("Failed to find "read" function in plt"); return JNI_FALSE; } LOGI("Set hook for "read" function. Original address: 0x%llx", reinterpret_cast<size_t>(originalReadAddress)); return JNI_TRUE;}现在我们可以通过将我们的库添加到我们的 Android 应用程序中来检查我们的包装器函数是如何工作的。首先,我们需要创建新的应用程序项目。然后,我们通过单击文件 -> 项目结构,选择 JAR/AAR 依赖项并将路由添加到 AAR 存档来添加 Android Archive (AAR) ldelib 依赖项。之后,build.gradle 文件将在指向 ldelib 库的部分中包含 anline。implementation filesdependencies接下来,我们在 MainActivity 文件中添加名为 com.example.ldelib 的导入包和Lde类。这使得Lde类的公共函数可供应用程序使用,并允许我们在onCreate方法中调用EnableEncryption函数。要检查拦截是否有效,我们需要尝试读取和写入共享首选项。由于当我们请求新写入的值时,SharedPreferences不会读出文件,因此我们读取该值,然后写入一个新值对:key”key_1”=”value_1” import com.example.ldelib.Lde; public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Lde.EnableEncryption(getFilesDir().getPath()); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); String value = prefs.getString("key", ""); SharedPreferences.Editor editor = prefs.edit(); editor.putString("key_1", "value_1"); editor.apply(); }}如果挂钩工作正常,日志将包含有关新包装函数的消息:挂钩读取函数!和挂钩写入功能!现在,我们的库可以拦截读写函数。我们的下一步是将此自定义透明加密框架添加到Android应用程序中,看看它是如何工作的。将加密框架添加到我们的测试应用程序我们的下一步是向测试 Android 应用程序添加新功能,这些功能将对沙盒中的数据应用透明加密。该应用程序将允许用户:1. 查看以前保存在应用程序中的联系人2. 添加新联系人3. 在应用程序的浅色和深色主题之间进行选择应用程序将联系人信息存储在包含字典列表的 contacts.json 文件中。每个词典有三个字段:姓名、电话号码和电子邮件地址。以下是 contacts.json 文件中的记录的外观: # cat files/contacts.json[{"name":"Test","phone":"+1234567890","email":"test@test.com"},有关应用程序设置的信息存储在共享首选项中。由于测试应用程序只有一个设置 — 颜色主题 — 此文件仅使用 0 表示浅色,1 表示深色。以下是我们shared_preferences文件的内容: # cat shared_prefs/com.example.lde_test_app_preferences.xml<?xml version='1.0' encoding='utf-8' standalone='yes' ?><map> <int name="theme" value="1" /></map>新增数据加密功能我们将使用高级加密标准 (AES) 加密应用程序沙箱中的数据,这是一种对称块加密算法。我们选择了伽罗瓦/计数器模式(GCM)加密和256位密钥长度。与一个特定应用程序关联的所有文件都使用相同的密钥进行加密,并且密钥使用在 Android 密钥库中创建的主密钥进行加密。
创建加密库我们可以使用Tink库实现加密操作,该库提供加密 API 来加密数据。Tink目前支持Java,Android,C++,Objective-C,Go和Python,并在Apache许可证2.0下可用。虽然 Tink 支持 C++,但我们将使用 Java 来处理加密原语,因为它支持 Android 密钥库。C++只能与 AWS 和 Google Cloud Platform 的密钥管理系统配合使用。要开始使用 Tink,我们需要创建一个单独的 Java Android 库。描述包装器函数的本机库将与此 Java 库通信,并调用加密和解密方法。让我们使用 Gradle 将 Tink 链接到我们的 Android 项目: dependencies { ... implementation 'com.google.crypto.tink:tink-android:1.5.0' ...}要开始使用 Tink,我们需要先初始化它。初始化允许库用户选择如何实现加密基元。对于我们的任务,我们只需要支持 AES GCM 256 加密的关联数据身份验证加密 (AEAD) 原语。以下是我们如何在CryptoHelper类的加密和解密方法中注册它: import com.google.crypto.tink.aead.AeadConfig;...AeadConfig.register();...现在,我们可以创建加密密钥,加密密钥,并将其保存到应用程序沙箱。Tink 将密钥存储在密钥集 ах 中,其中包含密钥和关联的元数据。我们可以以 JSON 文件格式存储密钥集。最好加密此文件以确保密钥受到良好保护。以下是保存和加载密钥集的方法: import com.google.crypto.tink.JsonKeysetReader;import com.google.crypto.tink.JsonKeysetWriter;import com.google.crypto.tink.aead.AeadConfig;import com.google.crypto.tink.KeysetHandle;import com.google.crypto.tink.aead.AesGcmKeyManager;import com.google.crypto.tink.integration.android.AndroidKeystoreKmsClient; public class CryptoHelper { private static final String KEYSET_FILE_NAME = "/lde_keyset.json"; private static final String MASTER_KEY_URI = "android-keystore://LDE_MASTER_KEY";... private void storeKeyset(KeysetHandle keyset) throws IOException, GeneralSecurityException { String filePath = pathToFiles + KEYSET_FILE_NAME; keyset.write(JsonKeysetWriter.withFile(new File(filePath)), AndroidKeystoreKmsClient.getOrGenerateNewAeadKey(MASTER_KEY_URI)); } private KeysetHandle loadKeyset() throws IOException, GeneralSecurityException { String filePath = pathToFiles + KEYSET_FILE_NAME; File file = new File(filePath); if (file.exists()) { return KeysetHandle.read(JsonKeysetReader.withFile(file), AndroidKeystoreKmsClient.getOrGenerateNewAeadKey(MASTER_KEY_URI)); } return null; }... }虽然 KeysetHandle 类的读取方法是静态的,但 write 方法不是,它要求我们通过参数将 KeysetHandle 传递给 storeKeyset 方法,该方法将密钥集写入 keyset-ах 文件。来自 AndroidKeystoreKmsClient 类的 getOrGenerateNewAeadKey 方法帮助我们使用假名从 Android 密钥库获取加密密钥。此方法也可以创建这样的密钥。要使用密钥集,我们需要知道 pathToFiles 应用程序的文件的路径。此路径是启用加密功能工作的参数之一。密钥集是在我们调用加密方法时创建的。首先,让我们在应用程序沙箱中找到带有密钥的文件。如果不存在,我们假设密钥尚未创建。所以我们需要生成新的密钥并将其保存到应用程序文件系统中: KeysetHandle keyset = loadKeyset();if (keyset == null) { keyset = KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate()); storeKeyset(keyset);}AesGcmKeyManager.aes256GcmTemplate()函数返回确定如何为 AES GCM 算法生成 32 字节密钥的密钥模板。我们还调用storeKeyset函数,该函数在沙盒的文件目录中创建 lde_keyset.json 文件。此文件包含加密密钥。以下是我们的密钥集的外观: # cat files/lde_keyset.json{ "encryptedKeyset": "Cct7zebLSCF42m5llL6WijgAvC9ObLsB7TVjDkIvnWZ4d2xWUdhttcQUrT+BJVhiDoxsTKrNcHuf+gbDlqvDb/c7TqeeLwR0sTQqyYiOhAryZhnl8tSE3vXvn2uaXBsO5U8AxUdzNHmIlaumPrvUPLKoZK/tVHcIHqajweH9OZ2+HfJnxgs1jw==", "keysetInfo": { "primaryKeyId": 686385746, "keyInfo": [ { "typeUrl": "type.googleapis.com/google.crypto.tink.AesGcmKey", "status": "ENABLED", "keyId": 686385746, "outputPrefixType": "TINK" } }}用于加密和解密的函数首先必须从 KeysetHandle 类初始化对象并加载现有密钥集或生成新密钥集。之后,我们必须通过从KeysetHandle类调用getPrimitive方法来获取AEAD原语,并使用它来加密和解密数据。在我们的示例中,我们没有任何附加数据,因此我们可以将 null 传递给 AEAD 类的加密和解密方法。这是我们加密机制的最后一部分: import com.google.crypto.tink.Aead;import com.google.crypto.tink.KeysetHandle;import com.google.crypto.tink.aead.AesGcmKeyManager;... public class CryptoHelper { ... public byte[] encrypt(byte[] plaintext) { try { KeysetHandle keyset = loadKeyset(); if (keyset == null) { keyset = KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate()); storeKeyset(keyset); } Aead aead = keyset.getPrimitive(Aead.class); byte [] result = aead.encrypt(plaintext, null); return result; } catch (IOException | GeneralSecurityException e) { Log.e(TAG, "Failed to encrypt data. Error: " + e.getMessage()); return null; } } public byte[] decrypt(byte[] encrypted) { try { KeysetHandle keyset = loadKeyset(); if (keyset == null) { return null; } Aead aead = keyset.getPrimitive(Aead.class); byte [] result = aead.decrypt(encrypted, null); return result; } catch (IOException | GeneralSecurityException e) { e.printStackTrace(); Log.e(TAG, "Failed to decrypt data. Error: " + e.getMessage()); return null; } }}
构建框架现在,我们可以结合前两个阶段,并在包装器函数中配置加密和解密的调用。因此,让我们开始向安卓应用程序添加加密框架。要使用 ReadHook 和 WriteHook 方法,我们需要 CryptoHelper Java 类。我们可以使用 JavaNative Interface(JNI) 调用这个 Java 类。执行以下步骤需要此机制:1. 在初始化 Java 虚拟机时提供的目录列表中查找 Java 类2. 获取构造函数方法的标识符3. 创建新的 Java 对象4. 获取目标方法的标识符5. 调用对象的目标方法这些步骤中的每一个都要求我们使用 JNIEnv — 一个指向存储指向所有 JNI 函数指针的结构的指针。JNIEnv 指针是我们可以在从 Java 代码调用的本机方法的签名中找到的两个附加参数之一。但是,此指针是本地引用,这意味着我们只能在发送到的线程中使用它;当它退出本机方法时,它将停止工作。我们的 ReadHook 和 WriteHook 方法不是直接从 Java 代码调用的,它们仍然需要 JNIEnv。但是我们可以存储指向JavaVM的指针,我们可以在需要时随时从中获取JNIEnv。我们可以使用GetJavaVM函数获取指向 JavaVM 的指针: JavaVM* g_vm; env->GetJavaVM(&g_vm);然后,我们可以使用GetEnv函数从 JavaVM 获取指向 JNIEnv 的指针: JNIEnv* JvmWrapper::GetEnv(){ JNIEnv *env; int status = m_jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6); if (status != JNI_OK) { return nullptr; } return env;}此代码可以在未附加到 Java VM 的线程中执行。在这种情况下,GetEnv函数会将 JNIEnv 值设置为 null 并返回JNI_EDETACHED状态。理论上,我们可以使用AttachCurrentThread函数来处理这种情况,该函数将所需的线程添加到 Java VM 并返回 JNIEnv 指针。但由于我们的应用程序不支持多线程,因此没有必要这样做。ReadHook 和 WriteHook 方法都需要引用 CryptoHelper 类。默认情况下,这是一个本地引用,但我们可以使用NewGlobalRef函数将其转换为全局引用: auto randomClass = env->FindClass("com/example/cryptohelper/CryptoHelper");m_cryptoHelperHandler = reinterpret_cast<jclass>(env->NewGlobalRef(randomClass));当应用程序停止工作时,我们需要使用DeleteGlobalRef函数删除此引用。否则,引用可能会导致资源泄漏。
包装函数的算法让我们来看看我们的包装器函数是如何工作的。要读取数据,它们:1. 获取必要文件的加密内容2. 解密内容3. 如果可能,返回请求的解密数据写入时,我们的函数通过两个简单的步骤重写文件的全部内容:1. 加密我们要添加到文件中的数据2. 使用新的加密数据重写文件但是,仅当我们可以在一次迭代中写入所有必要的数据时,该算法才有效。如果我们需要写入大量数据,则必须添加部分读取和写入的选项。该框架也不应加密应用程序沙箱中文件以外的任何内容,不包括 Tink 所需的密钥集文件。包装器函数必须能够检查它们使用的文件的位置及其名称,这应该与lde_keyset不同。如果我们知道描述符并使用readLink函数,我们可以发现文件名: std::string GetPathByFd(int fd){ char filePath; std::string processDescriptors = "/proc/self/fd/" + std::to_string(fd); if (readlink(processDescriptors.c_str(), filePath, PATH_MAX) == -1) { LOGE("Failed to get file path via fd. Error: %s", strerror(errno)); return std::string(); } return filePath;}考虑到这些细节,让我们来看看包装器函数的算法。read函数具有以下初始数据:· FD — 包含加密数据的文件的描述符· buf — 指向应包含读取数据的缓冲区的指针· count — 包装函数必须读取的数据大小以下是包装函数的工作原理:写入函数具有以下初始数据:· fd — 函数向其写入数据的文件的描述符· buf — 指向包含要写入的数据的缓冲区的指针· count — 用于写入的数据的大小此函数的算法有点类似于读取函数:
框架限制和进一步改进我们为 Android 沙盒创建了一个透明数据加密框架。我们的包装器函数设计用于在测试应用程序中处理shared_preferences和 JSON 文件。在当前状态下,我们的解决方案仅涵盖读写函数的基本使用。它不能与多线程应用程序一起使用,也不能打开带有O_APPEND、O_ASYNC、O_CLOEXEC、O_DIRECT、O_DIRECTORY、O_DSYNC、O_EXCL、O_NOATIME、O_NOCTTY、O_NOFOLLOW、O_NONBLOCK、O_PATH或O_SYNC标志的文件。我们的框架也可能遇到性能限制,因为我们使用了块加密算法。与流加密算法相比,AES 需要更多的计算能力来加密大文件。我们在示例框架中使用了 AES,因为通常建议加密本地数据。我们可以通过以下方式进一步改进我们的框架:· 将加密过程从 Java 传输到本机代码。这将使我们能够摆脱对 JNI 的资源需求调用。要做到这一点,我们还必须用另一个库(如LibTomCrypt)替换Tink,这是一个公共领域的简单库,不需要大量资源。· 实现流加密。新的加密算法将提高框架的性能,并能够在不解密所有文件内容的情况下写入数据。我们创建的示例框架允许在应用程序沙箱中加密和解密文件,而不会减慢应用程序本身的速度。有了它,即使在有根的Android设备上,我们也可以保护应用程序的敏感数据。
谢谢分享! 感谢楼主,支持一下! 感谢楼主分享! 好东西赶紧拿走 楼主辛苦了,谢谢分享! 大佬牛啊??先收藏了再说 活到老,学到老!
页:
[1]