3.1 Unidbg 的基本介绍
本文转自龙哥文章,原文地址:https://www.yuque.com/lilac-2hqvv/xdwlsg/idgio0?singleDoc#PtsaY
最后更新于
这有帮助吗?
本文转自龙哥文章,原文地址:https://www.yuque.com/lilac-2hqvv/xdwlsg/idgio0?singleDoc#PtsaY
最后更新于
这有帮助吗?
怎样能更好更快的分析二进制文件?这是困扰所有逆向分析人员的问题。二三十年来,无数的项目因此而生。如果只做脚本小子,疲于学习各式不同的工具,而不懂工具的原理和发展方向,那么最终一定会竹篮打水一场空。 我们需要去了解这些工具产生的原因,为什么会流行,以及下一个形态或者说更好的工具是什么样。
是 在 2019
年初开源的一个轻量级模拟器,支持对 Android Native
函数的模拟执行。在开源数月后,它做了进一步的扩展,试图增加对 IOS Native
函数模拟执行的支持。到目前为止,Unidbg
在 Android Native
上的完善度和可用度更高,我们也主要讨论 Android 而非 IOS 部分。
它是一个基于 构建的 JAVA 项目,请读者在 Github 下载它的源代码,然后在 IDEA/VScode
等 IDE 里打开,其依赖下载完成后,测试运行unidbg-android/src/test/java/com/sun/jna/JniDispatch32.java
代码文件,如果环境无误,最终可以得到如下的运行结果。
让我们回忆一下在 Android Native
场景下那些最顺手和热门的工具吧。 在常规工具上,最常用的是 IDA
,我们用它做反汇编和反编译。
反汇编就是将上图最上方的机器码转成左下的汇编代码,反编译就是将机器码或汇编代码转成右下的 C 伪代码。IDA 的反编译质量很好,很多逆向人员都靠它的 F5 讨口饭吃。
一些朋友喜欢用 Binary Ninja 或 Ghidra,这自然也有原因。从价格上看,IDA 的正版价格太贵了,Binary Ninja 便宜很多,Ghidra 则是免费。除了价格外,Ghidra 和 Binary Ninja 的使用体验和能力也都不错。Ghidra 是 NSA 的开源软件,它的完成度相当高,并且得到了开源社区的广泛支持,反编译质量也很好,几乎有和 IDA 二分天下的趋势。Binary Ninja 的 UI 很漂亮,而且控制流和数据流分析都很好,很多 IDA 没法处理的花指令,在 Binary Ninja 上则不会造成任何干扰。
反汇编、反编译工具提供的是静态分析能力,除了静态分析,我们还需要动态分析,即观察程序的真实执行情况。最古典和通用的动态分析工具是调试器,比如 GDB、LLDB、IDA Debugger 等等。调试器提供了经典的断点、单步调试、寄存器和内存的查看与修改等功能。调试器的实现基于 CPU 所提供的中断和异常机制,比如断点指令、单步模式等等。调试器作为最经典的动态分析工具,它有两个痛点。一是因为经典和常用,所以对它的检测手段发展的特别成熟,几乎所有应用都做了检测和阻止调试器使用的代码逻辑。二是调试器基于异常机制实现,所以它的开销很大,用过调试器 trace 的都知道,相当的慢。
除了调试器,动态分析的另一大类工具是 Hook,在 Android Native 分析场景里最多使用的 Hook 技术是 inline Hook 和 plt Hook,前者的代表是 Frida,后者的代表是 xHook。很多人都喜欢使用 Frida Hook Native,因为资料很多,而且 Frida 的 JS binding 确实用起来很爽。但应当注意,越来越多的工具开始检测 Frida,就像过去检测调试器那样频繁。
调试器主要用在算法分析上,Hook 既可以用在算法分析上,也可以用于观察监测。怎么区分算法分析和观察监测?算法分析是一个比较“细”的概念,关心的是某一个数据生成的全过程,监控观察是一个比较“粗”的概念,着重于程序对系统关键 API 的访问,比如 JNI、文件访问、系统调用、库函数、加解密函数等等。
有时我们会魔改和定制 Android 系统,以实现更好的观察监测,我们称这个环境为沙箱,最常见的处理方式包括修改内核的系统调用表,实现系统调用的监控和拦截,以及修改 Android FrameWork 代码,监控 JNI 函数的调用、SO 的加载、标准加解密算法的调用等等。
这两年很流行 eBPF,它是 Linux 内核提供给我们的后门和观测接口,让我们可以用一种非侵入、优雅、灵活的方式观测 Android 系统,实现对 JNI、系统调用、网络流等关键 API 的监控和拦截。
不妨总结一下。IDA 等反汇编、反编译工具让我们以静态分析的方式做算法分析和监控观察;调试器是一种传统、通用的动态分析工具;Hook 是最灵活和广泛使用的动态分析技术,在算法分析、监控观察上都应用广泛;魔改/定制系统,以及使用 ebpf 机制等等,是从系统的角度实现全面、无遗漏的监控观察,也是 2022 年在 Android 逆向上最热门的话题。
每个技术方向和旗下的具体工具,都以静态或动态的方式,帮助我们更好的理解二进制程序里的行为或算法。那么Unidbg 的定位和优势在哪里?
Unidbg 在大类上属于模拟器,如果要讨论 Unidbg 的定位和优势,就要两步走。一是讨论模拟器的定位和优势,二是讨论 Unidbg 和别的模拟器项目相比,有什么优势。讲清楚这些问题并不容易,我将内容分成三小节。本节讨论模拟器。
模拟器可以简单分为 CPU 模拟器和操作系统模拟器,先说说 CPU 模拟器。
CPU 是计算机的核心运算模块,它的职责很简单,就是执行指令。在具体流程上,分为取指—译码—执行三步骤。
如下是一个 ARM 程序在 IDA 里的汇编片段。
CPU 接收到的是形如下文的字节流。
CPU 先是从 PC 指向的内存地址获取(fetch)一条机器码,比如这里的 IDA 程序里,首条是8A 42。如果指令集是定长指令集,比如 ARM64,每条指令都 4 字节长度,那么取指会相当简单,总是取 PC 到 PC+4 这四字节即可。如果是变长指令集,比如 X86 ,一条指令可能 1 字节,也可能十数字节,或 thumb2 指令集,一条指令可能可能 2 字节,可能 4 字节,那么取指的处理上就要复杂一些。
取指后是所谓的译码(Decode),译码就是要翻译机器码,弄懂这几个字节的语义,包括指令的类别,以及操作数的个数和类别,你可以将译码理解为反汇编,它是将8A 42翻译为CMP R2, R1的过程。
以 ARM64 为例,每条指令长 4 字节,也就是 32 比特。它的某些位用于描述这条指令执行哪一类操作,包括加载内存、存储到内存、加减乘除、异或、与运算等等。我们将这些描述和限定操作类型的“位”称之为操作码(OpCode),其余位则描述了这种操作所需的参数,我们称之为操作数,操作数可能是寄存器、立即数或内存地址。
译码或者说反汇编,就是要搞清楚形如4D F6 B2 76或8A 42这样的机器码,它的操作码对应于什么类型的操作,以及操作数具体是什么。比如我们首行的8A 42,IDA 反汇编结果显示,它的操作码是 CMP,第一个操作数是 R2,第二个操作数是 R1。
译码的难度取决于指令集的编码设计规则,假设某个指令集里,OpCode 总是指令的首个字节,那么译码会相对简单。比如规定首字节为 0x00 表示 Nop,首字节为 0x01 是 ADD,首字节是 0xFE 表示 LDR,首字节是 0xFF 表示 STR。那么在译码时,只需要解析首字节,再根据对应的指令类别规则去解析操作数即可。但假设另一个指令集里,为了编排的紧凑或其他原因,使用 32 比特里分散开的十数个位表示 OpCode,那么处理上就复杂一些,需要用到掩码和移位运算才能确定操作码。
在译码之后,就是由 CPU 去执行这条指令,这个过程里可能会访问和更新寄存器、内存、立即数等等。
CPU 对指令所做的取指-译码-执行三步骤是一种硬件上的支持,如果我们用软件方式去实现这三步骤,那么这就是一个 CPU 模拟器,你也可以叫它虚拟机,本质是一回事。
下面是一个简单的伪代码结构,永不停息的往下模拟执行指令。
CPU 模拟器在计算机科学的很多领域都广泛使用,比如教学和开发测试。
它对一个由F#语言编写的项目,模拟了 ARM32 架构下的 ARM 指令集(不包括 Thumb2),它有一个漂亮的前端界面,学生们甚至还为部分指令设计了悬浮示意图,如下所示。
开发测试也是很大的需求,做一些跨平台的汇编开发时,指令模拟器可以帮助开发人员测试、验证功能以及发掘 Bug,比如嵌入式开发里使用的仿真调试器等等。
如果从逆向需求的角度看 CPU 模拟器,不得不说,过往的开源 CPU 模拟器问题很多。
速度慢是另一个大问题,真实 CPU 在数十年来,依靠硬件发展和相关技术的进步,指令执行速度快到了一种可谓恐怖的地步。模拟 CPU 采用软件方式去做指令仿真,速度一对比那是相当的慢,甚至每秒只能运行几百条指令。复杂的样本动辄数百上千万行执行流,采用这样的模拟器,简直跑不完。
为了让模拟器提速,最主流的方案是放弃模拟,转向翻译。具体而言就是将待模拟的指令翻译成宿主机上的本地指令,然后交由 CPU 直接执行,其中最广泛使用的技术是 JIT。举个例子,在 X86 的处理器上执行 ARM 指令,那么可以将 ARM 指令翻译成 X86 指令,然后交由处理器直接运行。
有的读者可能会困惑,上面我们说模拟器存在那么多问题,那为什么我们还要用模拟器呢?这是因为正常设计的 CPU 就是毫无情感的指令执行机器,它不会为我们算法分析提供一丁点帮助或后门,而模拟器不同,我们设计模拟器的目的就是为了更好的观察指令执行的过程,因此会在模拟器里提供各种各样的回调、拦截和打印,以增强对自身的观测。
每执行一条指令进入自定义的处理逻辑
每执行某一类指令(比如跳转指令或内存加载指令)进入自定义的处理逻辑
每执行某一种指令(比如 CMP)进入自定义的处理逻辑
每执行某一种指令下的某个子类(比如 CMP.W)进入自定义的处理逻辑
某个寄存器被赋值进入自定义的处理逻辑
某片内存被访问进入自定义的处理逻辑
下面具体举例几个 API,比如当 eax 被访问(读、写)时进入自定义的my_eax_handler处理逻辑。
比如当指令为CMP
时,进行拦截和打印。
比如当对 0x44444444
这个地址发起读写时,进入自定义的my_memory_handler
进行内容的打印。
上面我们简单讨论了 CPU 模拟器,接下来讨论操作系统模拟器。
CPU 模拟器的唯一功能就是执行指令,这意味着它不理解上层概念,比如各种二进制文件格式 PE/ELF/MachO,解析和加载它们是操作系统的工作。除此之外,典型的操作系统会提供数百个系统调用,功能从获取系统信息到进程/线程管理,以及内存管理、文件管理等等。你可能更习惯"库函数"的概念,但它在底层实现上同样依赖于系统调用,比如下面就是 openat 的实现。
SVC
指令用于发起系统调用,它会陷入内核,然后交由操作系统去做资源的管理和调度。一般的 CPU 模拟器遇到SVC指令就会报错,或者什么都不做继续往下执行(就像遇到 NOP 那样)。
为了实现这些系统调用,你至少需要理解这些系统调用的语义,其中的某些可以用硬编码的方式糊弄,另一些则需要利用宿主机的 API,以及自己去构建大量的逻辑去模拟这些系统调用,这会是很大的工作量。
上面我们讨论了 CPU 模拟器和操作系统模拟器,下面谈谈 Unicorn 这个项目,Unidbg、Qiling 等操作系统模拟器都基于它开发。它是新加坡南洋理工大学团队在 2015 年开源的一款 CPU 模拟器,目前更新到了 Unicorn2。除此之外,Keystone、Capstone 也都是这个团队的作品,我们称之为三剑客,分别用于汇编、反汇编和模拟执行。Unicorn 直译是独角兽,它的 logo 也据此设计。
Unicorn 自诩为新一代的 CPU 模拟器,自认为比过往的模拟器都高上那么一头,它的底气从何而来?在我看来主要来自三方面。
一是 Unicorn 躺在大树底下乘凉。Qemu 是最著名的开源操作系统模拟器,我们熟知的各种手游模拟器,比如夜神、雷电等等,都依赖于 Qemu 的支持。Unicorn 将 Qemu 的 CPU 模拟器部分扣了出来,作为一个单独的项目。Qemu 所具有的各种优良特性,比如支持十数个架构(X86/X64/ARM/ARM64/Mips 等),比如通过 JIT 将指令翻译成宿主机的本地指令以实现加速等等,都被 Unicorn 继承了过来。
二是 Unicorn 自身也确实努力。光薅 Qemu 的羊毛是不够的,它自己也做了不少事。Unicorn 提供了各种粒度的 Hook 和拦截,就像上面介绍的 PyEmu 那样,这花了不少功夫。Unicorn 还提供了 Python、Java、C# 等多种语言的绑定,可以在各种语言的项目里方便的调用它。过去的很多项目则不然,比如 X86Emu 是一个 IDA 插件,缺少良好的移植性,比如 PyEmu 是用 Python2 写的,用起来很不方便。
三是 Unicorn 的设计理念很好,过去很多的 CPU 模拟器,为了让自身有更好的实用性,都会既做 CPU 模拟器的活儿,又去做一定的操作系统模拟。你也可以反过来理解,因为过去缺少好用的 CPU 模拟器,所以如果你想写一个操作系统模拟器,就必须连带写一个 CPU 模拟器,比如 X86Emu 有超过一半的代码都在做指令模拟的处理。CPU 模拟器和操作系统模拟器的这样一种强耦合,带来了非常多的问题。两头都要管,都要抓,实际上是两头奔波,一会儿完善指令,一会儿完善系统调用,最后在两边往往都没有处理的太好。
Unicorn 专注于 CPU 模拟器,提供给用户简单的 Hook 接口,内存操作接口以及指令执行接口,完全不去考虑上层操作系统的那些活,什么二进制格式、系统调用、库函数等等一律不管,即提供一种纯粹的、支持多架构、运行高效的 CPU 模拟功能。
这让研究人员可以基于它,专心构建上层的操作系统模拟器,反正底层有好用又坚固的 Unicorn 做支撑,专注上层就行了。这几年涌现出数十个基于 Unicorn 的操作系统模拟器,Unidbg、qiling 都是其中的优秀代表。
简而言之,Unicorn 是一个很好的指令模拟器,比过去的 CPU 模拟器都更专注和强大。
基于 Unicorn 的项目有数十上百个,为什么我们要使用 Unidbg?这其实很简单,就两点原因。
我们要讨论两个方面,1 是相比较过去的方案,模拟器有什么好处。2 是目前对模拟器的开发有和不足。
模拟器一直被认为,相比较普通的 debug 或 Hook,更具有竞争力。
首先考虑 debug,原先使用 GDB/LLDB/IDA Debugger 这些传统调试器,它们有两大问题,一是太容易被检测,这既是因为调试器所基于的各种基础套件,比如软件断点、ptrace、debug server 等等,都特征明显,也是因为调试器作为一种传统方案,对它的检测已经成为一种惯例。二是调试器方案的限制颇多,功能很受限。比如监控内存读写所依赖的 watchpoint,在 arm 架构上就很难使用;比如指令追踪,在调试器上的开销过于巨大,几乎没法用在大型样本的追踪上。这是因为指令追踪在调试器上的实现基于断点和单步执行,断点和单步在调试器上依赖于异常处理机制实现。异常处理的开销很大,大大的拖累和影响了程序本身的执行。
模拟器的好处就很明显,在设计上往往自带了 codeHook 和 memHook,功能强大、稳定、灵活。像 Unidbg 这样的成熟工具,既提供了基于 Unicorn 构建的无界面调试器,又实现了良好的 GDBStub,可以将 IDA 调试界面作为展示的前端。除此之外,Unidbg 的 traceCode 可以实现每小时数千万行指令的追踪,使用体验非常好。
其次谈谈 Hook,比如 Frida 这样的 Hook 方案同样容易被检测,在 Unidbg 里既提供了类似 Frida 的 Dobby 等第三方 Hook 框架,又可以基于 Unicorn codeHook 做 Hook。这让我们在 Unidbg 里有不错的 Hook 体验,而且不易被检测。(但总体来说,在 Hook 这方面并不比过往工具的体验更好或功能更强大。)
最后是和魔改、定制系统,或利用系统本身提供的机制所实现的监控(比如 ebpf)相比,Unidbg 有什么特点?这里不妨说的更多一些。
在动态分析领域,对于如何观测样本的行为,有两类思潮。
一是基于完整系统做修改或利用其内部的某些机制,或者完整的模拟(比如 QEMU),让系统”自上而下“的具有某种观测能力。简单一些的比如修改 Rom 打印 JNI 调用、SO 加载;复杂一些的比如修改系统调用表,实现对系统调用的监控;无感且优雅的比如用内核提供的 ebpf 机制拦截和监控系统调用。
二是自下而上的构建局部可用的微型操作系统,实现对运行环境的完全控制。比如 Qiling、Unidbg 都属于此列,以 CPU 模拟器为基,添加 Loader、模拟主要的系统调用、实现关键的内存管理、文件管理、线程管理,然后根据具体的样本做一堆 patch,这就得到了一个拥有完全掌控能力的环境。这是显然的,系统调用、JNI 函数、SO 加载逻辑等等都是自己实现或模拟的,没有一点点无法窥探的地方。
怎么看待这两类思潮?应该说都有用也都有限制。
对于方案一,最大的好处就是通用和适配,只要操作系统能处理的样本和场景,那么就都可用。而方案二却不行,因为它是模拟的操作系统,模拟好比观航空母舰造小船,必然不可能面面俱到。如果说操作系统可以处理各种各样的情况,那么各种各样的模拟器必然只能处理很少的某些场景。比如 Unidbg 就不擅长处理游戏 SO,更没法处理整个应用。
对于方案二,它最大的好处是对环境的掌控力强。还是以船为例,对于航空母舰,即使你添加再多的机关,也不可能对它拥有完完全全的掌控力,你只是在使用它的机制,和它形成某种约定和妥协。而对于你造的小破船,它的每块木材,每个细节都为你所控,可以获得完全、极致的掌控能力。比如在 Unidbg 里我们可以轻松的指定 SO 的基地址该为多少、栈顶在哪儿、某个系统调用的实现是如何、对哪个文件做了什么操作等等。
当前第一类方案的研究热度更高,因为 EBPF 实在是太强大了,它让我们对航空母舰拥有了更细微和灵活的操控,而且它远好于侵入式的各种方案,而是系统本身提供的机制和“后门”。
现在很多人用模拟器来做监控观察,比如用 Unidbg 模拟执行跑通样本,然后观察 JNITrace、Syscall Trace、文件访问等等。如果说模拟器对算法分析颇有助益,其能力来自于底层的 CPU 模拟器,那么它对监控观察的帮助,则来自于对操作系统的模拟,在模拟操作系统的过程里,打印几个 log 输出内部的 JNI 和 Syscall 并非难事。
但严格的说,监控观察并非模拟器的长项,在这方面,研究人员应该把更多的精力放在 ebpf 等真实系统上所支持的观测机制上。原因也很简单——模拟器并不能完备的实现所有系统调用,因此对模拟器所作的观测,和样本真实发生在设备的行为并不完全一致,尤其对于复杂样本而言,这种不一致是巨大的。
模拟器最核心最主要的功能始终应该是辅助算法还原。这里还有一个重要的区分,就是重型操作系统模拟器和微型操作系统模拟器的差异,严格来说,Unidbg 等模拟器都属于微型、轻量、受限的操作系统模拟器,你没法指望它们像真实操作系统那样运行一个完整的 APK,更别提游戏应用了。比如 Bochs、Qemu 这样的模拟器就是重型或者说完整操作系统模拟器,你可以在这样的模拟器做几乎和真实操作系统相同的任何事。
重型操作系统模拟器更强大,但相对不够灵活,能提供的分析和调试能力相对少,轻量级操作系统模拟器功能比较受限,但更加灵活,可以做最细微又最全面的各项分析。这是一种理论上的说法,但在现实里我们观察到,轻量级操作系统模拟器都有些不太争气,它们在能力上远弱于重型操作系统模拟器,但又没有提供太多太好的功能。
我们不妨说的更清楚一些,因为事实上这件事并不复杂。
所有基于 CPU 模拟器的项目,在理论上都可以提供类似的功能,区别仅在于哪个 CPU 模拟器支持的架构更多,使用起来更舒服,速度更快,生态更多。Unicorn 胜出了。
所有基于 Unicorn 的项目,在理论上都可以提供类似的功能,因为都一个师傅教的,区别仅在于谁模拟的系统调用更多(这意味着系统完善程度更高),在 Android Native 上 Unidbg 胜出了。
重型操作系统模拟器和轻型操作系统模拟器相比,后者的特点就是仅模拟有限的一部分,比如 Unidbg 只模拟 SO,而不能模拟完整的 APK,这在理论上意味着,因为它所做的事少,所以可以把这有限的事做的更漂亮,有更强的掌控能力,比如每执行一个基本块,或每执行一个函数,就检测自身窄小的内存里有没有多出来一些敏感的内容等等。重型操作系统模拟器做不了这样的事,因为它要模拟的东西太多,内存空间自然也就很大,没法对自身做很细很频繁的”内省“,微型操作系统模拟器理论上可以做到这件事,这也是它很大的优势。但我们目前观察到,不管是 qiling 还是 Unidbg 又或是其他项目,它们都没做这些事,实话实说,它们没有利用好自己的优势。
Unidbg 是近年来在 Android Native 逆向分析情景下,最强有力的辅助分析工具。辅助需要自身有一定基础,如下是相关基础,掌握的越多越好。
基本分析工具—— IDA 的使用、Frida Native Hook/Call。
基本分析思路——自上而下顺序分析、由结果追溯来源、基于关键函数突破、JNI Trace、Function Trace 等等。
常见编码和算法——哈希算法与方案:MD5/SHA1/SHA256/CRC32/HMAC 方案、加密算法:AES/RC4/SM4/RSA、编码与压缩算法:Base64/Zlib/Protobuf 。
编程语言和 API —— C/C++ 语言基础、JNI 编程基础、C 标准库函数。
在计算机的教学课堂上,教师会使用 CPU 模拟器让学生更加直观的理解各种指令,在作业布置上,也可能布置编写 CPU 模拟器的作业,以加深对指令系统的理解,比如 这个有趣的项目就是伦敦帝国理工学院的学生作业。
应该说,它是一个很好的 ARM32 指令教学工具。你可以下载它的安装包 进行体验。
比如只支持单一架构,过去热门的 、 等 CPU 模拟器项目都仅支持 X86 架构。如果你的处理场景里可能会遇到多个不同的架构,那么就不太方便。除了架构单一,模拟不到位是更大的问题,这些模拟器往往只模拟了最常用的几十或一二百条指令,对指令集里相对不常用的指令则不做处理。这导致使用模拟器处理复杂样本时,常常会遇到某条指令无法处理的状况。
比如十年前的 项目,它支持下面这些类目的自我观测机制。
操作系统模拟器需要基于某个 CPU 模拟器之上,构建一个二进制文件加载程序,我们一般叫它 Linker,然后实现对应架构上本应由操作系统负责的各类系统调用,详见这个。
一是 Unidbg 是一个专注于 Android Native 场景下的操作系统模拟器,而很多其他模拟器,关注的是 Windows 操作系统,比如 和 。或者说,绝大多数模拟器处理的都是 Windows,小部分关注的是 Linux,而 Android Native 呢?几乎没有人关注它。qiling 是多操作系统模拟器,它对 Android Native 场景里的模拟程度也并不高,几乎没有处理 JNI 逻辑。
Unidbg 的实际竞争者主要是 和继任者 。
二是相比较 Unidbg 的竞争者——,它有更好的完善度,模拟实现了更多的系统调用和 JNI,即项目的完成度更高。