使用QBDI分析Android原生库

使用 QBDI 分析 Android 原生库

这篇博文讨论了 QBDI,以及如何使用 QBDI 来逆向一个安卓 JNI 库。

引言

在过去的几个月里,我们在 QBDI 中改进了对 ARM 的支持。更准确地说,我们增强了 QBDI 的引擎,以支持 Thumb 和 Thumb2 指令以及 Neno 寄存器。

开发仍在进行中,与 x86-64支持相比,我们需要清理代码并添加非回归测试。

为了添加 Thumb 和 Thumb2 支持,我们对著名的模糊处理器(如 EponaO-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分析Android原生库

对于那些对 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、调试、自定义,然后用专有算法对这些信息进行编码,并将编码后的数据发送到服务器。奇热看片

服务器解码设备收集器发送的信息,执行分析以检查设备的完整性,并发送一个令牌来处理设备是否损坏的信息。

下图总结了这一过程:

 使用QBDI分析Android原生库

这种架构是健壮的,类似于 Safetynet [4] 中的架构。 另一方面,SDK 的权限比 Safetynet 少,因此它不能像 Safetynet 那样收集关于设备的大量数据。

我们通过监视 SDK 及其服务器之间的网络流量开始分析工作。在某种程度上,我们可以观察到以下要求:

 使用QBDI分析Android原生库

它是 JSON 编码的,看起来像随机值的字符是设备收集器发送的编码信息。

对 SDK 的分析指在解决以下问题:

· SDK 是如何检查设备是否被 root 的?

· SDK 如何检测应用程序是否正在被调试?

· 从设备中收集什么类型的信息以及如何对其进行编码?

在查看了 Java 层之后,我们发现解决方案的逻辑是在一个名为 libApp.so [5] 的 JNI 库中实现的。 该库公开了以下 JNI 函数:

使用QBDI分析Android原生库

通过静态分析,我们可以确定 Java_XXX_JNIWrapper_ca3_14008()函数是生成序列"QJRR { JJJGQJ ~ | MJJJ…"所涉及的函数。 这个函数将编码后的数据作为 java.lang.String 返回并传入并接受两个非强制参数: bArr,iArr [6]

使用QBDI分析Android原生库

这个库作为一个整体并没有经过特别的模糊处理。尽管如此,我们在著名的 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);

 使用QBDI分析Android原生库

输出的值似乎与网络捕获的值"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分析Android原生库

一切看起来都很好,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 寄存器中的系统调用号进行基本查找,以解析其名称。

其结果如下:

 使用QBDI分析Android原生库

因为我们能够将系统调用的数字解析为函数名,所以我们可以改进调用函数参数的调度和打印逻辑:

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;
}

通过对通用系统调用号进行这样的操作,我们得到了这个新的调用链路跟踪:

使用QBDI分析Android原生库

根据这个输出,我们可以找出系统是如何执行 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() 系统调用 之间观察到这样的输出:

 使用QBDI分析Android原生库

它基本上是一个正在运行的字符串解码例程。 请注意,由于我们只跟踪可打印字符,因此缺少一些读操作。 但是,所有的写操作都存在的。

该例程使用地址 0x295e 的指令加载字符,并将解码值存储在地址0x2972。 如果我们查看处理这两个地址的函数,我们会找到解码程序:

 使用QBDI分析Android原生库

在上图中,绿色部分突出显示内存加载访问,而红色部分突出显示写操作。 蓝色区域是解码逻辑。

在函数的整个执行过程中,所有读/写访问的输出都非常详细。 我们可以通过在函数调用之前和之后添加两个回调来改进插桩:

  1. 在调用之前,我们打印目标地址(例如:0x123: blx r3 -> .text!0xABC).

  2. 在调用之后,我们打印在被调用函数中读取或写入的所有可打印字符

addCallCB (…)仍处于试验阶段,但它的目标是将回调放在调用指令之前或之后:

// Callback before ``call`` instructions
vm.addCallCB(PRECALL,  on_call_enter, nullptr);
// Callback when a ``call`` returns
vm.addCallCB(POSTCALL, on_call_exit, nullptr);

使用这两个回调函数,我们得到以下输出:

 使用QBDI分析Android原生库

通过进一步的内存跟踪,我们可以观察到这样的输出:

使用QBDI分析Android原生库

从这个输出我们可以推断出收集器的行为(伪代码) :

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 由比较输入和魔术打印值的指令复合而成,我们手动检查了它是否是编码函数。 此外,这个函数在设计上是可逆的,因为服务器端算法需要处理编码的数据。

 使用QBDI分析Android原生库

库迁移

在前面的部分中,我们的目标是 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;
}

通过执行这段代码,我们得到了与前面部分类似的输出:

使用QBDI分析Android原生库

我们还可以运行 strace 实用程序来检查系统调用:

使用QBDI分析Android原生库

既然我们能够在 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对象的字段

[2] Slides – Talk

[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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值