导语:在分析恶意软件时偶然会发现受Native代码加壳保护的APK,本文将介绍如何解决恶意APK中常见的Native代码加壳保护。
在分析恶意软件时偶然会发现受Native代码加壳保护的APK,大多数情况下,这些样本只是通过分离出DEX文件中的类/变量名来简单地进行混淆或者是通过字符串混淆,其中包括:
1. 反调试技术:恶意代码用它识别是否被调试,或者让调试器失效。恶意代码编写者意识到分析人员经常使用调试器来观察恶意代码的操作,因此他们使用反调试技术尽可能地延长恶意代码的分析时间。Dalvik和本地代码都有反调试技术(例如检查JDWP或ptrace的状态)。
2.反挂钩(anti-hooking)技术:旨在阻止像Xposed,Cydia substrate(一款强大的插件支持框架(平台),没有它的支持,大部分的插件是无法工作的,也是必装的依赖性插件)或Frida等框架,以实现早期检测。这个想法主要基于检查由框架引起的副作用或指纹,例如,检查注入的库或对框架函数的堆栈进行调用。
3.反仿真(anti-emulation):许多分析人员都会使用Android模拟器,QEMU或Genymotion查看恶意程序如何执行以及是否触发任何奇怪行为。但许多受保护的或恶意的APK会拒绝在模拟环境中启动或相应地工作,例如,如果检测到模拟或分析环境,则恶意软件可能不会启动感染例程。
4.编码或加密:资源文件或可执行代码可以以压缩或加密的形式进行传播,如果恶意行为的保护是由不同的执行存根(execution stub)组成,则每个存根都会执行恶意行为并解码下一个存根。
5.突破静态分析工具:这通常需要攻击者很好地了解用于分析恶意行为的一些工具的缺陷,例如,攻击者可以利用特制的classes.dex或ELF文件,而这些文件不能被分析工具正确进行分析的。这个反分析的思想既可以应用于文件结构,也可以应用于代码级,常见的情况有静态编译加剥离的ELF文件,格式错误的头文件或应用于代码的各种反编译技巧。
6.混淆处理:
6.1 CFG混淆处理:此方法通常应用于本地代码,大部分时间是将LLVM-Obfuscator应用于代码的结果。
6.2 虚拟化混淆:这是常见的一种混淆方式,主要原理就是生成一个自定义的字节码语言,然后由虚拟机执行。本文会有一部分篇幅,专门介绍如何处理那些应对虚拟机的分析以及如何发现检测虚拟化混淆的常见指纹。
因此,要想知道如何破解这些反分析的技术,就要知道它们实现的过程,知己知彼,才能百战百胜。例如,只要在Google上搜索“Android反调试技术”,就可以发现大量这方面的研究。
目前可用的用于分析恶意攻击的方法
目前,分析人员已经开发了各种用于分析恶意攻击的方法,以下是一份简要的研究项目清单,目的是自动从受保护的APK中提取原始代码,并可分为两大类:
1.针对Android源代码的修改,这种分析方案背后的关键思想是修改和重新编译ART和Dalvik运库(分别为libart.so和libdvm.so)以钩住用于加载内存中类的特定函数并执行字节码。这样,加载到内存中的数据结构被识别,转储并重建与原始类似的classes.dex文件。目前研究人员已经开发了几个属于这一类的项目,我会按时间顺序列举如下一些:
1.1 DexHunter:Android脱壳神器DexHunter针对的是Dalvik和 Android Runtime (ART) ,它完全是基于检测加载时间并初始化内存中的新类,通过转储,最终重建一个DEX文件。
1.2 AppSpear:仅针对Dalvik Runtime,它的想法类似于DexHunter,但是它没有连接到加载类的方法,而是修改libdvm.so代码以检测并转储Dalvik数据结构,这是一个关键的结构,由VM在内部执行字节码。
1.3 android-unpacker:这是一款ndk写的动态Android脱壳的工具,原理简单来说就是ptrace,然后在内存中匹配dex_file.cc源代码,且仅注入必要的代码以便在DEX文件执时转储它。
2.辅助脚本:其他时候,有时我们可能无法编译Android源代码或不想这样做,那这时,我们就应该依赖像Xposed框架或Cydia substrate或使用其它调试器,例如GDB/ptrace。这个想法是开发一个挂钩脚本来控制应用程序的执行,以保存所有动态加载的文件。基于此解决方案的一些可用脚本包括:
2.1 DexHook:这是一个简单的Xposed模块,它的主要方法是挂钩动态加载DEX文件,可以很容易地将它扩展到ART或Dalvik运行时中更详细的挂钩。
2.2 gdb脚本:这是一组脚本,它们使用ptrace和gdb来调试进程并转储动态加载的DEX文件。
虽然以上列出的解决方案非常适合快速评估要分析的样本,但有时还是需要深入了解恶意软件特定的保护工作原理并确认其所有使用的技术。除非我们完全理解用于加载的解包机制,否则我们无法确定转储代码是否完整。举个例子,只有满足特定的条件时才会出现特定classes.dex文件,或者一个类可能还没有在运行时加载,而由于转储机制,我们错过了对它的分析。请注意,以上一些列出的研究方法还采取了进一步改进措施,并提出了可能的解决方案,以解决任意动态加载问题。
我可以认为以上这些方法都是采取完全动态方法所带来的局限性,这就是为什么应该在代码分析中结合静态逆向方法,比如保护代码,因为它实际上既可以提供关于内部工作的技巧或鲜为人知的有价值的逆向分析技巧,也可能被野外恶意软件利用。
这是从AppSpear论文中提取的示意图,并显示了DDS的内存结构
示例
接下来,我要分析的样本是一些中国应用程序,它们已被开发者进行了反分析保护,其中一些应用程序现在已被证实是恶意的。虽然在过去几个月里,这些应用的保护措施得到更新和加强,但核心技术不变。
这个封装器不会在smali级别传递混淆,但是保护的存根由多个层组成,在执行结束时,它会将原始classes.dex文件加载到内存中。接下里我会描述这个特定保护措施是如何进行反分析的,同时我还会提供一些可能有用的提示,以便你对类似样本进行逆向分析。
入口点
使用jadx检查受保护的APK,我可以立即看到AndroidManifest.xml仍然包含所有原始信息(例如权限,活动,服务,接收者和提供者),并且原始APK资源似乎没有被压缩或加密。另外,开发者可能会通过破坏或混淆manifest和各种资源以阻挠分析工具继续分析。
通过查看已识别的包和反编译的类,我可以注意到AndroidManifest.xml中介绍的原始入口点都不可用,而一个名为com.qihoo.util.StubApp1868252644的类则会作为新的入口点,或者更恰当地说它构成了保护的存根。该类来源于android.app.Application ,这样做是为了在创建流程之前确保在任何其他应用程序类之前执行。com.qihoo.util.StubApp1868252644应该在manifest应用程序标签中声明,实际上只要稍加留心就可以找到。反编译的代码还包含其他两个类:
1.com.qihoo.util.Configuration:用于声明不同的应用程序配置,在这种情况下,ENABLE_CRASH_REPORT变量已被设置为false,因此我们可以断定崩溃报告已被禁用。
2.com.qihoo.util.QHDialog:在分析本地代码的过程中,我们可以确定这个类的真正含义,即在解包阶段出现问题时,在Toast对话框中显示错误消息。
我通过jadx查看了反编译的代码,但是得出结论的是,虽然manifest和各种资源都存在,但是APK中嵌入的classes.dex文件显然缺少原始代码,这些原始代码是真的消失了吗?
classes.dex文件大小大约为4.3MB,但它只包含3个类,没有足够的代码来解释大小,所以下一步就要查看DEX header:
使用DEX模板的010Editor分析DEX header
DEX header显示了一个6104字节的data_size和一个2712的data_off值。如果我转到偏移量8816,就可以清楚地看到我还没有达到所期望的DEX文件的末尾,所以可以肯定,有些东西是不正确的。该偏移量的第一个字节看起来并不是真正有意义,但细心的人可能会注意到,前两个字节形成了字符串“qh”,这看起来像一个魔术值,以用于识别Qihoo数据段的开始部分。
Qihoo数据标头分的第一个字节
显然,我还无法从字节序列中获得更多信息,不过我可以猜测它是经过了某种方式的编码,例如,0x52的值重复了多次,有些提示可能是简单的XOR编码。
现在是分析com.qihoo.util.StubApp1868252644代码的时候了。
源代码不会以任何方式混淆,并且可以使用attachBaseContext方法标识入口点:
1.原始上下文被保存;
2.检测到CPU ABI并生成本地存根库的名称;
3.正确的本地库被复制到文件夹(具有775个权限);
本地库最终通过对System.load()的调用加载。
转换成本地代码
1.一旦加载了库,执行就会传递给本地代码,不过,我还需要在继续之前确认一件重要的事情,即确保在本地库的加载阶段不会遗漏任何代码。实际上,ELF结构和链接器文档详细说明了在控件传递到共享库的入口点之前所运行的链接程序采取的步骤(即JNI_OnLoad),这符合“初始化和终止程序”。
.preinit_array,.init_array和.init部分是在构建动态对象时由链接编辑器创建的,这些部分分别标记为.dynamic tags DT_PREINIT_ARRAY、DT_INIT_ARRAY和DT_INIT。其地址包含在由DT_PREINIT_ARRAY和DT_INIT_ARRAY指定的数组中的函数,该函数由运行时链接程序按照其地址出现在数组中的顺序执行。
为了检查初始化部分的存在,在ELF模板的帮助下我既可以使用010Editor,也可以使用LIEF编写简单的脚本来提取所需的信息。
在本地库上执行脚本将显示偏移量为0x1a00的终止例程,并且没有任何初始化例程。显然,新版本的保护器在.init_array部分有两个函数偏移量,函数应该用来初始化一些字符串,并在ELF完全加载后清除动态部分,这是用来进行动态分析的一种方法。现在,我就可以安全地从传统的入口点函数JNI_OnLoad开始进行静态和动态分析,此时,IDA将成为我进行分析的工具。
libjiagu.so ELF头文件以及初始化和终止例程
将APK加载到IDA窗口并在另一个窗口上加载本地库后,我就可以在本地开始我的动态分析了。 像往常一样,JNI_OnLoad函数会检索到JNIEnv指针,然后跳转到一个非常有趣的函数,我会将其重命名为VM_ENTER。
VM_ENTER函数代码
该函数很有趣,如果你熟悉虚拟机的混淆处理,则可以将代码片段标识为VM_ENTER函数,然后跳到虚拟机执行循环。操作过程如下:
1.分配一个0x100字节的虚拟栈或临时空间(0xC字节不包含在内,因为它们似乎不属于虚拟环境的一部分);
2.保存SP寄存器的原始值;
3.将R0的原始值保存到R12,LR和PC寄存器;
4.加载R0中的字节码指针和R1中的字节码大小;
然后跳转到另一个已被重命名为EXECUTE_BYTECODE的函数,该函数会执行以下操作:
1.保存原始状态标志;
2.推送被称为VM_MARK的0x1024值,因为它实际上用于识别保存的原始上下文的边界;
3.执行跳转到名为VIRTUAL_MACHINE的真正主例程,其目的是解析和执行输入字节码(记住R0和R1)。
EXECUTE_BYTECODE函数代码
VIRTUAL_MACHINE函数执行图最初可能看起来有点复杂,但它是由许多单独的块组成的,这些块非常有助于我们理解流程。
VIRTUAL_MACHINE函数执行图
对虚拟机的分析
现在我将对虚拟机体进行分析,同时我还将详细分析两个简单的虚拟指令。所有对ARM寄存器的引用都将仅适用于以前分析的样本。这个分析方法的关键点是提供了一个关于如何逆向虚拟机的方法,但不能将其视为虚拟机混淆问题的常规解决方案。虚拟机循环的保护实现遵循了一个相当常见的执行顺序,但其他解决方案虽然相似,但可能采用完全不同的方法。
执行虚拟机循环的步骤如下:
1.在进入循环之前,堆栈指针回退到VM_MARK,这样虚拟机就会保存一个指向滚动地址的指针并用它来访问所谓的VM_REG_CTX;
2.对以下值进行初始化的三个寄存器:
2.1 VM_BYTECODE_SIZE:该寄存器包含字节码的大小;
2.2 VM_BYTECODE_PTR:该寄存器包含指向字节码的指针;
2.3 VM_BYTECODE_INDEX:该寄存器包含虚拟PC。
如下所示,循环被真正执行并且可以被转换为更高级别的代码。
每个虚拟操作码具有不同的语义,相应地更新VM_REG_CTX和VM_BYTECODE_INDEX
我要看的两个虚拟机指令分别被命名为VM_NOP和VM_CALL,然后执行上下文分析。
R4 = VM_BYTECODE_PTR R5 = VM_REG_CTX R12 = VM_BYTECODE_INDEX
VM_NOP
这是所有指令中最简单的,正如其名字所包含的意思那样,它什么都不做,实际上NOP代表的是无操作的意思,这样虚拟PC就增加了1并将其进行了保存。
VM_NOP虚拟指令的本地代码
VM_CALL
这是一个重要的虚拟指令,用于调用虚拟代码中的所有函数或API,挂钩下面的代码将有助于理解反调试技巧和解包阶段,然后再跳到保护的第二本地存根。
VM_CALL虚拟指令的本地代码
编写de-virtualizer
我采取了以下步骤,来编写de-virtualizer:
1.在共享库中标识了所有字节码数组和字节码大小;
2.执行已经被手动执行,并且ARM代码已被转换为更高级别的Python代码;
3.每个虚拟指令的语义已被转换为伪ARM指令序列,如果需要的话,可以使用临时虚拟寄存器的支持;
4.如果将字节码及其大小输入到Python脚本中,将导致一系列ARM块的输出;
虽然Python代码不是足够好,但足以分析样本中的虚拟化代码并用作构建类似de-virtualizer的基础。真正的挑战是了解虚拟机如何处理条件控制流程(因为它完全基于虚拟CPSR寄存器的值),并正确实现虚拟机使用的辅助函数的语义,例如,算术,位测试和控制流程函数。
所讨论的该样本具有四个字节码虚拟化序列,其中第一个和第三个函数的控制流程图已经生成。
以上是第一个字节码序列(左侧)和第三个字节码序列(右侧)的CFG
可以看到,有很多BLX调用,目标地址已经被识别出来。虽然虚拟化代码最初可能并不完美,但在早期阶段,它会指出有哪些操作正在进行以及哪些函数被调用。
较新的反分析技术
较新的反分析技术就是防止将中文翻译为英文,目前还不清楚虚拟化机制是否已被删除或彻底改变。分析清楚地表明,在反调试步骤中,代码执行了很多跳转,所以看起来虚拟化仍然存在,但在研究阶段可能被忽略了。
反调试技术
就像所有反分析一样,在早期的解包阶段都会依靠反调试检查。特别是,第一个字节码序列会将所有的BLX调用嵌入到反调试器函数中,并相应地修改执行,例如用raise(SIGKILL)来终止进程。
在样本中有一个简单的反调试检查列表:
1.打开proc/self/status并读取TracerPid的值,确保其值为0;
2.打开linker/system/bin/linker 并读取函数rtld_db_activity的第一个字节,如果没有附加的调试器,那么该函数只是一个空的存根,而如果连接了调试器,则会在其中放置断点或未定义的指令,这样就可以再次保证字节值为0;
3.监控所有可访问进程的 /proc/{pid}/cmdline,以确保某些字符串不存在,例如android_server,gdb,gdbserver等;
4.检查/proc/net/tcp是否包含00000000:23946字符串,如果出现该字符串,则表示默认的IDA调试器已连接到设备。
5.通过inotify监控进程内存(proc/self/mem和proc/self/pagemap),以检查在进程空间中是否出现了新的不允许的映射。
在所有反调试检查都通过验证后,第二个存根的解包就可以开始了,并且执行过程的步骤如下:
1.对0x2F24D字节进行内存分配,并将加密和压缩的字节流复制到其中;
2.字节流被解码,新的内存分配了0x52A88字节;
3.目标指针(0x52A88)、目标大小、源指针(0x2F24D映射)和源文件大小都准备好了,以用作zlib->解压缩()函数的参数;
4.未压缩的字节流是一个新的ELF文件,这会影响到第二个存根。
内部ELF加载
由于第二个存根ELF文件已在内存中进行了解压缩,但未被系统正确加载。实际上,本地库集成了系统ELF加载器和动态链接器的一部分,旨在正确初始化第二个存根。该过程可分为以下步骤:
1.libdl.so库被加载到进程空间中(如果尚未存在);
2.一个0x20字节的结构由Jiagu360内部分配并用于跟踪一些重要的值(例如第二存根分配指针,ELF头指针,ELF程序头指针,头大小,程序头条目数,加载段大小,加载段指针),IDA提供的强大的结构支持;
3.所有的PT_LOAD段都被映射,从文件中填充并正确设置正确的内存访问属性;
4.PT_DYNAMIC表将被解析,并且每个动态条目都可以通过基于d_tag类型的big switch-case 正确处理;
5.检查每个必需共享对象的DT_NEEDED条目,如果库缺失,则在运行时动态加载它;
6.最后一步是处理重定位表,每个重定位条目意味着一个符号查找,该查找由DT_HASH和DT_GNU_HASH哈希表执行;
7.此时,所有可执行代码都已在内存中准备好,并且在跳转到第二个存根的JNI_OnLoad函数之前的最后一步开始执行初始化例程。从初始化函数代码的图中,我们可以很容易地看到正在使用的C ++语言的不同指示符,例如,每个函数都会分配一些内存,并将其初始化为构造函数;
8.执行从libjiagu.so传递到新加载的ELF文件的JNI_OnLoad条目。
第二个存根ELF标头以及初始化和终止例程
进行Dalvik的JNI
第二个存根包含的代码比加载的本地库多得多。事实上,它的主要目的是识别Android的执行环境(ART或Dalvik),解密并加载原始的classes.dex(如果支持MultiDex,就不止一个)。加载步骤如下:
1.由于libdvm.so或libart.so库的存在,Android执行环境已经确定,不过YunOS例外,实际上,环境类型是从ro.yunos.vm.name系统属性中提取的;
2.无论ART运行时是否被检测到一个大小为0x5C字节的类,如果检测到Dalvik运行时,则分配一个大小为0x14字节的类。而每个类则会派生自一个名为Runtime的公共父类,并加载正确的vtable;
3.相比于Dalvik,ART加载要稍微困难一些,这主要是因为在ART加载中,Dalvik字节码的文件格式更加复杂,必须在执行之前转换成优化的格式。这样在接下来的步骤中,只用解释Dalvik加载过程既可;
4.在 proc/self/maps中搜索原始(O)DEX文件并进行检查以确保文件实际上受到了保护,如上说述,使用了“qh”标记,它标识了保护标头的开始部分和数据部分。(O)DEX映射的起始地址和大小会被保存在一个结构中,另外还保存了指向标标头分的指针;
5.执行第四个虚拟化函数,其中R0代表指向保护部分的指针的+ 0xC,R1代表保护标头大小的0x2A0,这样就会发生一些重要的操作,比如生成一个128位的解密密钥并且如预期的那样用0x52 XOR密钥解码保护标头的0x2A0字节,这样就会出现以下的元数据序列;
activityName,apk-md5,checkSum和pkg的元数据值已被篡改过
6.元数据列表包含所有类型的信息,这些信息将被加载函数用于重建原始代码,验证是否被篡改,并启用了诸如支持x86代码或崩溃处理程序的附加功能;
7.所有的元数据都被解析,并插入到一个看起来是radix-tree的地方,其余代码将使用密钥从radix-tree中提取出来;
8.用崩溃报告功能和签名检查以验证.appkey是否发生;
9.解密所有嵌入到保护数据部分(恰好在保护标头之后)的classes.dex,你可以使用RC4或SM4解码数据,并使用128位解密密钥初始化块密码,这样每个classes.dex都会映射到一个新部分,编码后的数据被复制并解密;
10.每次解码新的classes.dex时,都会提取来自DEX标头的所有信息,并在可用类中搜索activityName,这样做是为了了解哪些解密的classes.dex包含AndroidManifest.xml中声明的原始入口点,以及用于将DEX文件添加到当前的ClassLoaderDEX文件的每个mCookie值。这些操作都是基于ro.build.version.sdk值进行的;
11.在所有classes.dex已经被正确地释放到内存中之后,是时候修复com.qihoo.util.StubApp1868252644和com.qihoo.util.StartActivity(如果存在)类的某些字段中包含的虚拟值了:
11.1 包含虚拟值“com.qihoo360.crypt.entryRunApplication”的strEntryApplication字段被替换为与METADATA key appName相关联的原始值,在本文中它的值为“com.fake.application.MainApplication”;
11.2 由于本文中的StartActivity类未声明,所以mEntryActivity字段不存在。在任何情况下,我都会使用与 METADATA key activityName关联的原始值来代替mEntryActivity的值;
12.此时,加载过程几乎完成,getClassNameList函数指针被保护库提供的本地实现覆盖。查看com.qihoo.util.StubApp1868252644,我还可以看到许多方法(例如interface5, interface6)标记为native,这意味着它们的实现是通过基于JNI接口的本地代码提供的。事实上,在返回到Dalvik环境之前,这些接口已被注册:
12.1 如果启用了崩溃支持,则为CrashReportDataFactory类的interface9;
12.2 在StubApp1868252644类中,则为interface5、interface6、interface7、interface8;
13.JNI_OnLoad执行结束,又返回到StubApp1868252644类,在该类中创建strEntryApplication的新历程,并调用内部附加函数来设置由本地代码制作的类加载器;
14.interface8函数被调用,并负责初始化提供的原始应用程序的内容。之后,本地资源也通过调用initAssetForNative方法进行初始化;
15.执行StubApp1868252644的onCreate方法,并负责Java端崩溃报告功能的初始化。然后调用interface7并加载原始strEntryApplication。最后执行interface5并更新添加到path /data/data/<pkg_name>/files/的assetpath;
16.此时,执行最终回到了原始应用程序类,该类在元数据上的值为com.fake.application.MainApplication。