如何构建透明的加密框架以保护 Android 应用程序(上)
导语:本文将对希望使数据加密过程可靠且方便的 Android 应用程序开发团队有用。
Android 应用程序收集有关其用户的各种敏感数据:财务记录、个人偏好、地理位置,甚至健康指标。存储这样的数据为用户提供了便利。但是,如果设备已植根,它也会使敏感数据面临安全风险。
为了加强数据保护,谷歌建议在内部存储中加密机密数据。但是使用谷歌提供的工具来做这件事可能是一个相当大的挑战,因为开发人员必须为他们想要保护的每个文件定义加密参数。在我们的一个 Android 开发项目中,我们决定创建一个可以方便且透明地加密文件的库。
在本文中,我们将引导您完成开发框架以实现透明加密的过程,即使设备已植根,也能保护数据。本文将对希望使数据加密过程可靠且方便的 Android 应用程序开发团队有用。
默认情况下,Google 会为 Android 开发人员提供用于数据加密的原生工具,例如JetPack 安全库。Android 还会加密设备上用户数据分区中的所有文件。
但是,为了确保特定应用程序的数据加密,应用程序开发人员必须定义应加密哪些文件以及每个文件的加密参数。这样做会花费开发人员很多时间。如果开发人员忘记指定某个文件或在加密参数中出错,它还会为人为错误留下空间。
在我们的一个 Android 开发项目中,我们决定创建一个自定义库,我们只需调用一次即可加密数据。它建立了自动数据加密的框架,使加密过程清晰,让我们能够专注于应用程序开发。
与任何项目一样,构建透明加密框架我们需要的第一件事是一个可靠的计划。
规划框架
Android 应用程序中的加密过程通常对开发人员隐藏。假设 Android 开发人员使用以下代码保存SharedPreferences的值:
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); Editor editor = prefs.edit(); editor.put("key", value); editor.commit();
在这种情况下,当使用 SharedPreferences 写入文件时,该值已经被加密。读取此文件时,我们会取回解密后的值。对于开发人员来说,这是一个他们无法改变的不透明过程。
改变加密和解密过程的唯一方法是完全控制它们。在 Android 应用程序内部的某个地方,Java 函数调用write和read C 函数,因此我们可以添加和返回一个新值。如果我们可以在调用write和read时将这些函数的执行重定向到我们自己的函数,我们就可以在进程中添加加密和解密。
但是我们如何重定向函数呢?
我们已经在共享 ELF 库中的重定向函数一文中介绍了如何重定向共享 Linux 库中的函数。由于 Android 是一个类 UNIX 系统,我们也可以使用我们在该文章中描述的方法来处理这个项目。
我们的透明加密框架应该拦截读写函数的执行并将执行重定向到包装函数。拦截读取函数将启动文件数据的读取和解密,拦截写入函数将启动初步加密并记录加密数据到文件。
下面是我们Android透明加密框架开发所需要的:
定义调用read和write的库的位置
打开库文件。
在库文件的.dynsym部分中找到对应于读写函数的符号,并保存该符号的索引。
使用保存的索引在库文件的 rel.plt 或 rel.dyn 部分中查找重定位值。
找到原函数地址的位置,即库地址和重定位地址之和。
用包装函数的地址重写原始地址。
要在安卓应用中实现一个加密框架,我们需要知道以下几点:
我们需要拦截的函数名称
我们将把原始函数重定向到的包装函数的地址
库的名称及其在文件系统中的路径
图书馆地址
我们已经知道我们需要拦截的函数是read和write,并且我们知道包装函数的对应地址。经过一番研究,我们发现我们可以从 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 库开始。我们会将其添加到各种应用程序中以加密其本地数据。
为此,我们将在没有Activity类的 Android Studio 中启动一个新项目。这个类帮助最终用户与应用程序交互,但它在我们的库中没有用。因此,让我们在应用程序级别删除 build.gradle 文件中的以下行:
plugins { id 'com.android.application' }
我们需要用以下行替换它:
plugins { id 'com.android.library }
现在,让我们从 Java 中添加Lde类。在我们将 ldelib 库添加到应用程序后,它的公共功能将变得可用。
Lde类加载将拦截所需函数的本机库。它只有一个公共方法,称为 EnableEncryption。此方法接受带有到文件目录的路由的条目作为参数。我们将使用此路由来过滤需要加密的文件以及使用 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 JNICALL Java_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 存档 (AAR) ldelib 依赖项。之后,build.gradle 文件将在指向 ldelib 库implementation files的部分中有一行。dependencies
接下来,我们在 MainActivity 文件中添加名为 com.example.ldelib 的导入包和Lde类。这使得 Lde 类的公共函数对应用程序可用,并允许我们在onCreate方法中调用EnableEncryption函数。
要检查拦截是否有效,我们需要尝试读取和写入 SharedPreferences。由于当我们请求一个新写入的值时 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 应用程序中,看看它是如何工作的。
发表评论