导语:IDAPython 库 flare-emu团队新开发的一个库,这个库是依赖于 IDA Pro 和 Unicorn 模拟框架,并为让逆向工程师可以通过脚本对代码的功能进行模拟,Unicorn 支持 x86, x86_64, ARM 以及 ARM64 架构。
IDAPython 库 flare-emu团队新开发的一个库,这个库是依赖于 IDA Pro 和 Unicorn 模拟框架,并为让逆向工程师可以通过脚本对代码的功能进行模拟,Unicorn 支持 x86, x86_64, ARM 以及 ARM64 架构。
flare-emu为用户的脚本模拟提供了一个易于使用并且灵活的接口,旨在为不同的体系架构设置灵活且健壮的模拟器的所有基础工作,这样你就可以专注于解决代码分析的问题。
5个不同的接口
它目前提供了5个不同的接口来处理你的代码模拟需求,而且它还有一系列相关的帮助和工具函数。
1.emulateRange
这个API能够在用户指定的上下文中模拟一系列指令或函数,对于用户定义的钩子,它既可用于单个指令,也可以用于在调用 call 指令的时候,用户可以决定模拟器是单步跳过还是单步执行(进入函数调用)。此接口为用户提供了一种为指定寄存器和栈参数指定值的简便方法。如果指定了字节串,则将其写入模拟器的内存,并将指针写入寄存器或栈变量。模拟后,用户可以使用flare-emu的实用效用函数从模拟内存或寄存器中读取数据,或使用返回的Unicorn模拟对象进行直接探测。
此外, flare-emu 还提供了一个对于 emulateRange 的一个小的封装,名为 emulateSelection,可以用来模拟当前在 IDA Pro 中突出显示的部分指令。如果 flare-emu 没有提供你需要的一些功能,你可以直接使用返回的 Unicorn 模拟器对象。
2. iterate
这个API是用来强制模拟函数中的特定分支,以达到预期的运行路径。用户可以指定一个目标地址列表,或者指定一个函数的地址,从该函数中使用对该函数的交叉引用列表作为目标,以及一个用于到达目标时的回调。程序会执行到给定的目标地址上,尽管当前的条件可能会跳转到其他的分支上。
与emulateRange API类似,也提供了用户定义的钩子选项,用于单独的指令和何时遇到“调用”指令。iterate API的一个例子是实现与我们的argtracker工具类似的功能。
3.emulateBytes
这个API提供了一种简单模拟一个外部 shellcode 的方法,提供的字节不会添加到IDB,只是直接的模拟执行,这对于准备模拟环境非常有用。例如,flare-emu本身使用此API来操作ARM64 CPU的模型特定寄存器(MSR),该模块不是由Unicorn公开的,以便启用向量浮点(VFP)指令和寄存器访问。与emulateRange一样,如果 flare-emu 没有提供你需要的一些功能,你可以直接使用返回的 Unicorn 模拟器对象。
4.iterateAllPaths
这个API非常类似于iterate,只提供目标函数,不提供目标地址,它将尝试查找所有路径并进行模拟。这在执行代码分析时非常有用,因为代码分析需要访问函数的每个基本块。
5.emulateFrom
这个API在函数边界未明确定义的情况下非常有用,因为混淆处理过的二进制文件或shellcode通常就是这样定义的。只要你提供了一个起始地址,它将进行模拟,直到没有任何东西可以模拟,或者你在其中一个钩子中停止进行模拟。这可以通过将strict参数设置为False来调用,并启用动态代码进行探测,flare-emu将让IDA Pro在模拟过程中执行指令。
安装
要安装flare-emu,只需将flare_emu.py和flare_emu_hooks.py放入IDA Pro的python目录,然后将其作为IDApython脚本中的模块导入,flare-emu依赖于Unicorn及其Python绑定。
注意事项
flare-emu是使用新的IDA Pro 7x API编写的,它不与以前版本的IDA Pro版本不兼容。
具体用法
虽然flare-emu可用于解决许多不同的代码分析问题,但其更常见的用途之一是帮助研究人员解密恶意软件二进制文件中的字符串。 在此,我们简单提一下FLOSS,FLOSS是一个很好的工具,通常可以通过尝试识别字符串解密函数并使用模拟来解密在每次交叉引用时传入的字符串来自动执行此操作。但是,FLOSS并不总是能够识别这些函数并使用其通用方法正确模拟它们。有时你需要做更多的工作。此时,就是flare-emu大显身手的机会了。只要你使用顺手了,flare-emu就可以为你节省很多时间。下面,就让我们来看看恶意软件分析人员在处理加密字符串时遇到的一些常见场景。
简单的字符串解密场景
如果你已经确定了解密x86_64二进制文件中的所有字符串的函数这个函数就会到处被调用,并解密许多不同的字符串。在IDA Pro中,你可以此函数命名为decryptString。下面就是你的flare-emu脚本,用于解密所有这些字符串,并在每次函数调用时使用解密的字符串作为注释,同时记录每个解密的字符串及其解密的地址。
from __future__ import print_function import idc import idaapi import idautils import flare_emu def decrypt(argv): myEH = flare_emu.EmuHelper() myEH.emulateRange(idc.get_name_ea_simple("decryptString"), registers = {"arg1":argv[0], "arg2":argv[1], "arg3":argv[2], "arg4":argv[3]}) return myEH.getEmuString(argv[0]) def iterateCallback(eh, address, argv, userData): s = decrypt(argv) print("%016X: %s" % (address, s)) idc.set_cmt(address, s, 0) if __name__ == '__main__': eh = flare_emu.EmuHelper() eh.iterate(idc.get_name_ea_simple("decryptString"), iterateCallback)
在__main__中,我们首先从flare-emu创建一个EmuHelper类的实例,这是我们使用flare-emu做所有事情的类。接下来,我们使用iterate API,为它提供decryptString函数的地址和回调函数的名称,EmuHelper将为模拟的每个交叉引用调用回调函数。
iterateCallback函数接收名为eh的EmuHelper实例,以及交叉引用的地址,传递给此特定调用的参数以及此处名为userData的特殊字典。在这个简单的示例中虽然没有使用userData,但将其视为模拟器的持久上下文,你可以在其中存储自己的自定义数据。但要小心,因为flare-emu本身也使用这个字典来存储执行任务所需的关键信息。。其中一个数据片段就是EmuHelper实例本身,它存储在“EmuHelper”中。如果您感兴趣,可以搜索源代码以了解更多关于本词典的信息。此回调函数只调用decrypt函数,打印解密的字符串,并在对decryptString的调用地址处为其创建注释。
decrypt创建了EmuHelper的第二个实例,用于模拟decryptString函数本身,它将为我们解密字符串。这个decryptString函数的原型如下:char * decryptString(char * text,int textLength,char * key,int keyLength)。它只是简单地解密字符串。我们的decrypt函数将iterateCallback函数接收的参数传递给我们对EmuHelper的emulateRange API的调用。由于这是一个x86_64二进制文件,因此调用约定使用寄存器来传递参数而不是栈。 flare-emu根据IDA Pro确定的二进制文件的体系结构和文件格式自动确定哪些寄存器代表哪些参数,这样你就可以编写一些与架构无关的代码。如果这是32位x86,则你可以使用stack参数来传递参数,如下所示:myEH.emulateRange(idc.get_name_ea_simple("decryptString"), stack = [0, argv[0], argv[1], argv[2], argv[3]])。第一个栈值是x86中的返回地址,所以我们在这里只使用0作为占位符值。模拟完成后,我们调用getEmuString API来检索存储在内存位置的以null结尾的字符串,该位置由传递给函数的第一个参数指定。
模拟函数
emulateRange(startAddr, endAddr=None, registers=None, stack=None, instructionHook=None, callHook=None, memAccessHook=None, hookData=None, skipCalls=True, hookApis=True, strict=True, count=0):模拟的指令范围从startAddress开始到endAddress结束,不包括endAddress的指令。如果endAddress为None,则在模拟开始的同一函数中遇到“返回”类型指令时,模拟停止。
Registers:Registers是一个字典,其中键是寄存器名称,值是寄存器值。键值(key)是windows中注册表中的概念。键值位于注册表结构链末端,和文件系统的文件类似,包含当前计算机及应用程序执行时使用的实际配置信息和数据。键值包含几种数据类型,以适应不同环境的使用需求。一些特殊的寄存器名称由flare-emu创建,可以在这里使用,例如arg1,arg2,ret和pc。
Stack:stack是一组以相反的顺序在栈上推送的值,就像x86中函数的参数一样。在x86中,请记住将此数组中的第一个值用作函数调用的返回地址,而不是函数的第一个参数。 flare-emu将根据寄存器和栈参数中指定的值初始化模拟线程的上下文和内存。如果为这些值中的任何一个指定了字符串,它将被写入内存中的某个位置,而指向该内存的指针将被写入指定的寄存器或栈位置。
instructionHook:instructionHook可以是定义为在模拟每个指令之前调用的函数,它的原型如下:instructionHook(unicornObject,address,instructionSize,userData)。
callHook:callHook可以是定义为在模拟每个指令之前调用的函数,它的原型如下:callHook(address,arguments,functionName,userData)。
hookData:hookData是一个字典,其中包含用户定义的数据,可用于钩子函数。它是在整个模拟过程中持久保存数据的一种方法。flare-emu也出于自己的目的使用这个字典,因此必须注意不要定义已定义的键。由于它在Unicorn中命名,此变量通常在用户定义的钩子函数中命名为userData。
skipCalls:skipCalls将导致模拟器跳过“调用”类型指令并相应地调整栈,默认为True。
hookApis:hookApis导致flare-emu对它在仿真过程中遇到的一些更常见的运行时和OS库函数执行简单的实现,这使你无需担心对memcpy,strcat,malloc等函数的调用,并且默认为True。
memAccessHook:memAccessHook可以定义为一个函数,在访问内存进行读写时调用它。它的原型如下:memAccessHook(unicornObject, accessType, memAccessAddress, memAccessSize, memValue, userData)。
Strict:当设置为True(默认值)时,检查分支目标,以确保反汇编程序期望得到指令。否则它会跳过分支指令。如果设置为False,flare-emu将在IDA Pro中模拟它们。
Count:count是要模拟的最大指令数,默认值为0,表示没有限制。
iterate(target, targetCallback, preEmuCallback=None, callHook=None, instructionHook=None, hookData=None, resetEmuMem=False, hookApis=True, memAccessHook=None):对于target指定的每个目标,从头开始执行单独的模拟包含函数直到目标地址。模拟将被强制沿着必要的分支到达每个目标。 target可以是函数的地址,在这种情况下,目标列表将填充指定函数的所有交叉引用。或者,target可以是一个明确的目标列表。
targetCallback:targetCallback是你创建的一个函数,它将由flare-emu为模拟期间找到的每个目标调用。它的原型如下:targetHook(emuHelper, address, arguments, userData)。
preEmuCallback:preEmuCallback是你创建的一个函数,将在每个目标的模拟开始之前调用。如果需要,你可以在此处实现一些设置代码。
resetEmuMem:resetEmuMem将导致flare-emu在每个目标的模拟开始之前重置模拟内存,默认为False。
iterateAllPaths(target, targetCallback, preEmuCallback=None, callHook=None, instructionHook=None, hookData=None, resetEmuMem=False, hookApis=True, memAccessHook=None, maxPaths=MAXCODEPATHS, maxNodes=MAXNODESEARCH) :对于包含地址目标的函数,为每个通过它发现的路径执行单独的模拟,直到maxPaths。
maxPaths:maxPaths将搜索和模拟的函数的最大路径数,,一些较复杂的函数可能会导致图形搜索函数花费很长时间或永远无法完成,在合理的时间内调整此参数以满足你的需要。
maxNodes:通过目标函数查找路径时要搜索的基本块的最大数量,这是一个安全措施,可以防止不合理的搜索时间和挂起。
emulateBytes(bytes, registers=None, stack=None, baseAddress=0x400000, instructionHook=None, hookData=None):如果可能,将包含在字节中的代码写入baseAddress的模拟存储器,并模拟从字节的开头到结尾的全部指令。
emulateFrom(startAddr, registers=None, stack=None, instructionHook=None, callHook=None, memAccessHook=None, hookData=None, skipCalls=True, hookApis=True, strict=True, count=0):这个API在函数边界没有明确定义的情况下非常有用,而模糊的二进制文件或shellcode通常是这样定义的。如果你提供一个起始地址作为startAddr,它将模拟,直到没有任何东西可以模拟,或者你在其中一个钩子中停止模拟为止。这可以通过将strict参数设置为False来调用,以启用动态代码发现。另外, flare-emu将在模拟过程中遇到IDA Pro指令。
效用函数
以下是EmuHelper类提供的一些有用的实用效用函数的不完整列表。
hexString(value):返回的值为十六进制格式化字符串,用于日志记录和打印语句;
getIDBString(address):返回位于IDB中地址的字符串,直到null终止符。不过,字符不一定是可打印的,用于在没有模拟上下文的情况下检索字符串。
skipInstruction(userData, useIDA=False):从模拟钩子调用这个函数来跳过当前的指令,将程序计数器移动到下一条指令。增加了useIDA选项来处理IDA Pro将多个指令折叠成一个伪指令的情况,并你希望跳过所有这些指令。此函数不能从单个指令钩子多次调用以跳过多个指令。要跳过多个指令,建议在模拟ARM代码时不要直接写入程序计数器,因为这可能会导致拇指模式( thumb mode.)出现问题。所以,请尝试使用EmuHelper的changeProgramCounter API(如下所述)。
changeProgramCounter(userData, newAddress):从模拟钩子调用它来更改程序计数器寄存器的值,此API负责ARM体系结构的拇指模式跟踪。
getRegVal(registerName):检索指定寄存器的值,对子寄存器寻址敏感。例如,“ax”将返回x86中EAX/RAX寄存器的下级16位。
stopEmulation(userData):从模拟钩子中调用它来停止模拟,使用它而不是调用emu_stop Unicorn API,这样EmuHelper对象就可以处理与iterate功能相关的簿记。
getEmuString(address):返回位于模拟内存中某个地址的字符串,直到null终止符,字符不一定可以打印。
getEmuWideString(address):返回位于模拟内存中的一个地址的“宽字符”字符串,直到一个空终止符。“宽字符”在这里指的是每隔一个字节包含一个空字节的任何字节序列,就像UTF-16 LE编码的ASCII字符串一样,字符不一定可以打印。
getEmuBytes(address, length):返回位于模拟内存中地址的字节字符串;
getEmuPtr(address):返回位于给定地址的指针值;
writeEmuPtr(address):将指针值写入模拟内存中的给定地址;
loadBytes(bytes, address=None):在模拟器中分配内存并将字节写入其中;
isValidEmuPtr(address):如果提供的地址指向有效的模拟内存,则返回True;
getEmuMemRegion(address):返回一个元组,其中有包含所提供地址的内存区域的起始和结束地址,如果地址无效,则返回None。
getArgv():从“调用”类型指令的模拟钩子中调用它,以接收函数的参数数组。
addApiHook(apiName, hook):为此EmuHelper实例添加一个新的API钩子,每当在模拟期间遇到对apiName的调用指令时,EmuHelper将调用钩子指定的函数。如果hook是一个字符串,那么它应该是EmuHelper已经挂起的API的名称,在这种情况下,它将调用它现有的钩子函数。如果钩子是一个函数,它将调用该函数。
addApiHook(apiName, hook):分配足够的模拟器内存以包含大小字节,它尝试执行请求的地址,但是如果它与现有内存区域重叠,它将在未使用的内存区域中分配并返回新地址。如果地址没有页面对齐,它将返回一个地址,该地址将在新区域内保持相同的页面对齐偏移量。例如,当0x1000已经被分配时,请求地址0x1234可能会让它分配到0x2000并返回0x2234。