3.1 Unidbg 的基本介绍
本文转自龙哥文章,原文地址:https://www.yuque.com/lilac-2hqvv/xdwlsg/idgio0?singleDoc#PtsaY
一、引言
怎样能更好更快的分析二进制文件?这是困扰所有逆向分析人员的问题。二三十年来,无数的项目因此而生。如果只做脚本小子,疲于学习各式不同的工具,而不懂工具的原理和发展方向,那么最终一定会竹篮打水一场空。 我们需要去了解这些工具产生的原因,为什么会流行,以及下一个形态或者说更好的工具是什么样。
二、概述
Unidbg 是 凯神 在 2019
年初开源的一个轻量级模拟器,支持对 Android Native
函数的模拟执行。在开源数月后,它做了进一步的扩展,试图增加对 IOS Native
函数模拟执行的支持。到目前为止,Unidbg
在 Android Native
上的完善度和可用度更高,我们也主要讨论 Android 而非 IOS 部分。
它是一个基于 Maven 构建的 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 模拟器在计算机科学的很多领域都广泛使用,比如教学和开发测试。
在计算机的教学课堂上,教师会使用 CPU 模拟器让学生更加直观的理解各种指令,在作业布置上,也可能布置编写 CPU 模拟器的作业,以加深对指令系统的理解,比如 Visual2 这个有趣的项目就是伦敦帝国理工学院的学生作业。
它对一个由F#语言编写的项目,模拟了 ARM32 架构下的 ARM 指令集(不包括 Thumb2),它有一个漂亮的前端界面,学生们甚至还为部分指令设计了悬浮示意图,如下所示。
应该说,它是一个很好的 ARM32 指令教学工具。你可以下载它的安装包 V2releases 进行体验。
开发测试也是很大的需求,做一些跨平台的汇编开发时,指令模拟器可以帮助开发人员测试、验证功能以及发掘 Bug,比如嵌入式开发里使用的仿真调试器等等。
如果从逆向需求的角度看 CPU 模拟器,不得不说,过往的开源 CPU 模拟器问题很多。
比如只支持单一架构,过去热门的 x86emu、pyemu 等 CPU 模拟器项目都仅支持 X86 架构。如果你的处理场景里可能会遇到多个不同的架构,那么就不太方便。除了架构单一,模拟不到位是更大的问题,这些模拟器往往只模拟了最常用的几十或一二百条指令,对指令集里相对不常用的指令则不做处理。这导致使用模拟器处理复杂样本时,常常会遇到某条指令无法处理的状况。
速度慢是另一个大问题,真实 CPU 在数十年来,依靠硬件发展和相关技术的进步,指令执行速度快到了一种可谓恐怖的地步。模拟 CPU 采用软件方式去做指令仿真,速度一对比那是相当的慢,甚至每秒只能运行几百条指令。复杂的样本动辄数百上千万行执行流,采用这样的模拟器,简直跑不完。
为了让模拟器提速,最主流的方案是放弃模拟,转向翻译。具体而言就是将待模拟的指令翻译成宿主机上的本地指令,然后交由 CPU 直接执行,其中最广泛使用的技术是 JIT。举个例子,在 X86 的处理器上执行 ARM 指令,那么可以将 ARM 指令翻译成 X86 指令,然后交由处理器直接运行。
有的读者可能会困惑,上面我们说模拟器存在那么多问题,那为什么我们还要用模拟器呢?这是因为正常设计的 CPU 就是毫无情感的指令执行机器,它不会为我们算法分析提供一丁点帮助或后门,而模拟器不同,我们设计模拟器的目的就是为了更好的观察指令执行的过程,因此会在模拟器里提供各种各样的回调、拦截和打印,以增强对自身的观测。
比如十年前的 PyEmu 项目,它支持下面这些类目的自我观测机制。
每执行一条指令进入自定义的处理逻辑
每执行某一类指令(比如跳转指令或内存加载指令)进入自定义的处理逻辑
每执行某一种指令(比如 CMP)进入自定义的处理逻辑
每执行某一种指令下的某个子类(比如 CMP.W)进入自定义的处理逻辑
某个寄存器被赋值进入自定义的处理逻辑
某片内存被访问进入自定义的处理逻辑
下面具体举例几个 API,比如当 eax 被访问(读、写)时进入自定义的my_eax_handler处理逻辑。
比如当指令为CMP
时,进行拦截和打印。
比如当对 0x44444444
这个地址发起读写时,进入自定义的my_memory_handler
进行内容的打印。
上面我们简单讨论了 CPU 模拟器,接下来讨论操作系统模拟器。
CPU 模拟器的唯一功能就是执行指令,这意味着它不理解上层概念,比如各种二进制文件格式 PE/ELF/MachO,解析和加载它们是操作系统的工作。除此之外,典型的操作系统会提供数百个系统调用,功能从获取系统信息到进程/线程管理,以及内存管理、文件管理等等。你可能更习惯"库函数"的概念,但它在底层实现上同样依赖于系统调用,比如下面就是 openat 的实现。
SVC
指令用于发起系统调用,它会陷入内核,然后交由操作系统去做资源的管理和调度。一般的 CPU 模拟器遇到SVC指令就会报错,或者什么都不做继续往下执行(就像遇到 NOP 那样)。
操作系统模拟器需要基于某个 CPU 模拟器之上,构建一个二进制文件加载程序,我们一般叫它 Linker,然后实现对应架构上本应由操作系统负责的各类系统调用,详见这个网站。
为了实现这些系统调用,你至少需要理解这些系统调用的语义,其中的某些可以用硬编码的方式糊弄,另一些则需要利用宿主机的 API,以及自己去构建大量的逻辑去模拟这些系统调用,这会是很大的工作量。
五、Unicorn
上面我们讨论了 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 的项目
基于 Unicorn 的项目有数十上百个,为什么我们要使用 Unidbg?这其实很简单,就两点原因。
一是 Unidbg 是一个专注于 Android Native 场景下的操作系统模拟器,而很多其他模拟器,关注的是 Windows 操作系统,比如 Binee 和 speakeasy。或者说,绝大多数模拟器处理的都是 Windows,小部分关注的是 Linux,而 Android Native 呢?几乎没有人关注它。qiling 是多操作系统模拟器,它对 Android Native 场景里的模拟程度也并不高,几乎没有处理 JNI 逻辑。
Unidbg 的实际竞争者主要是 AndroidNativeEmu 和继任者 ExAndroidNativeEmu。
二是相比较 Unidbg 的竞争者——ExAndroidNativeEmu,它有更好的完善度,模拟实现了更多的系统调用和 JNI,即项目的完成度更高。
七、模拟器的好处
我们要讨论两个方面,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 标准库函数。
最后更新于