使用 QBDI 分析 Android 原生库
这篇博文讨论了 QBDI,以及如何使用 QBDI 来逆向一个安卓 JNI 库。
引言
在过去的几个月里,我们在 QBDI 中改进了对 ARM 的支持。更准确地说,我们增强了 QBDI 的引擎,以支持 Thumb 和 Thumb2 指令以及 Neno 寄存器。
开发仍在进行中,与 x86-64支持相比,我们需要清理代码并添加非回归测试。
为了添加 Thumb 和 Thumb2 支持,我们对著名的模糊处理器(如 Epona, O-LLVM 或 Arxan)进行了 DBI 测试,因为我们期待拥有良好的指令覆盖率、边角用例以及良好的用例。原生代码来自嵌入在不同 APK 中的 Android JNI 库。
这篇博客文章介绍了一些 QBDI 的特性,这些特性对于访问原生代码和加速逆向工程非常有用。为了揭示这些特性,我们分析了一个 Android SDK,它指在保护应用程序免受 API 滥用。
基于 Android 的动态插桩
Frida 是一个广泛用于测试应用程序的安卓日常动态测试框架。它可以通过内联挂接(inline hooking)处理本机代码,也可以通过 ART 插桩处理 Java 端[1]。
Frida 在函数级别工作,在某些情况下,我们可能需要在基本块级别或指令级别(即在插桩上有 hooks)上具有更细的粒度。
为了解决这个限制,常用的一个技巧是将hook与仿真结合起来。我们可以使用 Frida来hook我们感兴趣的函数,然后我们可以转储 CPU 上下文和进程的内存状态,最终通过 Miasm 或 Unicorn 这样的模拟器继续执行。中国菜刀
这种方法非常有效,但有一些局限性:
· 速度: 尤其对于大型函数集
· 外部调用: 需要模拟外部调用行为(例如 strlen、 malloc、 …)
· 有些行为可能很难模拟: 线程,安卓内部框架,..
此外,虽然模拟strlen的行为非常简单,但是模拟诸如 FindClass()、 GetMethodID()、 registerinator()、 … 等 JNI 函数行为可能更具挑战性。
QBDI的设计在完全检测和部分仿真之间提供了很好的折衷,这要归功于 ExecBrocker,它能够在检测代码(我们的函数)和非检测代码(strlen(), FindClass(), pthread_call_once())之间进行切换 … ..
这个图表显示了不同场景下的插桩流程:
对于那些对 QBDI 内部构件感兴趣的人,可以看看 Charles 和 Cédric [2]. 在 34C3 的演讲。在 GitHub 存储库[3]中也有一些例子。
总之,我们可以使用下面的方式启动 QBDI:
// QBDI main interface QBDI::VM vm; // QBDI CPU state for GPR registers GPRState* state = vm.getGPRState(); // Setup virtual stack uint8_t *fakestack = nullptr; QBDI::allocateVirtualStack(state, /* size */0x100000, &fakestack); // { // Setup instrumentation ranges, callbacks etc, ... // } // Start Instrumentation: uintptr_t retval; bool ok = vm.call(&retval, /* Address of the function to instrument */); // Instrumentation Finished
SDK 概述
在QBDI 测试中,我们分析了一个 SDK,它指在保护应用程序不受 API 滥用的影响。 这种保护用于保护 API 端点不被非法使用,如: 模拟器、机器人……。
为了保护主应用程序,解决方案收集关于设备状态的信息: root、调试、自定义,然后用专有算法对这些信息进行编码,并将编码后的数据发送到服务器。奇热看片
服务器解码设备收集器发送的信息,执行分析以检查设备的完整性,并发送一个令牌来处理设备是否损坏的信息。
下图总结了这一过程:
这种架构是健壮的,类似于 Safetynet [4] 中的架构。 另一方面,SDK 的权限比 Safetynet 少,因此它不能像 Safetynet 那样收集关于设备的大量数据。
我们通过监视 SDK 及其服务器之间的网络流量开始分析工作。在某种程度上,我们可以观察到以下要求:
它是 JSON 编码的,看起来像随机值的字符是设备收集器发送的编码信息。
对 SDK 的分析指在解决以下问题:
· SDK 是如何检查设备是否被 root 的?
· SDK 如何检测应用程序是否正在被调试?
· 从设备中收集什么类型的信息以及如何对其进行编码?
在查看了 Java 层之后,我们发现解决方案的逻辑是在一个名为 libApp.so [5] 的 JNI 库中实现的。 该库公开了以下 JNI 函数:
通过静态分析,我们可以确定 Java_XXX_JNIWrapper_ca3_14008()函数是生成序列"QJRR { JJJGQJ ~ | MJJJ…"所涉及的函数。 这个函数将编码后的数据作为 java.lang.String 返回并传入并接受两个非强制参数: bArr,iArr [6]。
这个库作为一个整体并没有经过特别的模糊处理。尽管如此,我们在著名的 libc 函数中发现了字符串编码和系统调用替换:
· read
· openat
· close
· …
这种技术通常用于避免被hook,但事实上,给定的系统调用包装在未内联的函数中。因此,可以 hook 封装了相关的系统调用的函数。
从 QBDI 开始
为了充分理解这个函数的逻辑,我们通过与一组插桩回调相关联的 QBDI [7] 检测这个函数。
这些回调指在提供对分析人员理解函数逻辑有用的不同类型的信息。 例如,我们可以设置一个记录所有系统调用指令的第一个回调,也可以添加一个记录内存访问的回调。
这篇博客文章的目的是展示回调函数是多么的少,但是选择得很好,通过这些回调能够理解函数的逻辑。
首先,可以使用 dlopen()/dlsym()在原始 APK 之外加载嵌入在 SDK 中的本地库。 此外,还可以通过 ART 运行时(libart.so)实例化一个 JVM:
int main(int argc, char** argv) { static constexpr const char* TARGET_LIB = "libApp.so"; void* hdl = dlopen(TARGET_LIB, RTLD_NOW); using jni_func_t = jstring(*)(JNIEnv* /* Other parameters are not required */); auto jni_func = reinterpret_cast<jni_func_t>(dlsym(hdl, "Java_XXX_JNIWrapper_ca3_14008")); JavaVM* jvm, JNIEnv* env; ART_Kitchen(jvm, env); // Instantiate the JVM and initialize the jvm and env pointers }
此时, jni_func()函数绑定到 Java_XXX_JNIWrapper_ca3_14008,并准备在 main()中执行:
jstring output = jni_func(env); const char* cstring = env->GetStringUTFChars(output, nullptr); console->info("Real Output: {}", cstring);
输出的值似乎与网络捕获的值"root:1"一致,因为我们是在一个已经 root 过的设备上 [8]:
现在,让我们通过 QBDI 运行这个函数:
console->info("Initializing VM ..."); QBDI::VM vm; GPRState* state = vm.getGPRState(); uint8_t *fakestack = nullptr; QBDI::allocateVirtualStack(state, 0x100000, &fakestack); console->info("Instument module: {}", TARGET_LIB); vm.addInstrumentedModule(TARGET_LIB); console->info("Simulate call in QBDI"); jstring dbioutput; bool ok = vm.call(&dbioutput, reinterpret_cast<rword>(jni_func), {reinterpret_cast<rword>(env)}); if (ok and dbioutput != nullptr) { console->info("DBI output {:x}", env->GetStringUTFChars(dbioutput, nullptr)); }
该代码输出了以下内容:
一切看起来都很好,QBDI 设法完整插桩(包括 ARM / Thumb 开关)该函数并且结果看起来类似于一次真正的执行结果。
分析
现在我们可以运行和检测函数了,我们可以开始添加插桩回调来分析它的行为。
对于设置非常有用的第一个回调函数是一个负责插桩系统调用指令(即 svc#0)的回调。为此,我们可以使用 vm.addSyscallCB (position, callback, data)。
· position-它代表回调的位置: 在系统调用之前或之后
· callback-回调本身
· data-指向用户数据的指针(例如,注册动态信息的用户上下文)
它引出了以下代码:
auto syscall_enter_cbk = [] (VMInstanceRef vm, GPRState *gprState, FPRState *fprState, void *data) { const InstAnalysis* analysis = vm->getInstAnalysis(ANALYSIS_INSTRUCTION | ANALYSIS_DISASSEMBLY); rword syscall_number = gprState->r7; /* * std::string sys_str = lookup[syscall_number]; // Lookup table that convert syscall number to function */ console->info("0x{:06x} {} ({})", addr, analysis->disassembly, sys_str); return VMAction::CONTINUE; }
vm.addSyscallCB(PREINST, syscall_enter_cbk, /* data */ nullptr);
在执行任何系统调用指令之前,我们对存储在 R7 寄存器中的系统调用号进行基本查找,以解析其名称。
其结果如下:
因为我们能够将系统调用的数字解析为函数名,所以我们可以改进调用函数参数的调度和打印逻辑:
auto syscall_enter_cbk = [] (...) { ... /* * Lookup table (syscall number, function pointer) * { * 322 -> on_openat * } */ auto function_wrapper = func_lookup[syscall_number]; return function_wrapper(...) } // Wrapper for openat syscall VMAction on_openat(VMInstanceRef vm, GPRState *gprState, ...) { auto path = reinterpret_cast<const char*>(gprState->r1); console->info("openat({})", path); return VMAction::CONTINUE; }
通过对通用系统调用号进行这样的操作,我们得到了这个新的调用链路跟踪:
根据这个输出,我们可以找出系统是如何执行 root 检查的(橙色区域)。 它通过检查以下二进制文件的存在来执行这个过程:
· /system/bin/su
· /system/xbin/su
· /sbin/su
· …
该函数还检查设备上是否存在一些目录(faccessat syscall) :
· /data
· /tmp
· /system
· …
特别是,如果目录 /tmp 出现在设备上,而标准的做法是有 /system 和 /data 目录,那么这种情况将是可疑的。
关于进程的调试状态(蓝色区域) ,可以通过查看 /proc/self/status 来完成。 分析之后,该函数检查 TracerPID 属性(来自于More Android Anti-Debugging Fun – B. Mueller)
最后,函数在返回编码值之前处理 /proc/self/maps 的输出。它表明解决方案收集的数据是基于这个资源的。
编码例程
在前面的部分中,我们对解决方案如何实现 root 检测、调试检测以及收集何种类型的数据(即进程内存映射)进行了全面的概述。
然而,还有一些问题悬而未决:
· 使用了进程内存映射的哪一部分: 基地址? 模块路径? ? 权限?
· 如何对数据进行编码的?(即如何生成 QJRR{JJJGQJ~|MJJJ…)?
除了对 QBDI ARM 的支持外,我们还添加了 ARM 支持来解析插桩过程中的内存地址。 这意味着 QBDI 现在能够解析指令的有效内存地址,例如:
LDR R0, [R1, R2]; # Resolve R1 + R2 STR R1, [R2, R3, LSL #2]; # Resolve R2 + R3 * 4 LDRB [PC, #4]; # Resolve **real** PC + 4
此外,QBDI 还能够得到被读取或写入的有效内存值。这个特性在条件指令的情况下非常有用,例如:
ITT LS; LDRLS R0, [R4]; LDRLS R1, [R0, #4]
R0和 R1的有效值存储在 QBDI 中。它可能不是 * (r4)和 * (r0 + 4) ,因为 LS 条件可能不会被验证。
要添加对内存访问的回调,我们可以使用 VM 实例上的 addMemAccessCB (…)函数:
vm.addMemAccessCB(MEMORY_READ_WRITE, memory_callback, /* data */ nullptr);
在给定的memory_callback(…)函数中,我们执行以下操作:
· 跟踪存储器字节访问
· 检查该值是否可打印
· 漂亮地打印 R/W 值
这个回调的思想是跟踪对可打印字符执行的内存访问。 它能够快速识别字符串编码/解码例程。
下面是回调的实现代码:
VMAction memory_callback(VMInstanceRef vm, GPRState *gprState, ...) { auto&& acc = vm->getInstMemoryAccess(); // Get last memory access MemoryAccess maccess = acc.back(); // Retrieve access information: rword addr = maccess.accessAddress; // Address accessed rword value = maccess.value; // Value read or written rword size = maccess.size; // Access size // Only look for byte access if (size != sizeof(char)) { return VMAction::CONTINUE; } // Read / Write operation as a string const std::string kind = maccess.type == MemoryAccessType::MEMORY_READ ? "[R]" : "[W]"; // Cast the value into a char const char cvalue = static_cast<char>(value); // Check if the value read or written is printable if (::isprint(cvalue)) { logger->info("0x{:x} {}: {}", addr, kind, cvalue); // Pretty print } // Continue this execution return VMAction::CONTINUE; }
使用这个新的回调函数,我们可以在 root 检查例程中涉及的两个 openat() 系统调用 之间观察到这样的输出:
它基本上是一个正在运行的字符串解码例程。 请注意,由于我们只跟踪可打印字符,因此缺少一些读操作。 但是,所有的写操作都存在的。
该例程使用地址 0x295e 的指令加载字符,并将解码值存储在地址0x2972。 如果我们查看处理这两个地址的函数,我们会找到解码程序:
在上图中,绿色部分突出显示内存加载访问,而红色部分突出显示写操作。 蓝色区域是解码逻辑。
在函数的整个执行过程中,所有读/写访问的输出都非常详细。 我们可以通过在函数调用之前和之后添加两个回调来改进插桩:
-
在调用之前,我们打印目标地址(例如:0x123: blx r3 -> .text!0xABC).
-
在调用之后,我们打印在被调用函数中读取或写入的所有可打印字符
addCallCB (…)仍处于试验阶段,但它的目标是将回调放在调用指令之前或之后:
// Callback before ``call`` instructions vm.addCallCB(PRECALL, on_call_enter, nullptr); // Callback when a ``call`` returns vm.addCallCB(POSTCALL, on_call_exit, nullptr);
使用这两个回调函数,我们得到以下输出:
通过进一步的内存跟踪,我们可以观察到这样的输出:
从这个输出我们可以推断出收集器的行为(伪代码) :
f = open("/proc/self/maps") for line in f.readlines(): if not "/" in line: # Avoid entries such as XXX-YYY ... [anon:linker_alloc] continue if not "-xp" in line # Process executable segments only continue buffer += encode(line)
我们还可以观察到下列行为:
1.READ line[i]
2.CALL .text!0xd2ba
3.WRITE encoded(line[i])
建议在地址0xd2ba 处实现 encode()函数的逻辑。
这个函数的 CFG 由比较输入和魔术打印值的指令复合而成,我们手动检查了它是否是编码函数。 此外,这个函数在设计上是可逆的,因为服务器端算法需要处理编码的数据。
库迁移
在前面的部分中,我们的目标是 ARM 版本的库。 结果表明,使用本地库的 SDK 通常为所有体系结构(arm、 arm64、 x86、 x86-64)提供库。
实际上,他们并不希望将开发人员限制在某些架构上。 前面分析的解决方案还附带一个 x86-64版本的 libApp.so具有完全相同的接口。
此外,前面的分析表明安卓系统并不存在真正的依赖性:
· Syscall 是一个标准,可以在 Linux 上使用。
· /proc/self/maps及/proc/self/status 也可以在 Linux 上使用。
因此,我们可以提取这个库并在 Linux 上运行它。 这个技巧已经在这篇博文中描述过了: 当 SideChannelMarvels 遇到 LIEF。
在第一步,我们必须用 LIEF 对库打补丁:
import lief libApp = lief.parse("libApp.so") # Patch library names # =================== libApp.get_library("libc.so").name = "libc.so.6" libApp.get_library("liblog.so").name = "libc.so.6" libApp.get_library("libm.so").name = "libm.so.6" libApp.get_library("libdl.so").name = "libdl.so.2" # Patch dynamic entries # ===================== # 1. Remove ELF constructors libApp[lief.ELF.DYNAMIC_TAGS.INIT_ARRAY].array = [] libApp[lief.ELF.DYNAMIC_TAGS.INIT_ARRAY].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp[lief.ELF.DYNAMIC_TAGS.INIT_ARRAYSZ].value = 0 libApp[lief.ELF.DYNAMIC_TAGS.FINI_ARRAY].array = [] libApp[lief.ELF.DYNAMIC_TAGS.FINI_ARRAY].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp[lief.ELF.DYNAMIC_TAGS.FINI_ARRAYSZ].value = 0 # 2. Remove symbol versioning libApp[lief.ELF.DYNAMIC_TAGS.VERNEEDNUM].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp[lief.ELF.DYNAMIC_TAGS.VERNEED].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp[lief.ELF.DYNAMIC_TAGS.VERDEFNUM].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp[lief.ELF.DYNAMIC_TAGS.VERDEF].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp[lief.ELF.DYNAMIC_TAGS.VERSYM].tag = lief.ELF.DYNAMIC_TAGS.DEBUG libApp.write("libApp-x86-64.so")
然后,我们可以实例化一个 Linux JVM 并运行本地函数:
int main() { JavaVM *jvm = nullptr; JNIEnv* env = nullptr; // JVM options JavaVMOption opt[1]; JavaVMInitArgs args; ... // JVM instantiation JNI_CreateJavaVM(&jvm, reinterpret_cast<void**>(&env), &args); // Load the library void* hdl = dlopen("libApp-x86-64.so", RTLD_LAZY | RTLD_LOCAL); // Resolve the functions using abi_t = jint(*)(JNIEnv*); using jni_func_t = jstring(*)(JNIEnv*); auto&& jni_get_abi = reinterpret_cast<abi_t>(dlsym(hdl, "Java_XXX_JNIWrapper_ca3_14007")); auto&& jni_func = reinterpret_cast<jni_func_t>(dlsym(hdl, "Java_XXX_JNIWrapper_ca3_14008")); // Execute jint abi = jni_get_abi(env); console->info("ABI: {:d}", abi); jstring encoded = jni_func(env); console->info("ca3_14008(): {}", env->GetStringUTFChars(encoded, nullptr)); return EXIT_SUCCESS; }
通过执行这段代码,我们得到了与前面部分类似的输出:
我们还可以运行 strace 实用程序来检查系统调用:
既然我们能够在 Linux 上运行这个函数,我们也可以使用 gdb、 Intel PIN 或 QBDI (x86-64)来分析这个库。
总结
虽然在 QBDI 中添加整个 ARM 支持非常具有挑战性,但是它在实际用例中开始工作得非常好。 这种支持还应导致有趣的应用,其中包括:
· 用于安卓的 HongFuzz/QBDI
· 针对 CPA 攻击的 SideChannelMarvels 整合
· Trustlets 插桩
这篇博客文章中使用的原始跟踪信息可以在这里找到: traces.zip
鸣谢
非常感谢 Charles Hubain 和 Cédric Tessier,他们开发和设计了 QBDI。 研究这个 DBI 中涉及的概念确实令人愉快。
感谢 LLVM 社区提供这样的框架,没有这样的框架,这个项目是不可能完成的。
感谢我的 Quarkslab 同事校对这篇文章。
参考资料
[1] 使用 Frida 修改与Java方法关联的art :: ArtMethod对象的字段
[3] https://github.com/QBDI/QBDI/blob/master/examples
[4] Droidguard 是一个收集设备信息的 SafetyNet 模块
[5] 这个名字是有意改变的
[6] 再加上 this 参数是一个 jclass 对象为静态方法
[7] 即使在这种情况下,静态分析已经足够了
[8] 使用 Magisk ROOT Nexus 5X-Android 8.1