本章首先论述了二进制可执行代码的做用和格式,以后介绍了软件逆向所须要了解的基本知识,最后论述了软件逆向经常使用工具及其原理。程序员
二进制可执行代码是由高级语言编写的源代码通过编译器编译的生成结果,或者低级语言代码通过汇编器的生成结果,在这个阶段,连接器将多个对象文件合并成一个包含了多个模块代码的可执行文件,表现为二进制可执行代码。运行时,系统可执行文件加载器将文件加载到内存执行。算法
最早进的程序分析工具最适合分析源代码,它相比于二进制可执行代码级别能够获得更多的高级信息。然而二进制可执行代码仍然值得研究,软件中全部的信息都会存在于二进制可执行代码中。不少商业软件(特别是Windows平台的软件)以及恶意软件(如病毒,木马,间谍软件)都是以二进制格式发行,而分析这类二进制可执行代码极其重要。和源代码相比,二进制可执行代码还有另外一个明显的优点——执行很方便可是难于阅读,这个特性在软件公司发行软件的隐私保护是颇有利的。
对于防止系统被攻击角度来讲,分析二进制可执行代码也是十分必要的,它能在不须要源代码的状况下提供安全保障且能避免一大堆法律上的问题。另外破解技术带动了一个流行的研究方向,即软件保护,它的做用是防止软件被逆向。破解和保护是一种无尽的博弈,相对于软件破解,软件保护更加须要对二进制可执行代码进行分析和理解,软件破解仅仅须要理解代码的逻辑,找到代码中敏感信息处以后禁用或者修改便可,而软件保护须要在理解二进制可执行代码以后创建起防护系统,将其植入代码敏感信息处,而且设法阻止原始二进制可执行代码和防护系统被逆向。同时,恶意软件会威胁使用者的系统和硬件安全,病毒和木马都是以二进制形式传播,而且隐藏在宿主文件二进制可执行代码中。恶意代码会随着宿主文件的启动而执行,以后又会感染更多文件。所以,对二进制格式代码的分析是颇有必要的。express
对象文件格式是一种计算机用于存储目标代码和关联数据的文件格式。各类类型的操做系统和编译器都有本身的对象文件格式,公认的格式有COFF、ELF和OMF。通常对象文件会包含多种数据块,每一个数据块都存储一种类型的数据:头部、可执行代码段、静态数据段、未初始化数据段、连接引用、重定位信息、动态连接信息、调试信息。根据对象文件的使用状况,能够分红下面3种类型及他们的组合:编程
PE格式是Windows系统下的可执行文件格式的一种(NE和LE格式是早期Windows使用的可执行文件格式) 。PE表明Portable Executable,意为可移植可执行。现今大部分Windows下的32位或者64位可执行文件都是PE文件格式,包括DLL(动态连接库)/EXE/OCX(控件)/LIB(静态连接库)/SYS(驱动)等后缀形式的文件。PE格式以旧DOS文件头信息开始,后接MS-DOS实模式存根程序,用来显示实模式DOS下的信息,该程序以后是PE文件头,该结构大小为18h字节,用于描述文件基本特征,包含PE\x0\x0标记。紧接在PE文件头以后是可选头,用于详细说明页面映像结构(加载基址、内存映像大小、对齐方式等)。可选头中最重要的结构之一是DATA_DIRECTORY结构,包含了导入导出表、调试信息、可重定位表等信息。接在可选文件头以后,是各类不一样类型的节,节以后是程序附加数据,通常打包程序可能用到该区域。windows
反汇编是将机器语言代码翻译成汇编语言代码的过程,按执行方式能够分为静态反汇编和动态反汇编。静态反汇编是指不执行的方式的翻译过程,动态反汇编则须要跟踪执行目标文件,具体反映出程序运行状况。静态反汇编可以一次对整个可执行文件进行处理,而动态反汇编只能处理被执行到的部分,同时反汇编的时间与文件的长度成正比,而动态反汇编时间与被执行到的指令数成正比。一般前者时间消耗比后者少,静态反汇编效率比动态反汇编效率高。本文主要讨论静态反汇编的状况。设计模式
C++语言在底层实现中借用了许多C语言中的优秀实现手段。为了实现跨平台,C/C++编译器生产厂商遵循简单内存布局原则,成员变量按照声明的顺序对其排列在内存中。在MSVC8及以上的版本中,可使用编译程序cl.exe获取生成的类布局信息,按以下格式使用:api
cl -d1 reportSingleClassLayout [classname] filename 查看单个类布局
cl -d1 reportAllClassLayout filename 查看该文件全部类及结构体布局
运行时类型信息(Run-Time Type Information)是一种由编译器生成的信息,用于支持dynamic_case<>和typeid()这样的操做符。RTTI是为带有虚函数的类(多态类)设计的。MSVC编译器会在虚函数表指针以前放置指针指向 “完整对象定位符(COL)”结构体,编译器能够经过该结构体根据虚函数表指针找到一个完整对象的位置。RTTI对于逆向分析函数类提供有价值的信息,能够从中恢复出类名和继承关系,甚至能够恢复出类布局。数组
在C++语言中,虚函数是程序运行时刻才肯定的函数,它会自动调用指针指向的真正对象对应的函数。调用的函数地址在编译时是没法肯定的,只有在调用即将执行前肯定,所以虚函数的调用经过间接调用实现。全部的虚函数地址都会存放在一个专用常全局数组——虚函数表中,而由带有虚函数的类实例化出的对象,老是带有虚函数表指针。非派生对象仅有一个虚函数表指针,而多重继承可能有多个,同一个类实例化的对象之间共用虚表。纯虚函数是在抽象类中使用的,必须通过继承类重载后才可调用。
对于基类,虚函数地址按照虚函数声明顺序排序在虚表中,而派生类的重载函数会替换相应基类虚函数地址。一般虚函数表就是一个普通的数组。然而某些编译器是以链表组织的,虚函数表中每一个元素含有指向下一个元素的指针,元素之间并非紧密排列而是分散在文件中,这种状况较为少见。对于多重继承的派生类,虚函数表项可能存在指向转换程序(Thunk)的地址,该程序修改this指针以从父类虚函数表中调用虚函数,使其指向“替换函数”对象实例。这种技术是由C++语言开发者Bjarne Stroustrup提供的,他借用了Algol-60的早期实现形式,在Algol中修正this指针的代码称为形实转换程序(thunk),而调用自己称为“经过形实转换程序进行的调用”,这些术语至今在描述C++时使用。sass
API是Application Programming Interface应用程序接口的缩写形式,是构筑Windows程序的基石,下层是操做系统内核,上层是用户应用程序,应用程序甚至操做系统自己都要经过API完成特定的功能,Windows上几乎全部有实际功能的程序最终都会直接或间接地调用API,所以熟悉API调用甚至API汇编代码对于逆向分析和调试代码十分必要。Windows API按函数功能能够划分为:硬件与系统函数、控件与消息函数、菜单函数、进程和线程函数、绘图函数、打印函数、网络函数、文件处理函数、加密函数等。API按照系统层次可划分为用户级API和系统级API
安全
Windows是消息机制驱动的系统,消息提供了应用程序之间、应用程序与系统之间的通讯手段,应用程序实现的功能靠消息触发,并可以对该事件进行响应和处理,这些处理函数称为回调函数。Windows窗口程序主要是是事件驱动而非过程驱动,所以了解Windows消息机制极其必要。WWindows系统中有两种消息队列,系统消息队列和应用程序消息队列。Windows为每一个程序(严格的说是每一个线程)维护一个消息队列,Windows检查系统消息队列消息的发生位置,若是位于某个应用程序窗口范围则将该消息派遣到应用程序消息队列中。若是应用程序没有来取消息则消息就暂时保留在队列中,当程序中的消息执行到GetMessage时,控制权转移到GetMessage所在的USER32.DLL中,USER32.DLL从消息队列中取出一条消息并把这条消息返回应用程序。应用程序处理这条消息时,因为可能存在多个窗口,所以并非直接调用本身的回调函数窗口过程,而是调用DispatchMessage函数经过系统找到给合适的窗口过程进行调用。窗口过程处理完毕后控制权返回到DispatchMessage,继续消息循环。应用程序之间或自己也能够发送消息,PostMessage将消息投递到某程序的消息队列中,而SendMessage则越过消息队列直接调用目标程序窗口过程,过程处理完毕后才返回。
在本文中,MSVC是指微软Visual Studio中的C++编译器,它是Windows程序设计中使用最多的编译器,所以熟悉该编译器内部运做机制对Windows下的逆向分析十分必要,可以识别编译器自动生成的代码不但有助于快速定位程序员编写的用户代码,并且对于恢复软件层次架构也颇有帮助。而GCC则是Unix/Linux系统的默认编译器,已经移植到Windows平台上。
逆向分析中最重要的工具包括静态分析工具、动态分析工具和其余辅助工具。动态分析工具主要功能是调试,以便高效分析软件的行为并验证静态分析结果,甚至找出软件缺陷和漏洞。因为操做系统提供了完善的调试API,所以利用各类调试工具能够很是方便的观察和控制目标软件,在调试过程当中,使用者能够随意修改指令、寄存器、内存、设置运行断点,使程序一边运行一边分析。而静态分析则是相对于动态分析而言的,在不少场合下不适合直接运行程序,例如软件的某一模块(没法单独运行)、病毒木马蠕虫程序、平台设备不兼容等。此时须要使用静态分析经常使用的反汇编软件将二进制可执行代码转换为汇编语言进行分析。
逆向工具的做用,也是其产生的目的,就是将逆向工做者从库函数分析、异常处理、反汇编这些重复的劳动中结果出来,而把主要精力放在程序的数据结构、算法、功能的分析中去,可是现今的逆向软件因为功能不够完善同时因为编译器的多样性和高度优化性,所以不少步骤仍需人工干预,交互式完成。
对于反汇编器,目前已经有不少比较好的反汇编软件产品,如国内的MC-Z80、DBJ-Z80系统软件,但它们主要是针对某种型号的产品而开发的,通用性和可移植性较差。国外著名的反汇编软件主要有C32Asm、W32Dasm、Hopper Disassmbler和IDA Pro等。C32Asm集反汇编、16进制工具、Hiew修改功能于一体。W32Dasm支持静态智能反汇编、代码查找及转移功能,还具备动态分析功能,速度快,可是它只能对80x86系列指令集程序进行操做。Hopper Disassmbler是一款专业的32位和64位可执行文件的反汇编、反编译和调试软件。IDA Pro支持的指令集最多,交互性最强,且提供了反汇编程序流程分析和流程图显示功能,内部支持自编写IDC脚本和用户插件。
对于调试器,目前比较优秀的调试器有OllyDbg、MDebug、RORDbg、WinDbg、SoftIce等。OllyDbg是一款出色的调试器,是当今最为流行的用户模式调试器,功能繁多,可编写插件进行功能扩展。RORDbg是一个虚拟机技术实现的简易调试器,主要用于外壳分析和脱壳,目前只能运行exe主线程和dll入口函数,因为采用虚拟方式执行指令所以能够做为分析外壳辅助手段,速度较慢。OllyDbg、PEBrowse Professional均为用户模式调试器,WinDbg和SoftIce则为内核模式调试器,WinDbg因为和Windows操做系统紧密结合,能够方便的调试DLL初始化代码和内核,同时在调试过程当中下载对应符号信息,方便理解程序。
在软件逆向中一般还须要其余多种类型的辅助工具,包括日志记录器、代码可视化分析、设计模式恢复、文档和图表生成工具等。
反汇编器是解析机器指令的,以x86平台Intel指令集Opcode为例,Intel指令手册中描述的指令由6部分构成,如表 2.1所示。前缀最多有4个,每一个前缀1字节,不容许一个前缀重复2次,前缀分为:普通前缀(Prefixed)、指示性前缀(Maandatory Prefix)和64位扩展前缀(REX Predix)。前缀有4中,包括锁前缀(F0H)和重复前缀(REP系)、寄存器和地址超越前缀(66H,67H)、64位扩展前缀(REX)。
Instruction Prefixes | Opcode | Mode-REG-R/M | SIB/Dispacement | Immediate |
---|---|---|---|---|
指令前缀(可选) | 指令操做码 | 操做数类型(可选) | 辅助Mode R/M,计算偏移地址(可选) | 当即数(可选) |
每一个1字节 | 1,2,3字节 | 1字节 | 1,2,4字节 | 1,2,4字节 |
了解了指令集以后,须要对指令集机器码进行二进制到汇编语言的解析,解析所用到最著名的2种算法称为线性扫描和回溯遍历,是全部调试器和反汇编器的基础。线性扫描算法会按顺序逐个读取二进制字节并尝试匹配指令,流程以下Procedure LinearDisasm(addr):
该算法的优势是:因为扫描的是整个代码区域,所以可以在很大程度上识别每条指令。然而该算法不能区分数据和代码。因为算法会顺序获取字节转换成指令,所以代码中嵌入的数据也会被当作指令字节,遇到这种状况反汇编器没法翻译成正确指令且不断产生错误指令直到遇到没法与任何指令进行匹配的字节。此外,没法让反汇编器得知开始产生持续错误的位置。GNU实用程序objdump和许多连接时优化工具均采用该算法
线性扫描算法的主要缺点是没有利用二进制文件的控制流程信息。所以它没法避免地将代码中嵌入的数据错误解释为数据,产生错误指令。这样不只会致使嵌入代码中的当前数据的翻译错误,后面接续的数据也会受到影响。为了不误把数据解释为指令产生了回溯遍历算法,该算法采起了以下的控制流程Procedure RecursiveDisasm(int addr):
不管反编译器什么时候遇到分支指令都会尝试从全部可能的分支地址处解析。理想的状况是,若是反编译器知道每一个分支的准确目的地址,根据控制流程,反汇编器就能够遍历全部可能在运行时执行的代码,这样代码段会被换分红多个不内嵌数据的代码块。但若是目标地址在运行时是动态变化的,即间接分支指令的目标地址具备歧义性,此时反编译器没法经过静态方法获取该地址,也会致使翻译错误。所以,一个反汇编器可能会遗漏实际指令,若是猜想的地址有误,也会产生翻译错误。大量的二进制翻译和优化系统都使用该算法,例如UQBT翻译系统,在控制流图解析的研究中也采用了该方法,主流逆向工具反汇编算法如表 2.2所示。
Debugger | Disasm |
---|---|
OllyDbg | 回溯遍历 |
SoftIce | 线性扫描 |
WinDbg | 线性扫描 |
IDA Pro | 回溯遍历 |
PEBrowse | 回溯遍历 |
代码分析工具在进行软件分析是经过提取软件信息完成的。软件分析一般分为三类:静态分析、动态分析和历史分析。静态分析用于不须要执行的软件,动态分析用于分析执行痕迹或捕获运行行为,历史分析用于分析版本系统变化引发的软件变化。
优秀的静态分析工具能支持多种编程语言元素特性、多种编译器内部处理机制、多种操做系统特性和平台不兼容代码,这意味着即便不能在当前平台进行运行、调试和测试,这种工具仍能进行代码分析,这类工具如TXL和SrcML;静态分析工具能解析出有价值的信息,有时并不须要构建一个完整的抽象语法树AST(Abstract Syntax Tree),而使用特殊的逆向工程方式获取这些信息,这类工具如Bauhaus和Columbus;静态分析工具可以提取程序语义,该功能经过多重逆向手段解析出更多的AST信息,这类工具如CodeSurfer。静态工具速度较快,然而在处理指针、多态和动态类型等情形时静态分析会难于进行,另外对于用户交互和对象之间数据交换的分析也是静态分析所不擅长的,而动态分析却适合处理这些状况。
逆向分析的目标是理解一个系统中的软件以便更容易地进行加强功能、更正、增长文档、再设计或者用其余的程序设计语言再编码。逆向工程工具应支持产生程序的高层抽象,使维护者更容易理解程序,重用旧代码以及准确加入新功能,避免死码的产生。
在必定规则下进行逆向分析会提升逆向分析的效率,下面就来介绍这样的规则。本章提出的逆向框架其中涉及的每一部分都在会在本章中和以后的章节中进行详细介绍。
该模型分为预处理模块、函数识别模块、类识别模块、异常处理识别模块、综合分析模块。预处理模块对二进制可执行代码进行反汇编和初步分析,去除软件保护机制,初步识别PE文件格式、资源及导入导出表、程序入口等信息。函数识别模块用于识别系统库函数和用户函数,包括对变量、表达式、语句、函数传参调用和函数执行流程的分析。类识别模块用于识别存在于二进制文件中的类布局、对象结构、RTTI信息、this指针的使用,从而分析出成员变量和成员函数,推导出原始的类结构。异常处理模块用于识别二进制可执行代码中存在的异常处理信息,包括SEH和C++异常。综合分析模块对上述三个模块的处理结果进行综合分析,推导出二进制可执行代码对应的源代码、软件架构、算法、设计模式和文档
现代软件倾向于打包(添加保护机制),通过打包之后实际执行的代码会被加密和压缩保护,下降了汇编代码可读性。
为了便于分析试剂执行代码,去除程序保护,须要先用外壳探测程序得到目标程序所用保护类型,而后针对该类型使用解包器、破解、脱壳、调试、内存转储等多种手段和工具将实际执行的代码剥离出来,对于打包器或加壳工具对原程序资源和导入表形成的破坏还应使用相关恢复工具进行恢复。另外,分析前处理的另外一个重要做用是识别程序的编写语言,这有助于对程序内用到的编程语言相关的库函数的签名识别。
通常地手工分析程序编写语言能够经过剖析入口点特征,这个步骤和查壳工具原理相似,能快速定位编写语言甚至编写库但并不通用,由于无保护机制的程序入口点也有多是只是一层语言外壳,更通用的方式是使用IDA等专业工具的库函数签名机制。
在Windows中,代码共享是进程通讯的核心思想,用户程序不能直接控制硬件,也不能直接和Windows内核通讯,Windows提供了各类功能的dll(动态连接库),这些dll的输出函数能够为用户程序提供内核服务,用户进程会常常调用API实现特定功能,所以了解API是必要的。一般使用PE导入表查看静态加载的API,字符串常量域结合动态加载函数API(GetProcAddress)以获得动态加载的API。
PE格式常见的资源包括位图、加速键、光标、对话框、图标、菜单、字符串表、工具条和自定义资源,分析PE资源能够经过调试技术快速定位到程序执行流程的关键点,其中字符串所包含的信息较为敏感,经过观察字符串和程序执行逻辑的变化,有助于快速定位到关键代码。对于有界面框架的程序,对话框和菜单有着特殊的重要性。例如MFC工程中,根据菜单和对话框操做相关API,以及对话框中子控件资源属性(主要是ID)和菜单子项ID,能够很容易地分析出当前代码所产生的行为。导入和导出函数能够用于方便地定位程序主干代码,便于理清执行逻辑。
程序的入口点是最早执行的代码位置,不少初始化工做都会在其中进行。程序入口分为真正入口和用户入口,以MSVC为例,应用程序的真正入口点并非main/WinMain/DllMain及其宽字符形式(w-)(这部分属于用户入口),这些函数仅仅是真正入口点(如start)所执行的一个可由用户重载和自定义的函数而已。对入口函数启动部分流程和库函数使用的分析,能够进一步肯定程序相关的编程语言信息和使用库函数信息。函数入口在开始执行用户入口函数以前所作的操做有:获取平台版本、初始化堆空间、初始化命令行参数、初始化环境变量、初始化全局数据和浮点寄存器等,能够经过该流程和用户入口函数的参数类型,找到用户入口函数。同时,在全部用户函数执行完以后,程序并无结束运行,而是继续执行一些清理工做,例如exit和atexit函数,最后调用API终结进程,这部分也是在库函数代码中实现的。
现代程序中早已融入模块设计概念,程序中充斥着各类系统库函数、第三方函数和组件,接口设计方法普遍应用于软件领域,所以对于程序的分析不可避免会遇到库函数,并且一般这些库函数代码量会比用户实际编写代码量多出几倍(平均起来库函数在程序代码中所占的比重为50%到90%,特别是利用了可视化开发环境自动生成代码功能),因为数据量巨大,分析时间漫长,且库函数经常比简单的程序代码更加复杂而难以理解,所以不适合直接手动分析,于是程序分析软件应该能提供一种较为智能的方式自动识别这些函数,而把逆向分析者主要精力快速集中在用户代码的分析上。做为逆向工做者,也应该熟悉经常使用库函数的汇编级模板,这样就能够在代码分析工具因为库函数升级或高度优化致使没法正常识别的库函数的状况下不影响逆向速度。
IDA使用了一种高效的方式,使用二叉树形式组织的标准库检索字节序列,这种搜索方式的时间复杂度是O(logn),对于大多数状况使用函数开头的32字节足以准确识别。在识别操做正确性方面,许多函数结束位置处于二叉检索树相同的叶节点,这会致使识别过程出现冲突或二义性,这也是在制做IDA的sig(特征标志库)文件常常发生冲突的缘由。为了减小错误,IDA经过启动代码识别编译器程序并加载相应的库文件,同时容许用户手工加载这些特征标志库文件。在程序连接时编译器常以用户OBJ模块与函数库的列表顺序分配函数,因此不少状况下代码区中的库函数段和用户代码段之间会有明显的分隔。
不少函数库包含了开发商信息和库版本的版权内容,这为识别编译器类型和版本带来了很大便利,只须要找到相应文本字串片断便可。特殊函数库带有某些特征也能够用来识别编译器,例如调用的Windows API种类(用于文件、内存、图形、网络、加解密、硬件等)、数学函数通常含有丰富的协处理器指令等。另外能够利用参数和常量等信息进行推断,如函数接受浮点参数,那么极有可能来自于某个数学函数库。最后,算法的识别也会有助于识别库函数。
本章对常见数据类型、表达式、语句、函数结构等基本的C语言元素进行语法分析和底层实现分析;对函数栈结构的函数序言和函数结语结构进行剖析;根据内存变量使用状况提出了一种识别变量生命周期的方法;基于函数栈帧原理提出了一种切实可行的函数边界检测方法,该方法能够准肯定位特定高级语言函数的机器码范围。
在高级语言代码中若是显式声明过自动类型变量,则一般都会在该函数栈中开辟对应空间划分变量空间,一种划分方式是将指令sub esp,xxx放于函数入口附近,而相对的add esp,xxx放于函数结束处附近。而在使用这些变量时,也相应会用ebp作间接寻址。MSVC中常使用ebp的正偏移作栈变量寻址,而Borland和其余编译器则经常使用使用负偏移。做为参数的变量因为传递以前会被压栈,所以只要在函数体内计算出当前栈顶指针和栈底指针位置,就能够选择一个栈寄存器进行间接寻址获取参数。在存在函数序言的状况下,因为调用函数时的call func指令和函数内push指令的2次压栈操做,所以第一个参数是从ebp+8位置开始的,后面的参数按4字节大小向高地址递推,能够看出参数寻址的相对偏移量为正,与此相反,函数内栈变量则是以ebp负偏移量进行寻址。特殊状况下,编译器可能对栈变量进行优化,而将一些无用的参数栈位置做为栈变量使用。根据这些特性和函数中引用局部变量和参数的指令能够恢复相应的函数栈。函数内部常常会对栈指针进行调整,这时参数和变量偏移位置就须要常常从新分析。
堆变量是全部变量中最容易识别的一种类型。在C\C++中一般使用malloc和new操做符实现堆空间的申请,返回的数据是堆地址,该地址对于整个进程有效。相应的使用free和delete释放该地址处申请空间。在Windows下申请堆空间在程序结束前都须要调用释放堆空间的API函数,不然会形成内存泄露。
为了使访问内存频率尽量低,编译器的高级优化功能会把使用频率最多的局部变量存在通用寄存器中。C/C++语言中,register关键字用来向编译器请求分配在寄存器中,由编译器选择最佳代码处理方式。寄存器变量能够由PUSH指令临时存放在栈中,并由POP指令弹出堆,寄存器变量不会经过EBP寄存器进行寻址。临时变量的产生是编译器根据实际须要产生的变量,临时变量产生的缘由有下面三个可能:
分析程序的算法的关键一步就须要透彻地分析整个反汇编代码,并搜索出全部的交叉引用,因为全局变量经过直接寻址,所以在高级语言中识别全局变量相对容易。IDA中能够对符号进行交叉引用检索,这个特色大大提升了分析代码的效率,然而在一些状况下IDA并不能很好地识别这些符号,所以须要以手工方式进行交叉引用的重建。静态变量和全局变量类似,只是做用域不一样,静态变量在编译级别只能在定义的做用域内使用,在汇编级别的表现是集中于某个函数代码域,而全局变量能够在多处使用。全局变量须要在主函数执行前初始化,在程序退出前执行清理工做(析构),而和静态变量在首次使用时初始化,并设置某个标志位,注册退出函数,在下次执行到该处时跳过初始化,在程序退出时执行清理工做。MSVC经过生成初始化函数段实现这种初始化功能,这些初始化例程地址会存入一个表中,在程序启动后由运行库函数_cinit进行处理。该表常位于.data段起始处。
IDA能够识别多种格式的字符串,包括c型(结束符’\0’)、dos型(结束符’$’)、pascal型(长度域1字节)、宽pascal型(长度域2字节)、delphi型(长度域4字节)、unicode等类型。Windows程序常见的类型是c型和unicode,所以识别正常的字符串并无困难,可是若是字符串采用了加密技术转换成不明确的数字,状况就会变得复杂。这时候首先应该使用交叉引用功能查看数据段里的数据被哪些代码所引用,若是被引用则对该处代码进行分析,进行算法逆向还原出字符串。对于自动检测程序中的字符串有许多的识别字符串的算法可用,都是基于以下三点:
若是在汇编代码中发现对某处内存附近进行连续读写且读写指令相距较近,则该处可能为结构体实例一部分。存在于堆空间的结构体大小通常能够从动态分配空间大小推断出来;存在于栈空间的结构体识别,则须要结合两种分析方式。第一种方式是采用累积法,若是发现内存连续赋值且这种行为在多处反汇编代码中出现就将该偏移处元素其加入结构体。第二种方式是反推法,对于栈结构体,分析当前整个函数栈大小和全部其余栈变量边界范围,反推出该结构体范围。若是从API调用参数或者从其余分析过的有关联的函数中已经得知该结构体类型,则能够简化分析。
普通表达式会包括算数运算、逻辑运算、关系运算、位运算等形式的语言元素。对于复杂表达式,编译器首先会对复合条件根据内定的计算顺序进行语法分析和表达式分解,拆分红多个体现基本操做之间相互关系的简单条件做为中间形式,以后使用goto语句替换条件语句。编译器在分析时采用逻辑二叉树结构表示复杂条件的分解过程。在逻辑树分支较多时,会对逻辑树作修剪操做,优化逻辑树结构,经过对条件进行取反而剪除多余树枝并删除子树全部标号,经过合并和修剪枝干的分支优化理清逻辑关系。
循环语句是一种常见程序设计逻辑,C++循环语句主要包括for循环、while循环、do-while循环三种形式,每种循环有着不一样的执行流程:do循环先执行循环体后比较判断,while先比较判断后执行循环体,for先初始化再比较判断最后执行循环体。
for循环语句能够抽象成下面的通常语法形式:for(statement1;condition;statement2) {statement3;}
,编译以后能够转化为以下汇编代码形式:
call statement1;
jmp judge
change:
call statement2;
judge:
call condition;
jz end;
call statement3;
jmp change;
end:…
while循环语句能够抽象成通常语法形式:while(condition) {statement;}
,编译之后能够转化为以下汇编代码形式,能够看出形式上较for循环要简单:
judge:
call condition;
jz end;
call statement;
jmp judge;
end:…
do循环语句能够抽象成通常语法形式:do{statement;} while(condition);
,编译结果能够转换为以下伪代码,能够看出形式上较while循环简单:
begin:
call statement;
call condition;
jnz begin;
一般编译器在优化的时候,while和for循环会近似优化为效率更高的do循环。对于continue语句,continue执行后当即将控制权传递给检查条件代码,通常地在带有前置条件的循环中,该语句会编译成一条向上方定位的无条件跳转指令;而在后置条件循环中,该语句则被编译成一条向下方定位的无条件跳转指令,continue以后的当前域语句不可执行。
C语言分支语句包括if-else语句、if-else if-else语句、switch-case-default语句,分支是任何程序设计语言的核心内容,所以正确识别它们是极其重要的。
if语句能够抽象成通常语法形式:if(condition) then{statement1;statementN;} else{statementl1;statementlN;}
,编译器的任务是将这条语句编译成若是condition成立则执行statement1与statementN指令序列,若是不成立则执行statementl1与statementlN指令序列。绝大多数编译器(即便不具备优化功能)都对条件值进行取反从而将语句if(condition) then{statement1;statementN;}转换成以下的伪代码:
if(not condition) then continue
statement1;
…
statementN;
continue:
可见要重建程序的源代码,必须对条件值进行取反,从而使语句块{statement1;statementN;}一定继起于then关键字。 而对于整个语句的if-then-else,伪代码以下:
if(not condition) then else
//执行if分支语句
statement1;
…
statementN;
goto continue;
else:
//执行else分支语句
statementl1;
…
statementlN;
continue:…
在Windows程序设计中常常在多信息码的状况下会用到switch语句,例如消息回调、错误处理、网络状态、驱动派遣等实现代码,是比较经常使用的多分支结构,效率上也高于if分支结构。这种分支语句若是不经优化,表示成逻辑树因为分支数较多、深度较大、效率较低,表现为“一边倒”,所以一般会在编译时经过分叉算法进行优化和平衡,在这种算法中编译器会根据须要改变case分支语句的处理顺序,进行压缩处理,下降逻辑树深度,加快索引速度。编译器须要找到合适的值使每一个节点的左右子树深度达到基本平衡,通过平衡逻辑二叉树,最大比较深度从o(n)降为o(logn)。通过编译器优化的switch语句提升了执行性能,也提升了逆向分析的复杂度。在逆向分析switch代码时只须要将相等判断的语句提取出来便可恢复switch语句。
在switch分支数小于4的状况下,MSVC采用模拟if-else if 的方法,而当分支数大于4且case断定值存在明显线性关系组合时,编译器会采用语句块地址表或语句块索引表进行优化。若case断定值无明显线性关系则编译器会采用相似上面二叉判断树的方式实现。整体上说,编译器处理case有几步:首先对全部case值按大小排序,而后划分出近似线性段和相对非线性段,对每一个线性段分配静态索引地址数组创建跳转表以加快索引速度,对于非线性段则使用原始if-else型判断加以翻译。
函数和堆栈是密不可分的:参数经过栈传递给函数;函数内部经过划分栈区分配栈变量。
对于32位程序下,若是未经编译器优化,即可能生成函数序言和函数结语,它们的做用分别是保护现场和恢复现场。函数序言部分通常出如今函数的开始,32位函数中标准的函数序言代码以下:
push ebp;保存ebp寄存器
mov ebp,esp;设置栈帧指针
sub esp,localbytes;在栈内存中分配局部变量空间
push <registers>;保存寄存器
其中localbytes变量表示局部变量栈上所须要分配的字节数,变量表示要保存在栈上的寄存器列表,这些寄存器压入栈后,即可以在函数中使用。函数结语部分通常出如今函数的结尾,一般只有一个函数序言,而函数结语可能有多个,32位函数中标准的函数结语代码以下:
pop <registers>;恢复寄存器
mov esp,ebp;恢复栈指针
pop ebp;恢复ebp
ret;函数返回
在MSVC中若是使用naked关键字修饰函数,编译器会省略函数序言和函数结语部分。
64位程序中有两种函数类型,须要栈帧的函数称为帧函数,不须要的称为叶函数。在帧函数中任何须要分配栈空间、调用了其余函数、保存非易失性寄存器或者使用了异常处理的函数都必须有函数序言和函数结语,此外,帧函数还须要一个函数表项。函数序言所作的操做有:必要时将参数寄存器保存在内部栈中、将非易失性寄存器入栈、为局部变量和临时变量分配固定的栈空间、设置栈指针。对于栈中分配的固定空间超过一页(大于4096字节)的状况,栈空间的分配范围可能超过一个虚拟内存页,所以实际分配前须要检查分配状况。编译器会为此提供一个特殊的例程用于保护参数寄存器,供函数序言调用。64位程序函数序言的典型代码为:
mov [rsp+8],rcx;存储rcx
push r15;保存非易失性寄存器
push r14;
push r13;
sub rsp,fixed-allocation-size;为局部变量分配固定大小的栈空间
lea r13,128[rsp];创建栈指针
函数传递参数的方式有3种:堆栈方式、寄存器方式和同时使用堆栈与寄存器的方式,通常来讲传参的类型,不管是普通类型变量、结构体、类,都会拆分红固定长度(4字节)压栈,而浮点数则能够经过寄存器拆分式压栈也能够经过浮点寄存器压栈传参。
64位平台下编译器只使用新型_fastcall调用约定,其主要特性以下:
栈变量老是在某个函数的范围内起做用,一个函数从生命周期开始到结束,整个函数内不管在哪一个做用域申请的局部非静态变量,均是栈变量,一般状况下全部栈变量空间的总和均在函数头部(可能位于函数序言)一次分配。esp寄存器为栈顶寄存器,指向当前栈的起始地址,压栈操做会改变该寄存器值,ebp寄存器为栈底寄存器,指向当前栈的结束地址,用来保存和恢复函数栈帧。esp和ebp经常使用来取得栈变量,在一个函数中,栈顶常常会发生变化,而栈底相对不变。当发生函数调用时,控制权从一个函数进入另外一个函数,就会针对该函数开辟出所需栈空间;当一个函数结束时,须要清除使用的栈空间,关闭栈帧,这一过程称为栈平衡,在MSVC的调试版程序中,会有库函数 __chkesp专门检测函数调用以后的栈平衡。栈帧中能够寻址的数据有局部变量、函数返回地址、函数参数等。
分析栈变量生命周期:高级语言中,变量均有做用域,所谓做用域是指变量在源码中能够被访问到的范围。全局变量属于进程做用域,在整个进程中都可访问,静态变量属于文件做用域,在当前文件中能够访问。局部变量属于代码块做用域,从定义开始的代码块范围内能够访问,该代码块能够是整个函数、循环体中、甚至花括号内。因为全部局部变量均处于函数栈中,而汇编级别不存在高级语言的做用域,而在整个函数范围内都可访问,所以若是须要还原局部变量在高级语言的范围,能够分析引用该变量的代码块,例如仅仅在循环体中引用到该变量而其他代码未涉及,则可认为该变量做用域为该循环体。以上讨论的是非静态局部变量的状况,对于静态局部变量,其做用域也处于代码块中,然而在汇编级别,因为须要保持相对不变,所以位于全局变量区,而不存在栈中。所以静态局部变量的查找方式和做用域相似全局变量。
对于普通类型返回,编译器会根据函数返回类型大小不一样进行不一样的操做。
返回长度 | 返回方式 | 返回类型 |
---|---|---|
1字节 | AL寄存器 | 按值返回 |
2字节 | AX寄存器 | 按值返回 |
4字节 | EAX寄存器 | 按值返回 |
8字节 | EDX:EAX寄存器 | 按值返回 |
浮点型 | 协处理器堆栈或者EAX寄存器 | 按引用/值返回 |
双精度型 | 协处理器堆栈或者EDX:EAX寄存器 | 按引用/值返回 |
近指针 | EAX寄存器 | 按值返回 |
3字节/5字节/6字节/7字节或多于8字节 | 引用方式的隐含参数 | 按引用返回 |
在一些时候IDA采用反汇编算法会产生函数范围误判、没法识别甚至和数据混淆的状况,对于这种状况须要进行人工干预。对于函数起始位置和终止位置的判断能够采用如下方式进行试探。
C++机制是在C语言基础上构建的,因此在实现时借助了C语言的实现方法。下面两个等式从本质上形象地描述了C++语言的主要元素构成方式:
本章介绍了C++区别于C语言的高级语言特性和实现原理,并提出了切实可行的恢复方法,其中包括对new和delete操做符的实现原理进行了分析;对通常类类型的对象内存布局原理进行总结;基于内存对象布局理论提出了一种恢复类结构(包括成员变量和成员函数)的方法,该方法能够用于重建类和识别程序中建立的对象;分析了SEH实现机制和32/64位Windows程序异常处理结构;基于SEH实现机制、C++异常处理底层实现原理和RTTI设计原理提出了Windows程序异常处理语句恢复方法,该方法支持32/64位Windows程序中异常处理语句的恢复。
MSVC的new操做符在编译时是以库函数和类构造函数实现的,内部调用operator new函数,该函数接受一个参数,为申请的空间大小(对象大小),operator new函数会调用malloc函数,而malloc函数调用Windows API函数HeapAlloc,返回分配指针。在代码中放置了new函数之后,为防止内存分配失败,二进制代码中首先会检测该地址是否为空,若为空则直接返回空对象,不然使用该地址做为this指针,传递给类构造函数执行,若构造函数执行成功,则会将地址返回。编译器在遇到new和delelte操做符时,会将它们转化成函数调用。
相似的,delete操做符是以库函数和类析构函数实现的,编译代码首先使用this指针执行类析构函数以后执行delete函数。delete函数接受一个地址参数,最终调用Windows API函数HeapFree实现分配内存释放。
类是C++面向对象机制的基础,所以了解编译器对类的处理机制十分重要。类布局由虚函数表(简称虚表)、虚基类表(简称虚基表)、成员变量构成。
类成员变量一般按照声明顺序在内存中分配,和类的成员变量域相同的结构体仅仅是没有成员函数。下面以包含2个成员变量a1,a2的基类A为例说明编译器一般在实现中采用的类布局。
简单继承中,派生类的成员变量在内存中的位置位于基类成员变量以后,这是大多数知名C++厂商采用的内存安排,这样的好处是,派生类获取基类指针时不须要计算偏移量,由于派生类对象地址同时基类指针。在单继承类层次下,每一个新派生类都简单地把成员变量添加到基类成员变量以后,若是派生类既不重写也不增长新的虚函数,那么父类虚表能够重用。以类B为例,B继承于A且有一个b3成员变量。
大多数状况下简单继承对于编程已经足够,然而C++为特殊缘由也支持多重继承,若是当前对象同时兼有多个互斥对象的特性,须要对多个基类作交集,这时候要使用多重继承。内存中的布局是基类在先,派生类在后,与单继承相同的是,类C拷贝了类A和类B的全部数据,不一样的是,类C的指针和类A相同和类B不一样。以类C为例,C依次继承于A和B,且有一个c4成员变量。
在多重继承中,若派生类继承的基类也继承于同一个原始基类,若是该原始类成员较多,通过拷贝后每一个基类都会含有相同的成员,而派生类进行继承就会产生较大资源浪费和内存开销,同时实例中原本相同的成员能够分别进行修改而不是共享关系形成数据不一致,为了解决这个问题出现了虚继承,在虚继承中继承的相同成员变量是共享关系,只有一份实例。虚继承中,虚基类的相对位置是不固定的,可能会根据派生类而不一样。
编译器须要跟踪每一个继承的虚基类的基址偏移,这部分在MSVC经过生成虚基类表vbtable实现从而实现间接计算虚基类位置的目的,该表存储的是相对该类的每一个虚基类表指针与虚基类之间的偏移量,而GCC作法也较为类似,它会将该偏移存放在虚函数表(vftable)中,也就是说MSVC中的虚函数和虚基类使用的是不一样的表(虚表和虚基表),而GCC中则是都写在虚函数表中的。以类D和类E为例,D虚继承于A,且有一个成员d5,E依次虚继承于A、继承于B,且有一个成员e6。
MSVC | GCC |
---|---|
const D::vbtable dd 0//类D基址偏移 dd 8//类A基址偏移 |
const D::vftable dd 8 dd 0 dd offset//类D typeinfo结构偏移 dd 0 |
const E::vbtable dd 0//类E基址偏移 dd 0CH//A基址偏移 |
const E::vftable dd 0CH dd 0 dd offset//E的typeinfo结构偏移 dd 0 |
若虚继承的类自己是虚继承的,则类布局中会有多个虚基类表指针,对于虚继承以及继承的基类是虚继承的状况,下面的类布局顺序在MSVC系列编译器中成立:
菱形继承是另外一类较为复杂的对象结构,会将单一继承和多重继承进行组合,所以菱形继承能够很好地用来观察类布局。假设类A为基类,有成员a1,x,类B和类C分别虚继承于类A,同时类B和类C各有成员b1,x和c1,类D依次继承于类B和类C,且有成员d1,x。
RTTI存储了丰富的类型,能够给逆向分析C++的类结构带来极大帮助,同时若是使用了面向对象的异常处理机制,编译器也会产生相应的RTTI信息。可见了解RTTI结构十分必要。对于有虚函数的类,其布局中会产生虚表,而虚表所在地址以前的一个机器字长大小的元素,存放着该类的一种称为“RTTI彻底对象定位定位符”(RTTI Complete Object Locator)的结构体指针。它是一种用于描述类继承关系的结构体。该结构包含两个指针,该结构以下:
+0x00 ULONG signature;//结构标志
+0x04 ULONG offset;//对象内存中该类偏移
+0x08 ULONG cdOffset;//RTTI类型描述符(RTTI Type Descriptor)指针
+0x18 ULONG pTypeDescriptor;//RTTI类继承描述符指针(RTTI Class Hierarchy Descriptor)
其中RTTI类型描述符在C++程序中以type_info类实现,该结构以下:
+0x00 ULONG _vfptr;//type_info类虚表指针
+0x04 ULONG spare;
+0x08 CHAR name;//通过名称粉碎和重修饰的类名
而RTTI类继承描述符记录了类的继承信息,其结构以下:
+0x00 ULONG signature;//结构标志
+0x04 ULONG attributes;//继承类型,虚继承或多重继承
+0x08 ULONG numBaseClasses;//基类个数
+0x0C ULONG pBaseClassArray;
其中pBaseClassArray是指向基类描述符数组,该数组每一个元素指向每一个基类的RTTI基类描述符结构体,该结构体中一个成员指向该基类的type_info结构,从这些结构很容易肯定出全部类之间的关系。
对象也有做用域,不一样做用域的对象生命周期不一样,所以构造函数被调用的时机也不一样,若是能够从二进制代码中分析出对象构造函数和析构函数的调用时机,那么就能够推知该对象的做用域类型以及生命周期。对象按做用域类型能够分为下面几种: 局部对象:和栈变量相似,栈变量均在函数入口处统一分配空间,对象也相同,然而对象的构造函数是在做用域(块)开始位置调用的,析构函数是在做用域(块)结束位置调用的。识别局部对象的构造函数的必要条件有两个:该函数是这个对象调用的第一个函数;该函数返回this指针。
构造函数和析构函数是类的重要组成部分。类构造函数和析构函数都是可选的。构造函数在类实例化对象时分配空间以后自动调用,是对象第一个被调用的函数,用来初始化类,在高级语言语法中,构造函数是禁止设置返回类型的,然而在汇编级别的实现中,老是返回传进来的this指针,并能够接受多个参数。根据C++标准,构造函数不自动激活异常,即便对象内存分配失败。大多数编译器在调用构造函数以前会放置检查空指针的代码,内存分配成功后,才会执行构造函数,而对象的其它函数即便在内存分配不成功的状况下也会被调用,而若是此时this指针为空,那么对象首次调用的非构造函数将可能触发一个异常。根据上述原理可知,以检查空指针代码做为函数结尾的函数多是构造函数。在最坏状况下,构造函数会依次执行以下操做:
在编译器执行代码优化后,上面的步骤可能顺序会被打乱,而且有些函数进行了内联操做(例如构造函数和析构函数)。 对于全局对象,其构造过程在启动代码中实现。通常的方式是使用编译器生成的函数表调用构造函数,构造函数的内存存于数据段,在这个步骤中编译器会设法在程序结束前调用类析构函数,MSVC会将析构函数添加到atexit()回调中,而GCC则会使用一种析构函数表(全局对象)完成该操做。对象数组的构造,对象数组的每一个元素会分别建立,若是任何元素的构造函数抛出异常,全部前面构造的元素都会析构,数组析构时,每一个元素都要正确释放,即便数组大小不能肯定也必须成功完成该操做。在这个过程当中MSVC使用了一种称为向量构造迭代器(vector constructor iterator)的辅助函数完成该操做。析构函数和构造函数类似,可是是无参函数,C++规定只在内存分配成功而且建立了对象的状况下才调用析构函数,所以析构函数代码中也会放置检查空指针代码。MSVC编译器会自动生成异常处理结构以保证异常发生时对象能够被销毁。与构造函数不一样的是,类只能有一个析构函数,而可能有多个重载的构造函数,而析构函数通常设置为虚函数以实现资源自动回收释放机制,所以构造函数不会出如今虚表中,而析构函数每每出如今虚表中。析构函数执行的操做和构造函数恰好相反,在最坏状况下析构函数会依次执行以下操做(若是有虚函数则初始化虚虚函数表指针及成员变量(这样操做之后函数体里的虚函数调用会使用当前类的方法):
因为简单的析构函数可能会在编译器优化期间内联,所以常常能够在汇编代码中见到虚表指针在一个函数屡次加载的状况。在MSVC中有虚基类的类构造函数接受一个隐藏的“最终派生类”标志决定虚基类是否须要初始化。MSVC采用分层析构模型,在析构代码中加入了一个隐藏的析构函数用于析构包含虚基类的类(对于“最终派生类”而言);代码中再加入另外一个虚构函数用于析构不包含虚基类的类同时前者调用后者。
对于不一样继承类,虚析构函数可能结构不一样,编译器须要保证在不知道指针类型的状况下进行正确的操做,所以MSVC使用了一种辅助函数(deleting析构函数)存放在虚表中替代实际析构函数,它会调用实际的析构函数,而后执行delete操做。而GCC则使用了多重析构函数(in-charge、not-in-charge和incharge-deleting函数),并经过调用相应的多重析构函数进行操做。 通常在没有显式定义构造函数时,在下面两种状况下编译器会提供默认构造函数:
C++面向对象思想和高级特性是以C为蓝本实现的,对象从本质上讲就是含有属性(成员变量)、事件和方法(成员函数)的动态结构体,而类则是包含函数、静态数组(包含虚函数表、虚基类表、成员变量域)的具备保护属性(如public,private,protected,friend)的混合体。保护属性只在编译级别由编译器语法检查来维护,而在底层类布局中,基类全部成员不管保护属性如何都会被派生类继承。对象实例和结构体实例最大的不一样在于对象实例会使用this指针。经过this指针能够分析出对象大小。 全局(静态)对象在编译期间被分配到数据段中,所以通常不会出现内存分配失败。为了实现构造函数只能调用一次的条件,一般编译器会使用一个初值false的全局变量标志,在首次调用构造函数时将该值置true。在类对象被使用时,先判断该标志是否为false,若是不为false就跳过构造函数语句。全局析构函数一般在_atexit之类的运行库函数内顺序进行注册,并在程序结束前由doexit函数倒序进行调用。
识别非虚函数:在调用类成员函数调用以前,一般会先获取到对象实例的this指针以便函数在须要的时候使用。所以若是在汇编级流程中已经分析出某函数中使用了this指针就能够利用该信息分析和使用到该变量的代码,经过函数调用识别出某个类成员。 识别虚函数和成员变量:若是找到了类构造函数就能够找到该类的虚函数表地址,通常来讲虚函数表与静态变量和全局变量存放在数据段的不一样地方,虚函数表中存在的虚函数和成员变量便容易分析出来。纯虚函数在函数表中是以指向库函数__purecall的指针代替。编译期间会作出语法限制,纯虚函数要求继承后才可调用,通过继承虚函数表中的__purecall被替换成相应的继承类虚函数地址,所以通常不会生成对纯虚函数的调用,若是运行时遇到了纯虚函数调用,程序会出现一个异常并终止运行;在发行版中,纯虚函数一般会被优化掉。通常地,能够经过下面特征识别虚函数:
所以,对于类虚函数识别,最终归结于对构造函数和析构函数的识别,如前所述。在这两个函数中,均会对虚表进行初始化。另外对于this指针的识别,若是发现某个寄存器在父函数调用子函数以前被赋值,而在子函数中该寄存器未经初始化直接引用的,则应判断该处是否存储了this指针。若是找到了初始化this指针的行为,那么就能够找到该类的继承关系。
重建类主要是重建类布局,手工重建类布局包括两方面,一方面是经过this指针的引用状况识别出类成员变量,另外一方面,经过this指针找到构造函数并根据构造函数中设置this指针的行为找到该类全部虚函数。另外,对于引用this了指针却不在虚表中的函数,能够归并到类的普通成员函数中。因为对象内存大致上是虚表和类成员,且虚表中的虚函数与类成员均与声明顺序相同,这样就能够近似模拟一个和原始高级语言类功能基本相同的类。
异常是对程序运过程当中发生的异常状况的一种响应。异常能够是硬件产生的,也能够是软件产生的。当异常发生时,系统将程序控制权转交给异常处理代码,例如在32位Windows系统中,FS寄存器的零偏移处存储着线程相关的结构体,经过该结构体能够获得异常处理函数地址。异常流程一般包括三个部分:引起异常、捕获异常、处理异常,经常使用于建立对象、文件I/O操做中。异常处理机制因为其执行顺序的复杂性常常用于软件保护和代码混淆机制中,所以对异常机制的分析是必要的。这里以MSVC为例说明编译器如何利用Windows下SEH机制产生异常代码,MSVC支持三种类型的异常处理,包括C++异常处理、结构化异常处理、MFC异常处理。
C++标准规定了异常处理的语法,各编译厂商都要遵循这些语法,可是因为C++标准没有规定异常处理的实现过程所以不一样厂商编译器产生的异常处理代码不一样,是C++程序经常使用的类型安全的处理方式,用来确保函数结束的栈解退(stack unwinding)过程当中对象析构函数被正常调用。栈解退是这样一种过程:函数因为出现异常而非因返回而终止,则程序会释放函数栈内存,可是不会释放到当前函数返回地址(正常函数调用指令会将CALL指令的下一条指令地址压栈)而结束,而是继续释放多级函数栈直到找到一个位于try块中的返回地址,以后控制权转到该异常处理程序。和函数返回同样,该操做会调用类的析构函数,然而函数返回仅处理当前函数在栈中的对象,而异常处理语句则处理try块和throw之间整个函数调用序列中存在的对象。
C++异常中使用的关键字有try,catch和throw。try块用于监视异常,一个try块后能够跟随多个catch块,每一个catch块用于捕获一种异常,catch异常声明语句是省略号表明捕获判断以前未捕获的任何类型的异常,包括C类型异常和系统和程序产生的异常。throw表达式用来抛出各类表达式形式的异常。一般在捕获时能够指定标准库中定义的std::exception类及其派生的类做为异常捕获类型,同时,C++还容许从该类派生自定义类。
MSVC的异常处理机制创建于SEH机制之上,在处理C++异常时会在具备异常处理的函数入口处注册一个异常回调函数,该函数将一种异常信息结构体(FuncInfo)压栈并调用库函数__CxxFrameHandler处理该异常。抛出异常采用库函数__CxxThrowException完成,该函数接受的两个参数分别是产生异常的对象指针和异常信息结构体(ThrowInfo)指针。异常回调函数在得到执行权后会获得这两个参数以及FuncInfo表结构地址,根据异常类型进行try块匹配操做,若是匹配失败则析构异常对象并返回继续搜索的信号;若是找到对应try块则经过ThrowInfo表结构的类型遍历查找匹配catch块,以后进行栈解退和析构对象操做直到到达try所在函数,进而执行catch块。C++异常处理机制经过下面的指令序列完成当前函数中SEH链的构造和异常回调处理例程的注册:
push ebp;
push trylevel;__try的层数
push handler_address;异常处理函数地址
push large fs:0;当前SEH链地址入栈
mov large fs:0,esp;SEH链增长新元素
异常处理函数中会先将FuncInfo压栈,该结构体偏移0x10处指向一种TryBlockMapEntry结构体,结构以下:
+0x00 DWORD tryLow;try块的最小状态索引,用于范围检测
+0x04 DWORD tryHigh;try块的最大状态索引,用于范围检测
+0x08 DWORD catchHight;catch块的最高状态索引,用于范围检测
+0x0C DWORD dwCatchCount;catch块的个数
+0x10 _msRttiDscr* pCatchHandlerArray;catch块描述
_msRttiDscr结构体中存储了每一个catch语句所捕获类型的RTTI描述,以及catch块的首地址,根据这些信息即可以恢复出C++异常部分的源代码。在C++程序中,应该使用C++异常处理而应避免使用结构化异常处理,虽然SEH能够用于多种语言,然而使用C++自己的异常处理更灵活,能够处理任何异常类型,使程序更好地移植。
Windows系统特有的异常处理机制,适合在C语言程序设计中使用。当进程没法从硬件和软件异常中恢复时,结构化异常处理机制会显示出相应错误信息并记录下进程内部状态用于诊断软件缺陷。这个机制对于不可复制的缺陷极为有用,在Windows程序设计中也很常见。编译器的SEH机制是创建在操做系统SEH机制之上的,在SEH中有三种形式的异常处理方式,包括异常处理(Exception handler)、终止处理(Termination Handler)和向量化异常处理(vectored exception handler)。其中异常处理是使用try-except语句;终止处理是使用try-finally语句;向量化异常处理是使用API调用AddVectoredExceptionHandler注册处理函数,RemoveVectoredExceptionHandler注销处理函数。
__try compound-statement
__except (expression) compound-statement
__try语句用于监视异常,__except用于异常处理,触发过程为:首先执行try块语句,若是过程当中无异常发生,则执行__except语句以后的语句。若是执行过程当中发生了异常或者被监视语句调用的任何子程序中发生了异常,程序会计算expression表达式决定如何处理异常.
__try compound-statement
(__leave)
__finally compound-statement
__try语句用于监视异常,__finally语句用做在监视代码退出后执行的特定操做,该操做不管监视代码是否因异常而退出都会在执行。触发过程为:首先执行__try语句,若产生异常则控制权转到__finally,若是未发生异常则监视语句执行完毕后控制权转到__finally语句中(不管如何都会进入__finally,即便使用了goto语句),__finally中的复合语句执行完毕后执行以后的语句。__leave关键字在try-finally语句块中是合法的,用来跳出try块直接执行__finally语句。Windows为注册异常回调函数定义了一种特殊结构体EXCEPTION_REGISTRATION,该结构体以链表形式相互链接成SEH链,结构以下:
+0x00 struct EXCEPTION_REGISTRATION* Prev//前一个结构指针
+0x04 DWORD Handler;//异常处理例程地址
+0x08 struct SCOPETABLE_ENTRY* scopetable;//异常处理做用域表
+0x0C int trylevel;//try层数
+0x10 int _ebp;//函数序言ebp
+0x14 PEXCEPTION_POINTERS xpointers;
相应地,在32位Windows程序中,能够经过下面的指令序列完成当前函数中SEH链的构造和异常回调处理例程的注册:
push ebp;若是使用了SEH那么必定会有函数序言部分且被添加到函数序言
push trylevel;__try的层数
push scopetable;指向scopetable表的指针,描述异常处理的做用域
push handler_address;异常处理函数地址
push large fs:0;当前SEH链地址入栈
mov large fs:0,esp;SEH链增长新元素
32位Windows程序中,每一个线程都有本身的异常回调函数,TEB结构体记载了线程的全部信息,该结构体能够经过FS:找到,其第一个成员为指向EXCEPTION_REGISTRATION结构体的指针,结构如上所述。其中Handler成员指向一个运行库函数,该函数用来从SEH链向前索引获得做用域适合该异常的第一个异常回调。其中存储于SCOPETABLE_ENTRY结构体的HandlerFunc域为用户异常处理代码,而FilterFunc域用作异常类型过滤函数,一般SCOPETABLE_ENTRY结构随Handler变化。
当statement1的代码块中产生异常后,系统查找当前线程TEB结构,从中读取出异常结构EXCEPTION_REGISTRATION,使用结构中的运行库函数根据SEH链搜索适合处理该做用域的第一个异常处理,若是搜到就进行过滤函数的判断以及执行相应的用户代码。当含有异常处理的代码所在函数退出时,会将栈上的原始FS:(这一步包含在构建新SEH链中)覆盖现有FS:,从而完成异常处理回调例程的注销。
相综上所述,对于32位Windows程序,恢复SEH代码的步骤为:首先查找函数序言的代码,若是有对fs:处的操做就是在引用SEH,须要查看以前的压栈操做,根据上述结构找到用户异常处理代码块,包括__except的过滤函数和执行块以及__finally执行块。对于__try块的肯定,每次该块代码执行以前,一般会将以前压栈的trylevel设置为0,而在执行以后会当即将该值设置为-1;trylevel的做用极其重要,能够用来分析多重异常布局状况下的异常语句恢复。
32位程序异常处理的实现须要借助函数栈,这种方式存在两个弱点,首先异常信息存储于栈上,容易被栈缓冲区溢出利用。其次,异常状况通常出现的次数并很少,可是每次执行函数都须要为使用SEH而初始化相关变量和栈,形成指令冗余。64位程序异常处理提供基于表的SEH(32位程序是基于栈帧的SEH)以解决上述两个问题,在源码编译成可执行代码后,编译器会为该PE文件生成一种表存放于PE头部用于异常处理,该表存储了全部描述模块异常代码的信息。异常发生时,Windows系统会解析该表,根据执行函数找到合适的异常处理函数。
Windows 64位程序采用PE32+文件格式,该格式是PE格式的一种改进形式。该格式中的.pdata段的ExceptionDir目录结构中存在一种异常表,存储了全部具备异常检测功能的函数信息,该结构存储了大量RUNTIME_FUNCTION结构体数组,该结构体结构以下:
+0x00 ULONG BeginAddress;//异常处理所在函数起始地址
+0x04 ULONG EndAddress;//异常处理所在函数结束地址
+0x08 ULONG UnwindData;//异常结构信息
其中UnwindData是指向异常结构信息的地址,该地址处为包含一个UNWIND_INFO信息头以及若干数量的UNWIND_CODE结构体,该数量由UNWIND_INFO的CountOfCodes决定,该结构体存储了该函数中的异常处理信息,包括try块所包围的代码起始和终止位置。
和32位SEH同样,编译器会提供一个库函数用于处理异常,这个函数(__C_specific_handler)的地址存放于UNWIND_INFO的handler_address中,而variable域,在try_finally语句下,会生成flag=11的结构体,该结构体分别存放:try块指令起始地址偏移、try块指令结束地址偏移、finally语句指令偏移。而try_except语句下则会生成flag=9的结构体,该结构体分别存放:try块指令起始地址偏移、except语句指令偏移、过滤函数地址偏移。根据这些信息足以定位异常代码。相应地,对于C++类型异常,会产生flag=3的结构体,处理异常的库函数一样为__CxxFrameHandler,该结构体较为复杂,其中包含每一个catch所捕获类型的RTTI信息(为前述type_info结构体)、每一个catch的函数序言起始指令偏移及中间体代码(除去函数序言和函数结语部分)起始指令偏移及函数结语起始指令偏移。根据这些信息就能够还原出原始异常捕获代码。
在PE32+文件中异常表RUNTIME_FUNCTION数组是根据函数起始地址排序的,当异常发生时,当前线程的全部现场信息都会由操做系统存储在一种context记录中,以后系统触发异常派遣功能,重复执行如下步骤:
本章经过实例演示了如何利用文中提出的逆向分析模型,借助分析工具,对Windows下通常应用软件进行逆向分析的整个流程,输入二进制可执行代码,经过分析文件类型、查找程序入口、分析C/C++语言元素、分析函数功能、分析算法,输出软件设计流程、算法和文档,在这个过程当中合理协调软件与人工分析共同完成软件逆向工做,例证了该模型的正确性和可用性。
IDA是用来作手工分析的辅助工具。IDA反汇编的时间与程序代码段大小有关,分为两个阶段,第一阶段将代码与数据分开,标记各个函数和符号并分析参数调用、参数栈、局部变量栈、跳转等指令关系,并分析数据结构,自动生成流程图和模块调用关系。第二阶段识别出文件编译类型信息并加载对应特征库,这部分主要经过FLIRT技术(Fast Library Identification and Recognition Technology)实现,该技术能够经过对比特征码自动找出库函数调用;除此以外IDA的插件扩展性和交互性极强,较好的插件包括C语言伪代码分析工具Hex-Rays;支持多平台调试;支持IDC脚本。
查看PE信息:在去除了程序保护之后,首先须要了解文件的类型,这种类型包括两个方面,一个是该文件的编程语言(C++/C/Delphi/VB/ASM等)和编译器类型(Delphi、VB、Borland C++等),另外一个是该文件的用途,IDA通常能够自动根据PE格式获取文件类型是可执行程序、动态连接库、静态连接库、驱动程序等,而用户也能够根据经验经过查看入口函数部分指令序列判断程序类型。其次,须要分析文件中使用了哪些API调用或者导出了哪些符号,这部分能够经过函数导入表和导出表获得。对于。或者导出IDA能够列出输入函数、输出函数、PE节、字符串表。
分析程序结构:分离各个PE节,分离用户代码和库函数代码,IDA提供了PE文件布局图,对于PE文件各个段数据,以及库函数代码和用户代码都作出了区分。红色的部分是未识别出的函数,须要使用者手工肯定该处数据类型是函数或数据。
识别库函数:在这一阶段,IDA将使用FLIRT技术识别库函数,加载并标记特征库,并加载相关的数据结构模板。
识别数据结构:通常来讲IDA能够根据API函数和库函数的相关信息推导出与之关联的变量类型,可是对于用户自定义函数则无能为力,所以IDA提供了自定义数据结构的方法,包括简单数据类型、浮点类型、结构体、枚举类型等。用户能够经过直接修改数据类型、自定义结构体、从C语言头文件导入结构体、从选择的结构体操做代码区域推导结构体。对于代码区隐藏的数据和数据区的结构体,通过上面的操做,能够以与源代码所定义数据结构形式最接近的方式呈献给用户。用户能够手动加载头文件添加数据结构,同时也能够自定义数据结构。对于能够,IDA支持C类型的数据结构,包括简单数据类型和结构体。
代码分析: IDA能够以反汇编指令、16进制数据、C语言伪代码、函数关系结构图四种形式展现代码分析结果。因为代码分析过程会产生错误,所以IDA容许用户手动调整,自定义指令序列或数据部分开始的位置。
函数识别: 加载PE文件后,IDA会自动分析其中的库函数和API函数,并将识别出的函数名替换到函数列表中以便查阅。对于未能识别或识别错误的代码,须要进行手工分析其参数类型、参数个数、起始位置、结束位置、调用方式等,并对IDA的相应参数进行修改,必要时还需作堆栈平衡分析和修改。在了解函数功能后最好进行注释,并取符合功能的名称做为函数名。
本文研究已在普通PC机上进行了成功测试,操做系统平台为Windows 7 x64 Ultimate,测试工具为PEID、IDA,测试对象为Nisoft的AltStreamDump,该软件用于查找指定目录下的全部NTFS文件数据流。本文处于研究目的,版权归原做者全部。
int wmain(int argc,wchar_t* argv,wchar_t* envp)
GetCurrentProcess
LoadLibraryW “advapi32.dll”
OpenProcessToken
LookupPrivilegeValueW
AdjustTokenPrivileges
CloseHandle
查询MSDN并进行详细分析可知该组调用序列为Windows系统提权的典型功能代码。
根据这种方式综合分析,获得程序中的两个自命名类,一个是用于控制显示部分的MainClass,另外一个用于文件查找的FindFileClass。这样就根据this指针肯定了全部成员函数和类成员。另外调用第一个成员函数以前存在对对象成员变量域的赋值操做,这种操做极有多是类构造函数采用了优化而内联的形式存在于父函数之中,对因而否为构造函数,能够经过该类每次出现于内存中是否都执行了构造函数这个本质进行验证,若是不是则认为它是通常成员函数,对于析构函数同理。在分析了各个成员变量做用域以及搜索到全部关联的成员函数后能够获得下面的两个简单类结构:
AltStreamDump v1.05 Copyright©2011-2012 Nir Sofer
系统配置:支持Windows 2000直到Windows7的系统
使用说明:AltStreamDump不须要任何安装过程或附加dll文件,打开命令提示符窗口就能够运行该程序。AltSrtreamDump默认显示当前目录的文件数据流,您能够经过使用-f和-d命令行参数查看其余文件夹的文件数据流。
命令行选项:
-h用于显示命令行帮助;
-f [Folder Path]用于指定要搜索的目录;
-d [Subfolders Depth]用于指定要搜索的父目录深度(0=不搜索子目录 1=搜索一级子目录,以此类推)
例子:AltStreamDump.exe –f “c:\myfolder” –d 3
通过逆向分析后,可知该程序是经过调用ntdll.dll中的API函数NtQueryInformationFile获得文件数据流的。将源代码采用MSVC6从新编译,生成的AltStreamDump.exe运。