导语: 在分析恶意软件时偶然会发现受 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_ARRAYDT_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 类中, 则为 interface5interface6interface7interface8;
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
来源: http://jaq.alibaba.com/community/art/show?articleid=1565