- UID
- 2
- 精华
- 积分
- 7736
- 威望
- 点
- 宅币
- 个
- 贡献
- 次
- 宅之契约
- 份
- 最后登录
- 1970-1-1
- 在线时间
- 小时
|
本帖最后由 元始天尊 于 2015-10-22 00:38 编辑
这是我的毕业设计,核心部分是在半个月内完成的,大部分是总结性质的,我的研究集中在恢复代码部分
Windows下C/C++程序的静态分析技术
第一章 绪论
软件逆向起源于软件维护,通过恢复和重建软件的设计模式、技术文档、源代码、资源和数据库,以便跟踪软件更新带来的影响、重新构建已有系统、更新用户接口或移植到新架构和平台上。逆向工程已成为软件工程领域不可或缺的一个重要组成部分,随着软件复杂性和复用性的提高,逆向工程受到广泛关注,从而有了更广阔的发展空间。
1.1 软件逆向工程的研究意义
逆向工程是一个广义概念,是指从可运行的程序系统出发,运用解密、反汇编、系统分析、动态调试等多种技术对软件进行分析[1],推导出软件产品的结构、流程、算法、软件架构、设计模式、运行方法、相关资源及文档等这一过程。软件逆向的整个过程统称为软件逆向工程,过程中所采用的技术统称为软件逆向工程技术。
现实世界中的软件系统需要不断改进以满足用户新需求、适应新的行业模式和结构、符合不断变化的法律要求,还要紧跟技术革新[2]。软件改进就需要对已有软件进行深入理解,理解软件的过程是一个高强度的人工介入过程,软件开发者需要具有渊博的软件和系统相关领域的知识,经过分析、推断、归纳和总结最终理解软件。多数情况下软件理解所遇到的最大困难主要有:相关文档缺乏、技术瓶颈和资源时间受限[3]。
在专业开发环境下使用高级语言进行软件开发是现今最为常见的软件开发方法。但是由于软件所使用的环境、编译器、库文件、接口和组件的复杂性,使用高级语言源代码分析目标软件往往无法满足对测试和调试的要求,很多时候还需要针对已经编译过的甚至是优化的低级语言代码进行分析,这种低级代码通常是二进制形式的机器码指令序列。虽然对于软件逆向分析技术的研究已经有50多年的历史,然而至今未形成一套良好的分析方法[4]。
随着计算机科学和相关技术的不断发展,尤其是高级编程语言的不断完善,高级语言越来越容易学习、掌握和使用,学习周期和开发周期随着语言的进步不断缩短,各种高级语言开发环境和编译工具的广泛涌现,使得设计出来的程序具有高度可读性、可维护性和可靠性,软件开发人员可以将一些“繁杂琐碎的事务”交给编译程序或者解释程序去做,从而从某种层面上脱离了机器语言,提高了程序可移植性和可重用性。然而同时软件开发人员对于硬件层的低级语言形式编码越来越陌生,对于程序的优化程度也会造成一定影响。事实证明计算机软件领域不可能真正脱离对繁琐的代码进行分析的需求,而软件逆向分析技术一直是计算机科学领域的研究热点,特别是当软件系统缺乏最新的文档时,进行软件逆向分析尤其是对动态代码的分析是必要且是较为困难和繁琐的过程,一些著名智能代码分析工具(如IDA和Hex-Rays)的出现,在一定程度上提高了逆向工作的效率,但是由于现有的逆向分析工具功能不一,软件结构本身复杂性与多样性,逆向工作者往往需要手动深入挖掘软件里包含的所有隐藏信息和可推断隐藏信息才能对分析的软件有更深刻的了解。
从软件安全和网络安全角度来看,逆向分析有着不可替代的作用,在大多数情况下,恶意软件、病毒木马蠕虫、外挂等威胁软件安全的工具作者并不会提供源代码,然而有针对性地逆向分析其源代码可以帮助准确了解其产生的危害和防治方法,通过静态和动态分析方法了解其运行机制。其次,逆向分析可以用来进行软件和系统的安全漏洞的挖掘,分析该漏洞是否可用以及什么情况下可用,进而开发出相应的补救措施。
1.2 国内外发展现状
软件逆向工程已有近50年的历史。1994-2004年间曾经召开了11次会议探讨逆向工程技术,卡内基梅隆大学软件工程成立了专门的再工程中心,IBM研究中心设立了软件工程中心在多维分解方面的研究项目已持续了多年。逆向工程技术发展至今,国外已经开发了众多商业版或自由逆向工程软件,例如SrcML, DMS, TXL, CodeSurfer, Bauhaus fat extractor, Columbus/CAN, Daikon, Ldiff, Evolizer, Moose, Grok, Crocopat, Bauhaus, Rigi, CCFinderX, FINT, Design pattern detector, Codecrawler, SHnMP, HipiKat, CloneTracker等[5]。在PC应用软件方面,IDA及其插件是功能较强的、交互式、可扩展、多功能的静态分析工具。
在国内随着人们对软件维护和软件安全的重视,软件逆向工程的研究也逐步开展,这方面国内的典型是JBPAS和XDRE。从另一个角度看,由于法律的不完善和监管力度不足,软件逆向和破解技术在国内十分火热。目前开发出来的逆向工程工具有很多,有些已经得到了比较广泛的应用,对于理解程序也有很大帮助,但是,逆向工程仍然是一个相当不成熟的领域,理论和实践都处于早期阶段。
在PC应用软件方面,以往的软件逆向研究往往侧重于原理,而较少从二进制可执行代码的角度进行分析,另外一些研究仅仅停留在理论阶段,或无法直接应用于现有软件体系,局限性很大。就目前现状来说,直接从机器码转化为C语言代码在技术上已经基本实现,然而直接从机器码转化为C++语言代码是比较困难的,造成这种结果一方面是由于现有软件机制(软件、编译器和系统)的复杂性,另一方面是软件逆向工作者缺乏系统的逆向观。
目前Windows下C/C++软件逆向的分析手段较多,分析软件不断推陈出新且越来越智能化和人性化,功能也越来越强,常用的调试器有OllyDbg、WinDbg、RORDbg等,其中RORDbg为虚拟运行调试,反汇编器有IDA Pro、Hopper、W32DASM等,反编译器有Hex-Rays,PE工具有LordPE、Peditor、ImportREC、exeScope、Resource Hacker、ResScope、PE Explorer等,查壳脱壳工具有peid、FileInfo、File Scanner等,监视工具有Process Monitor、PC Hunter、MFCspy、Spy++。除此之外还有API-HOOK、嗅探工具等辅助工具。借助诸多逆向分析工具和一些底层代码分析技术在一定程度上可以对小规模软件进行逆向分析,然而如果缺乏系统逆向观,对于大规模软件或遇到混淆代码的特殊情况,仅仅使用代码分析工具和底层代码分析技术,会显得力不从心,无法保证逆向分析的进度。本文提出了一种应用级静态逆向分析模型进行系统化、通用性的软件逆向,该模型与高级语言元素一一对应,可以同时解决编程语言级别和软件架构级别的软件逆向问题,提高软件逆向分析的效率。
1.3 论文内容及结构安排
本文提出了一个应用层面的、有一定通用性的、系统化的逆向分析模型,围绕这一模型对数据类型、表达式、语句、函数结构等基本的C语言元素和类类型、异常处理机制等C++语言元素进行了实现原理分析,并提出相应的恢复方法。最后通过实例演示利用该模型,借助分析工具对Windows一般应用软件进行逆向分析的整个流程,例证了该模型的正确性和可用性。
第二章对软件逆向相关工具、方法、原理相关知识进行了简略介绍。
第三章针对Windows系统下的C/C++程序,提出了一个较为系统的、通用的、可实践的逆向分析模型。
第四章从基础的C语言语法开始进行描述,其中包括对常见数据类型、表达式、语句、函数结构进行具体深入分析,论证其实现原理和逆向分析方法。
第五章在C语言的基础上介绍了C++区别于C的高级特性和实现原理,并提出了切实可行的恢复方法。其中包括new和delete操作符、内存类布局、SEH和C++异常处理。
第六章是对第三章提出逆向模型的测试样例,证明了该模型的可用性。
第二章 软件逆向的基础
本章首先论述了二进制可执行代码的作用和格式,之后介绍了软件逆向所需要了解的基本知识,最后论述了软件逆向常用工具及其原理。
2.1 二进制可执行代码
二进制可执行代码是由高级语言编写的源代码经过编译器编译的生成结果,或者低级语言代码经过汇编器的生成结果,在这个阶段,链接器将多个对象文件合并成一个包含了多个模块代码的可执行文件,表现为二进制可执行代码。运行时,系统可执行文件加载器将文件加载到内存执行[6]。
2.1.1 分析二进制可执行代码的必要性
最先进的程序分析工具最适合分析源代码,它相比于二进制可执行代码级别可以得到更多的高级信息。然而二进制可执行代码仍然值得研究,软件中所有的信息都会存在于二进制可执行代码中。很多商业软件(特别是Windows平台的软件)以及恶意软件(如病毒,木马,间谍软件)都是以二进制格式发行,而分析这类二进制可执行代码极其重要。和源代码相比,二进制可执行代码还有另一个明显的优势——执行很方便但是难于阅读,这个特性在软件公司发行软件的隐私保护是很有利的[7]。
对于防止系统被攻击角度来说,分析二进制可执行代码也是十分必要的,它能在不需要源代码的情况下提供安全保障且能避免一大堆法律上的问题。另外破解技术带动了一个流行的研究方向,即软件保护,它的作用是防止软件被逆向。破解和保护是一种无尽的博弈,相对于软件破解,软件保护更加需要对二进制可执行代码进行分析和理解,软件破解仅仅需要理解代码的逻辑,找到代码中敏感信息处之后禁用或者修改即可,而软件保护需要在理解二进制可执行代码之后建立起防御系统,将其植入代码敏感信息处,并且设法阻止原始二进制可执行代码和防御系统被逆向。同时,恶意软件会威胁使用者的系统和硬件安全,病毒和木马都是以二进制形式传播,并且隐藏在宿主文件二进制可执行代码中。恶意代码会随着宿主文件的启动而执行,之后又会感染更多文件。因此,对二进制格式代码的分析是很有必要的。
2.1.2 二进制对象文件格式
对象文件格式是一种计算机用于存储目标代码和关联数据的文件格式。各种类型的操作系统和编译器都有自己的对象文件格式,公认的格式有COFF、ELF和OMF。一般对象文件会包含多种数据块,每个数据块都存储一种类型的数据:头部、可执行代码段、静态数据段、未初始化数据段、链接引用、重定位信息、动态链接信息、调试信息。根据对象文件的使用情况,可以分成下面3种类型及他们的组合:
可链接对象文件:是链接器或链接加载器的输入文件。包含了大量符号和重定位信息,目标代码通常分成许多小的逻辑段,链接器每次会分别按不同方式处理这些段。
可执行对象文件:加载到内存且作为程序执行的文件。包含目标代码,为了使整个文件映射到地址空间,一般是页对齐的,通常不需要符号和重定位信息。该种文件格式是最常见的可执行文件,通常可以直接在目标操作系统环境中运行。
可加载对象文件:可以作为库和其他程序一起加载到内存中。由许多纯目标代码构成,包含所有符号和重定位信息,以便根据不同系统运行环境进行运行时符号链接。可执行文件是一种具体的对象格式,在不同的系统和编译器中,可执行有各种不同的格式。现今最流行的包括LINUX/UNIX下的ELF文件和Windows下的 PE格式。
PE格式是Windows系统下的可执行文件格式的一种(NE和LE格式是早期Windows使用的可执行文件格式) [8]。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结构,包含了导入导出表、调试信息、可重定位表等信息[9]。接在可选文件头之后,是各种不同类型的节,节之后是程序附加数据,一般打包程序可能用到该区域。PE可执行文件结构如图 2.1所示:
图 2.1 PE可执行文件结构
2.2 相关专业术语
2.2.1 反汇编
反汇编是将机器语言代码翻译成汇编语言代码的过程,按执行方式可以分为静态反汇编和动态反汇编[10]。静态反汇编是指不执行的方式的翻译过程,动态反汇编则需要跟踪执行目标文件,具体反映出程序运行情况。静态反汇编能够一次对整个可执行文件进行处理,而动态反汇编只能处理被执行到的部分,同时反汇编的时间与文件的长度成正比,而动态反汇编时间与被执行到的指令数成正比。通常前者时间消耗比后者少,静态反汇编效率比动态反汇编效率高。本文主要讨论静态反汇编的情况。
2.2.2 类布局和RTTI
C++语言在底层实现中借用了许多C语言中的优秀实现手段。为了实现跨平台,C/C++编译器生产厂商遵循简单内存布局原则,成员变量按照声明的顺序对其排列在内存中。在MSVC8及以上的版本中,可以使用编译程序cl.exe获取生成的类布局信息,按如下格式使用:
cl -d1 reportSingleClassLayout [classname] filename 查看单个类布局
cl -d1 reportAllClassLayout filename 查看该文件所有类及结构体布局
运行时类型信息(Run-Time Type Information)是一种由编译器生成的信息[11],用于支持dynamic_case<>和typeid()这样的操作符。RTTI是为带有虚函数的类(多态类)设计的。MSVC编译器会在虚函数表指针之前放置指针指向 “完整对象定位符(COL)”结构体,编译器可以通过该结构体根据虚函数表指针找到一个完整对象的位置。RTTI对于逆向分析函数类提供有价值的信息,可以从中恢复出类名和继承关系,甚至可以恢复出类布局。
2.2.3 虚函数和虚表
在C++语言中,虚函数是程序运行时刻才确定的函数,它会自动调用指针指向的真正对象对应的函数。调用的函数地址在编译时是无法确定的,只有在调用即将执行前确定,因此虚函数的调用通过间接调用实现。所有的虚函数地址都会存放在一个专用常全局数组——虚函数表中,而由带有虚函数的类实例化出的对象,总是带有虚函数表指针[12]。非派生对象仅有一个虚函数表指针,而多重继承可能有多个,同一个类实例化的对象之间共用虚表。纯虚函数是在抽象类中使用的,必须经过继承类重载后才可调用。
对于基类,虚函数地址按照虚函数声明顺序排序在虚表中,而派生类的重载函数会替换相应基类虚函数地址。通常虚函数表就是一个普通的数组。然而某些编译器是以链表组织的,虚函数表中每个元素含有指向下一个元素的指针,元素之间并不是紧密排列而是分散在文件中,这种情况较为少见。对于多重继承的派生类,虚函数表项可能存在指向转换程序(Thunk)的地址,该程序修改this指针以从父类虚函数表中调用虚函数,使其指向“替换函数”对象实例。这种技术是由C++语言开发者Bjarne Stroustrup提供的,他借用了Algol-60的早期实现形式,在Algol中修正this指针的代码称为形实转换程序(thunk),而调用本身称为“通过形实转换程序进行的调用”,这些术语至今在描述C++时使用。
2.2.4 API
API是Application Programming Interface应用程序接口的缩写形式,是构筑Windows程序的基石,下层是操作系统内核,上层是用户应用程序,应用程序甚至操作系统本身都要通过API完成特定的功能,Windows上几乎所有有实际功能的程序最终都会直接或间接地调用API,因此熟悉API调用甚至API汇编代码对于逆向分析和调试代码十分必要。Windows API按函数功能可以划分为:硬件与系统函数、控件与消息函数、菜单函数、进程和线程函数、绘图函数、打印函数、网络函数、文件处理函数、加密函数等。API按照系统层次可划分为用户级API和系统级API[13],结构如图2.2所示。
图2.2 API调用结构
2.2.5 Windows消息机制
Windows是消息机制驱动的系统,消息提供了应用程序之间、应用程序与系统之间的通信手段,应用程序实现的功能靠消息触发,并能够对该事件进行响应和处理,这些处理函数称为回调函数。Windows窗口程序主要是是事件驱动而非过程驱动,因此了解Windows消息机制极其必要。Windows消息机制如图 2.3所示,Windows系统中有两种消息队列,系统消息队列和应用程序消息队列。Windows为每个程序(严格的说是每个线程)维护一个消息队列,Windows检查系统消息队列消息的发生位置,如果位于某个应用程序窗口范围则将该消息派遣到应用程序消息队列中[14]。如果应用程序没有来取消息则消息就暂时保留在队列中,当程序中的消息执行到GetMessage时,控制权转移到GetMessage所在的USER32.DLL中,USER32.DLL从消息队列中取出一条消息并把这条消息返回应用程序。应用程序处理这条消息时,由于可能存在多个窗口,因此并不是直接调用自己的回调函数窗口过程,而是调用DispatchMessage函数通过系统找到给合适的窗口过程进行调用。窗口过程处理完毕后控制权返回到DispatchMessage,继续消息循环。应用程序之间或本身也可以发送消息,PostMessage将消息投递到某程序的消息队列中,而SendMessage则越过消息队列直接调用目标程序窗口过程,过程处理完毕后才返回。
2.2.6 MSVC和GCC
在本文中,MSVC是指微软Visual Studio中的C++编译器,它是Windows程序设计中使用最多的编译器,因此熟悉该编译器内部运作机制对Windows下的逆向分析十分必要,能够识别编译器自动生成的代码不但有助于快速定位程序员编写的用户代码,而且对于恢复软件层次架构也很有帮助。而GCC则是Unix/Linux系统的默认编译器,已经移植到Windows平台上。
图 2.3 Windows消息机制
2.3 在软件逆向工程中使用工具
2.3.1 在逆向工程中使用分析工具的必要性
逆向分析中最重要的工具包括静态分析工具、动态分析工具和其他辅助工具。动态分析工具主要功能是调试,以便高效分析软件的行为并验证静态分析结果,甚至找出软件缺陷和漏洞。由于操作系统提供了完善的调试API,因此利用各种调试工具可以非常方便的观察和控制目标软件,在调试过程中,使用者可以随意修改指令、寄存器、内存、设置运行断点,使程序一边运行一边分析[15]。而静态分析则是相对于动态分析而言的,在很多场合下不适合直接运行程序,例如软件的某一模块(无法单独运行)、病毒木马蠕虫程序、平台设备不兼容等。此时需要使用静态分析常用的反汇编软件将二进制可执行代码转换为汇编语言进行分析。
逆向工具的作用,也是其产生的目的,就是将逆向工作者从库函数分析、异常处理、反汇编这些重复的劳动中结果出来,而把主要精力放在程序的数据结构、算法、功能的分析中去,但是现今的逆向软件由于功能不够完善同时由于编译器的多样性和高度优化性,因此很多步骤仍需人工干预,交互式完成。
2.3.2 常见软件逆向工具简介
对于反汇编器,目前已经有很多比较好的反汇编软件产品,如国内的MC-Z80、DBJ-Z80系统软件,但它们主要是针对某种型号的产品而开发的,通用性和可移植性较差。国外著名的反汇编软件主要有C32Asm、W32Dasm、Hopper Disassmbler和IDA Pro等[16]。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初始化代码和内核,同时在调试过程中下载对应符号信息,方便理解程序。
在软件逆向中通常还需要其他多种类型的辅助工具,包括日志记录器、代码可视化分析、设计模式恢复、文档和图表生成工具等。
2.3.3 Intel指令结构
反汇编器是解析机器指令的,以x86平台Intel指令集Opcode为例,Intel指令手册中描述的指令由6部分构成[17],如表 2.1所示。前缀最多有4个,每个前缀1字节,不允许一个前缀重复2次,前缀分为:普通前缀(Prefixed)、指示性前缀(Maandatory Prefix)和64位扩展前缀(REX Predix)。前缀有4中,包括锁前缀(F0H)和重复前缀(REP系)、寄存器和地址超越前缀(66H,67H)、64位扩展前缀(REX)。
表 2.1 Intel 指令集结构
Instruction Prefixes Opcode Mode-REG-R/M SIB Dispacement Immediate
指令前缀
(可选) 指令操作码 操作数类型
(可选) 辅助Mode R/M,计算偏移地址(可选) 立即数
(可选)
每个1字节 1,2,3字节 1字节 1,2,4字节 1,2,4字节
Opcode为机器码中的操作符部分,用来说明指令语句执行什么操作,如某条汇编语句是MOV,JMP还是CALL。Opcode是汇编指令的主要部分,必不可少,对Opcode的解析也是反汇编引擎的主要工作。汇编指令助记符与Opcode大体上一一对应。
Mode-REG-R/M:是辅助Opcode解释汇编指令助记符后的操作数类型,R表示寄存器,M表示内存单元
SIB:辅助Mode-REG-R/M计算地址偏移,SIB的寻址方式为基址+变址,SIB占1字节大小,0,1,2位用于指定作为基址的寄存器,3,4,5位用于指定作为变址的寄存器,第6,7位用于指定乘数,由于只有2位因此可以表示4种乘数:1,2,4,8。如MOV EAX,DWORD PTR DS:[EBX+ECX*2]。
Displacement:辅助SIB,如MOV EAX,DWORD PTR DS:[EBX+ECX*2+3]这条指令的“+3”是由Displacement指定。
Immediate:立即数,用于解释指令语句中操作数为常量值的情况。
2.3.4 反汇编器工作原理
了解了指令集之后,需要对指令集机器码进行二进制到汇编语言的解析,解析所用到最著名的2种算法称为线性扫描和回溯遍历,是所有调试器和反汇编器的基础[18]。
线性扫描算法会按顺序逐个读取二进制字节并尝试匹配指令,流程如下:
Procedure LinearDisasm(addr)
1)判断addr是否在起始地址和结束地址范围内,如果不在则退出,否则转2。
2)将该地址处字节翻译成指令I,并加入结果指令集合中,同时返回指令长度length,转3。
3)将addr向后推进length个字节,转1进入序列中下一条指令。
该算法的优点是:由于扫描的是整个代码区域,因此能够在很大程度上识别每条指令。然而该算法不能区分数据和代码。由于算法会顺序获取字节转换成指令,因此代码中嵌入的数据也会被当做指令字节,遇到这种情况反汇编器无法翻译成正确指令且不断产生错误指令直到遇到无法与任何指令进行匹配的字节。此外,无法让反汇编器得知开始产生持续错误的位置。GNU实用程序objdump和许多链接时优化工具均采用该算法
线性扫描算法的主要缺点是没有利用二进制文件的控制流程信息。因此它无法避免地将代码中嵌入的数据错误解释为数据,产生错误指令。这样不仅会导致嵌入代码中的当前数据的翻译错误,后面接续的数据也会受到影响。为了避免误把数据解释为指令产生了回溯遍历算法,该算法采取了如下的控制流程:
Procedure RecursiveDisasm(int addr)
1)判断addr是否在起始地址和结束地址范围内,如果不在则退出,否则转2。
2)如果addr已经访问过则退出,否则转3。
3)将该地址处字节翻译成指令I并加入结果指令集合中,返回指令长度length,同时该地址处标记为已访问,转4。
4)如果I是一条分支指令则转5,否则转6。
5)对于I的每条分支,执行RecursiveDisasm(addr)转6回溯执行分支。
6)将addr向后推进length个字节,转1进入序列中的下一条指令。
无论反编译器何时遇到分支指令都会尝试从所有可能的分支地址处解析。理想的情况是,如果反编译器知道每个分支的准确目的地址,根据控制流程,反汇编器就可以遍历所有可能在运行时执行的代码,这样代码段会被换分成多个不内嵌数据的代码块。但如果目标地址在运行时是动态变化的,即间接分支指令的目标地址具有歧义性,此时反编译器无法通过静态方法获取该地址,也会导致翻译错误。因此,一个反汇编器可能会遗漏实际指令,如果猜测的地址有误,也会产生翻译错误。大量的二进制翻译和优化系统都使用该算法,例如UQBT翻译系统,在控制流图解析的研究中也采用了该方法,主流逆向工具反汇编算法如表 2.2所示。
表 2.2 主流逆向工具反汇编算法
OllyDbg 回溯遍历
NuMega SoftIce 线性扫描
Microsoft WinDbg 线性扫描
IDA Pro 回溯遍历
PEBrowse Professional 回溯遍历
2.3.5 利用静态工具进行分析
代码分析工具在进行软件分析是通过提取软件信息完成的。软件分析通常分为三类:静态分析、动态分析和历史分析。静态分析用于不需要执行的软件,动态分析用于分析执行痕迹或捕获运行行为,历史分析用于分析版本系统变化引起的软件变化[19]。
优秀的静态分析工具能支持多种编程语言元素特性、多种编译器内部处理机制、多种操作系统特性和平台不兼容代码,这意味着即使不能在当前平台进行运行、调试和测试,这种工具仍能进行代码分析,这类工具如TXL和SrcML;静态分析工具能解析出有价值的信息,有时并不需要构建一个完整的抽象语法树AST(Abstract Syntax Tree),而使用特殊的逆向工程方式获取这些信息,这类工具如Bauhaus和Columbus;静态分析工具能够提取程序语义,该功能通过多重逆向手段解析出更多的AST信息,这类工具如CodeSurfer[20]。静态工具速度较快,然而在处理指针、多态和动态类型等情形时静态分析会难于进行,另外对于用户交互和对象之间数据交换的分析也是静态分析所不擅长的,而动态分析却适合处理这些情况。
第三章 静态逆向分析模型
逆向分析的目标是理解一个系统中的软件以便更容易地进行增强功能、更正、增加文档、再设计或者用其他的程序设计语言再编码。逆向工程工具应支持产生程序的高层抽象,使维护者更容易理解程序,重用旧代码以及准确加入新功能,避免死码的产生。
在一定规则下进行逆向分析会提高逆向分析的效率,下面就来介绍这样的规则。本章提出的逆向框架其中涉及的每一部分都在会在本章中和之后的章节中进行详细介绍。
3.1 使用IDA进行静态逆向分析的各个阶段
图 3.1 静态逆向分析总体模型
如图 3.1所示,该模型分为预处理模块、函数识别模块、类识别模块、异常处理识别模块、综合分析模块。预处理模块对二进制可执行代码进行反汇编和初步分析,去除软件保护机制,初步识别PE文件格式、资源及导入导出表、程序入口等信息。函数识别模块用于识别系统库函数和用户函数,包括对变量、表达式、语句、函数传参调用和函数执行流程的分析。类识别模块用于识别存在于二进制文件中的类布局、对象结构、RTTI信息、this指针的使用,从而分析出成员变量和成员函数,推导出原始的类结构。异常处理模块用于识别二进制可执行代码中存在的异常处理信息,包括SEH和C++异常。综合分析模块对上述三个模块的处理结果进行综合分析,推导出二进制可执行代码对应的源代码、软件架构、算法、设计模式和文档,具体过程见图 3.2所示详细逆向模型。
3.2 分析前处理
3.2.1 去除保护机制
现代软件倾向于打包(添加保护机制),经过打包以后实际执行的代码会被加密和压缩保护,降低了汇编代码可读性。
图 3.2 静态逆向分析详细模型
为了便于分析试剂执行代码,去除程序保护,需要先用外壳探测程序获得目标程序所用保护类型,然后针对该类型使用解包器、破解、脱壳、调试、内存转储等多种手段和工具将实际执行的代码剥离出来,对于打包器或加壳工具对原程序资源和导入表造成的破坏还应使用相关恢复工具进行恢复[21]。另外,分析前处理的另一个重要作用是识别程序的编写语言,这有助于对程序内用到的编程语言相关的库函数的签名识别。
一般地手工分析程序编写语言可以通过剖析入口点特征,这个步骤和查壳工具原理类似,能快速定位编写语言甚至编写库但并不通用,因为无保护机制的程序入口点也有可能是只是一层语言外壳,更通用的方式是使用IDA等专业工具的库函数签名机制。
3.2.2 分析程序中用到的函数
在Windows中,代码共享是进程通信的核心思想,用户程序不能直接控制硬件,也不能直接和Windows内核通信,Windows提供了各种功能的dll(动态链接库),这些dll的输出函数可以为用户程序提供内核服务,用户进程会经常调用API实现特定功能,因此了解API是必要的。通常使用PE导入表查看静态加载的API,字符串常量域结合动态加载函数API(GetProcAddress)以得到动态加载的API。
3.2.3 分析程序中的资源
PE格式常见的资源包括位图、加速键、光标、对话框、图标、菜单、字符串表、工具条和自定义资源,分析PE资源可以通过调试技术快速定位到程序执行流程的关键点,其中字符串所包含的信息较为敏感,通过观察字符串和程序执行逻辑的变化,有助于快速定位到关键代码。对于有界面框架的程序,对话框和菜单有着特殊的重要性。例如MFC工程中,根据菜单和对话框操作相关API,以及对话框中子控件资源属性(主要是ID)和菜单子项ID,可以很容易地分析出当前代码所产生的行为。导入和导出函数可以用于方便地定位程序主干代码,便于理清执行逻辑。
3.2.4 分析入口函数
程序的入口点是最先执行的代码位置,很多初始化工作都会在其中进行。程序入口分为真正入口和用户入口,以MSVC为例,应用程序的真正入口点并不是main/WinMain/DllMain及其宽字符形式(w-)(这部分属于用户入口),这些函数仅仅是真正入口点(如start)所执行的一个可由用户重载和自定义的函数而已[22]。对入口函数启动部分流程和库函数使用的分析,可以进一步确定程序相关的编程语言信息和使用库函数信息。函数入口在开始执行用户入口函数之前所做的操作有:获取平台版本、初始化堆空间、初始化命令行参数、初始化环境变量、初始化全局数据和浮点寄存器等,可以通过该流程和用户入口函数的参数类型,找到用户入口函数。同时,在所有用户函数执行完之后,程序并没有结束运行,而是继续执行一些清理工作,例如exit和atexit函数,最后调用API终结进程,这部分也是在库函数代码中实现的。
3.2.5 识别库函数
现代程序中早已融入模块设计概念,程序中充斥着各种系统库函数、第三方函数和组件,接口设计方法广泛应用于软件领域,因此对于程序的分析不可避免会遇到库函数,而且通常这些库函数代码量会比用户实际编写代码量多出几倍(平均起来库函数在程序代码中所占的比重为50%到90%,特别是利用了可视化开发环境自动生成代码功能),由于数据量巨大,分析时间漫长,且库函数常常比简单的程序代码更加复杂而难以理解,因此不适合直接手动分析,因而程序分析软件应该能提供一种较为智能的方式自动识别这些函数,而把逆向分析者主要精力快速集中在用户代码的分析上。作为逆向工作者,也应该熟悉常用库函数的汇编级模板,这样就可以在代码分析工具由于库函数升级或高度优化导致无法正常识别的库函数的情况下不影响逆向速度。
IDA使用了一种高效的方式,使用二叉树形式组织的标准库检索字节序列,这种搜索方式的时间复杂度是O(logn),对于大多数情况使用函数开头的32字节足以准确识别[23]。在识别操作正确性方面,许多函数结束位置处于二叉检索树相同的叶节点,这会导致识别过程出现冲突或二义性,这也是在制作IDA的sig(特征标志库)文件经常发生冲突的原因。为了减少错误,IDA通过启动代码识别编译器程序并加载相应的库文件,同时允许用户手工加载这些特征标志库文件。在程序链接时编译器常以用户OBJ模块与函数库的列表顺序分配函数,所以很多情况下代码区中的库函数段和用户代码段之间会有明显的分隔。
很多函数库包含了开发商信息和库版本的版权内容,这为识别编译器类型和版本带来了很大便利,只需要找到相应文本字串片段即可。特殊函数库带有某些特征也可以用来识别编译器,例如调用的Windows API种类(用于文件、内存、图形、网络、加解密、硬件等)、数学函数一般含有丰富的协处理器指令等。另外可以利用参数和常量等信息进行推断,如函数接受浮点参数,那么极有可能来自于某个数学函数库。最后,算法的识别也会有助于识别库函数。
第四章 C语言元素分析
本章对常见数据类型、表达式、语句、函数结构等基本的C语言元素进行语法分析和底层实现分析;对函数栈结构的函数序言和函数结语结构进行剖析;根据内存变量使用情况提出了一种识别变量生命周期的方法;基于函数栈帧原理提出了一种切实可行的函数边界检测方法,该方法可以准确定位特定高级语言函数的机器码范围。
4.1 识别变量
4.1.1 识别栈变量
在高级语言代码中如果显式声明过自动类型变量,则通常都会在该函数栈中开辟对应空间划分变量空间,一种划分方式是将指令sub esp,xxx放于函数入口附近,而相对的add esp,xxx放于函数结束处附近。而在使用这些变量时,也相应会用ebp做间接寻址。MSVC中常使用ebp的正偏移做栈变量寻址,而Borland和其他编译器则常用使用负偏移。作为参数的变量由于传递之前会被压栈,因此只要在函数体内计算出当前栈顶指针和栈底指针位置,就可以选择一个栈寄存器进行间接寻址获取参数。在存在函数序言的情况下,由于调用函数时的call func指令和函数内push指令的2次压栈操作,因此第一个参数是从ebp+8位置开始的,后面的参数按4字节大小向高地址递推,可以看出参数寻址的相对偏移量为正,与此相反,函数内栈变量则是以ebp负偏移量进行寻址。特殊情况下,编译器可能对栈变量进行优化,而将一些无用的参数栈位置作为栈变量使用。根据这些特性和函数中引用局部变量和参数的指令可以恢复相应的函数栈。函数内部经常会对栈指针进行调整,这时参数和变量偏移位置就需要经常重新分析。
4.1.2 识别堆变量
堆变量是所有变量中最容易识别的一种类型。在C\C++中通常使用malloc和new操作符实现堆空间的申请,返回的数据是堆地址,该地址对于整个进程有效。相应的使用free和delete释放该地址处申请空间。在Windows下申请堆空间在程序结束前都需要调用释放堆空间的API函数,否则会造成内存泄露。
4.1.3 寄存器变量和临时变量
为了使访问内存频率尽可能低,编译器的高级优化功能会把使用频率最多的局部变量存在通用寄存器中。C/C++语言中,register关键字用来向编译器请求分配在寄存器中,由编译器选择最佳代码处理方式。寄存器变量可以由PUSH指令临时存放在栈中,并由POP指令弹出堆,寄存器变量不会通过EBP寄存器进行寻址。
临时变量的产生是编译器根据实际需要产生的变量,临时变量产生的原因有下面三个可能:
对于复杂语句表达式,在上个指令完成之后存储操作结果,并用于下面的指令,该操作结果可能存储为临时变量。
在移动数据时产生临时变量。由于80x86处理器不支持直接从内存到内存的数据传输,因此内存间的变量赋值需要借助于临时寄存器变量。
作为存储函数返回值。绝大多数高级语言(包括C/C++)允许将函数调用作为表达式的一部分。
4.1.4 识别全局变量和静态变量
分析程序的算法的关键一步就需要透彻地分析整个反汇编代码,并搜索出所有的交叉引用,由于全局变量通过直接寻址,因此在高级语言中识别全局变量相对容易。IDA中可以对符号进行交叉引用检索,这个特点大大提高了分析代码的效率,然而在一些情况下IDA并不能很好地识别这些符号,因此需要以手工方式进行交叉引用的重建。静态变量和全局变量相似,只是作用域不同,静态变量在编译级别只能在定义的作用域内使用,在汇编级别的表现是集中于某个函数代码域,而全局变量可以在多处使用。全局变量需要在主函数执行前初始化,在程序退出前执行清理工作(析构),而和静态变量在首次使用时初始化,并设置某个标志位,注册退出函数,在下次执行到该处时跳过初始化,在程序退出时执行清理工作。MSVC通过生成初始化函数段实现这种初始化功能,这些初始化例程地址会存入一个表中,在程序启动后由运行库函数_cinit进行处理。该表常位于.data段起始处。
4.2 识别特殊类型
4.2.1 识别字符串
IDA可以识别多种格式的字符串,包括c型(结束符’\0’)、dos型(结束符’$’)、pascal型(长度域1字节)、宽pascal型(长度域2字节)、delphi型(长度域4字节)、unicode等类型。Windows程序常见的类型是c型和unicode,因此识别正常的字符串并没有困难,但是如果字符串采用了加密技术转换成不明确的数字,情况就会变得复杂。这时候首先应该使用交叉引用功能查看数据段里的数据被哪些代码所引用,如果被引用则对该处代码进行分析,进行算法逆向还原出字符串。
对于自动检测程序中的字符串有许多的识别字符串的算法可用,都是基于如下三点:
字符串是有限字符集和,字符是指数字、字母、符号和诸如列表框和回车符之类的控制字符。
字符串至少包含2个以上的字符。
确定字符串类型,不同类型字符串边界计算方式不同。
MSVC编译器在初始化栈上字符串变量时,通常按机器字长为步长对数据和栈区进行划分,将数据分别拷贝到目的栈区。
4.2.2 识别结构体
如果在汇编代码中发现对某处内存附近进行连续读写且读写指令相距较近,则该处可能为结构体实例一部分。存在于堆空间的结构体大小一般可以从动态分配空间大小推断出来;存在于栈空间的结构体识别,则需要结合两种分析方式。第一种方式是采用累积法,如果发现内存连续赋值且这种行为在多处反汇编代码中出现就将该偏移处元素其加入结构体。第二种方式是反推法,对于栈结构体,分析当前整个函数栈大小和所有其他栈变量边界范围,反推出该结构体范围。如果从API调用参数或者从其他分析过的有关联的函数中已经得知该结构体类型,则可以简化分析。
表 4.1 运算类型指令对照表
运算符 用到的指令 运算类型
加法 add 算术运算
减法 sub 算术运算
乘法 imul/mul/shl/fmulx等 算术运算
除法/取余 idiv/div/fdivx/imul/mul/sar等 算数运算
自增 add/inc 算术运算
自减 sub/dec 算数运算
等于/不等/大于等于/小于等于/大于/小于 跳转指令/cmp/test等 关系运算
或/与/非 跳转指令等 逻辑运算
问号表达式 跳转指令/cmp等 关系运算
移位 shl/sar 位运算
位或 or 位运算
位与 and 位运算
异或 xor 位运算
同或 not 位运算
4.3 识别语句
4.3.1 识别表达式
普通表达式会包括算数运算、逻辑运算、关系运算、位运算等形式的语言元素,相应指令对照表如表 4.1所示。
对于复杂表达式,编译器首先会对复合条件根据内定的计算顺序进行语法分析和表达式分解,拆分成多个体现基本操作之间相互关系的简单条件作为中间形式,之后使用goto语句替换条件语句。编译器在分析时采用逻辑二叉树结构表示复杂条件的分解过程。在逻辑树分支较多时,会对逻辑树做修剪操作,优化逻辑树结构,通过对条件进行取反而剪除多余树枝并删除子树所有标号,通过合并和修剪枝干的分支优化理清逻辑关系。图4.1为编译器对特定表达式
((a == b) || (a == c)&&(a != 0))的解析过程。
图4.1 表达式解析过程
4.3.2 识别循环语句
循环语句是一种常见程序设计逻辑,C++循环语句主要包括for循环、while循环、do-while循环三种形式,每种循环有着不同的执行流程:do循环先执行循环体后比较判断,while先比较判断后执行循环体,for先初始化再比较判断最后执行循环体。
1)for循环语句可以抽象成下面的一般语法形式:
for(statement1;condition;statement2) {statement3;}
编译之后可以转化为如下汇编代码形式:
call statement1;
jmp judge
change:
call statement2;
judge:
call condition;
jz end;
call statement3;
jmp change;
end:…
2)while循环语句可以抽象成一般语法形式:
while(condition) {statement;}
编译以后可以转化为如下汇编代码形式,可以看出形式上较for循环要简单:
judge:
call condition;
jz end;
call statement;
jmp judge;
end:…
3)do循环语句可以抽象成一般语法形式:do{statement;} while(condition);编译结果可以转换为如下伪代码,可以看出形式上较while循环简单:
begin:
call statement;
call condition;
jnz begin;
通常编译器在优化的时候,while和for循环会近似优化为效率更高的do循环。对于continue语句,continue执行后立即将控制权传递给检查条件代码,一般地在带有前置条件的循环中,该语句会编译成一条向上方定位的无条件跳转指令;而在后置条件循环中,该语句则被编译成一条向下方定位的无条件跳转指令,continue之后的当前域语句不可执行。
4.3.3 识别分支语句
C语言分支语句包括if-else语句、if-else if-else语句、switch-case-default语句,分支是任何程序设计语言的核心内容,因此正确识别它们是极其重要的。
1)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:…
2)switch语句
在Windows程序设计中经常在多信息码的情况下会用到switch语句,例如消息回调、错误处理、网络状态、驱动派遣等实现代码,是比较常用的多分支结构,效率上也高于if分支结构。这种分支语句如果不经优化,表示成逻辑树由于分支数较多、深度较大、效率较低,表现为“一边倒”,因此通常会在编译时通过分叉算法进行优化和平衡,在这种算法中编译器会根据需要改变case分支语句的处理顺序,进行压缩处理,降低逻辑树深度,加快索引速度。编译器需要找到合适的值使每个节点的左右子树深度达到基本平衡,经过平衡逻辑二叉树,最大比较深度从o(n)降为o(logn)。经过编译器优化的switch语句提高了执行性能,也提高了逆向分析的复杂度。在逆向分析switch代码时只需要将相等判断的语句提取出来即可恢复switch语句。
在switch分支数小于4的情况下,MSVC采用模拟if-else if 的方法,而当分支数大于4且case判定值存在明显线性关系组合时,编译器会采用语句块地址表或语句块索引表进行优化。若case判定值无明显线性关系则编译器会采用类似上面二叉判断树的方式实现。总体上说,编译器处理case有几步:首先对所有case值按大小排序,然后划分出近似线性段和相对非线性段,对每个线性段分配静态索引地址数组建立跳转表以加快索引速度,对于非线性段则使用原始if-else型判断加以翻译[24]。
4.4 识别用户函数
函数和堆栈是密不可分的:参数通过栈传递给函数;函数内部通过划分栈区分配栈变量。
4.4.1 函数序言(prolog)和函数结语(epilog)
1)32位的情况
对于32位程序下,如果未经编译器优化,便可能生成函数序言和函数结语,它们的作用分别是保护现场和恢复现场。函数序言部分一般出现在函数的开始,32位函数中标准的函数序言代码如下[25]:
push ebp;保存ebp寄存器
mov ebp,esp;设置栈帧指针
sub esp,localbytes;在栈内存中分配局部变量空间
push <registers>;保存寄存器
其中localbytes变量表示局部变量栈上所需要分配的字节数,<register>变量表示要保存在栈上的寄存器列表,这些寄存器压入栈后,便可以在函数中使用。
函数结语部分一般出现在函数的结尾,通常只有一个函数序言,而函数结语可能有多个,32位函数中标准的函数结语代码如下:
pop <registers>;恢复寄存器
mov esp,ebp;恢复栈指针
pop ebp;恢复ebp
ret;函数返回
在MSVC中如果使用naked关键字修饰函数,编译器会省略函数序言和函数结语部分。
2)64位的情况
64位程序中有两种函数类型,需要栈帧的函数称为帧函数,不需要的称为叶函数。在帧函数中任何需要分配栈空间、调用了其他函数、保存非易失性寄存器或者使用了异常处理的函数都必须有函数序言和函数结语,此外,帧函数还需要一个函数表项。函数序言所做的操作有:必要时将参数寄存器保存在内部栈中、将非易失性寄存器入栈、为局部变量和临时变量分配固定的栈空间、设置栈指针。对于栈中分配的固定空间超过一页(大于4096字节)的情况,栈空间的分配范围可能超过一个虚拟内存页,因此实际分配前需要检查分配情况。编译器会为此提供一个特殊的例程用于保护参数寄存器,供函数序言调用。64位程序函数序言的典型代码为:
mov [rsp+8],rcx;存储rcx
push r15;保存非易失性寄存器
push r14;
push r13;
sub rsp,fixed-allocation-size;为局部变量分配固定大小的栈空间
lea r13,128[rsp];建立栈指针
4.4.2 识别函数参数栈
函数传递参数的方式有3种:堆栈方式、寄存器方式和同时使用堆栈与寄存器的方式,一般来说传参的类型,无论是普通类型变量、结构体、类,都会拆分成固定长度(4字节)压栈,而浮点数则可以通过寄存器拆分式压栈也可以通过浮点寄存器压栈传参。
1)32位程序常见调用约定[26]
_cdecl:C/C++函数默认调用约定,栈由调用者清理,因此函数参数个数可以是变参,同时由于每次调用结束,调用者都要平衡堆栈,因此调用时产生的代码量教_stdcall多。
_stdcall:Windows API函数使用的调用约定,栈由被调用者清理,因此无法使用变参,由于恢复栈的代码存在于API实现中,因此调用时产生的代码量较_cdecl少
_fastcall:该调用约定指定传递的参数尽可能使用寄存器,仅适用于x86体系结构。由于使用了寄存器,因此这种调用约定函数执行效率较高。无法使用变参
_thiscall:该约定是类成员函数的默认调用约定,不能使用变参,由被调用者清理堆栈,this指针通常作为隐藏参数传递。
2)64位程序调用约定[27]
64位平台下编译器只使用新型_fastcall调用约定,其主要特性如下:
①前四个整形或指针类型参数由4个通用寄存器R8, R9, RCX, RDX依次传递,前四个浮点类型参数由4个浮点寄存器XMM0,XMM1,XMM2,XMM3传递。
②除前四个参数以外的参数通过栈来传递,从右至左依次入栈。
③由调用函数负责清理调用栈。
④小于等于64位的整形或指针类型返回值由RAX寄存器传递。
4.4.3 识别函数栈变量
栈变量总是在某个函数的范围内起作用,一个函数从生命周期开始到结束,整个函数内无论在哪个作用域申请的局部非静态变量,均是栈变量,通常情况下所有栈变量空间的总和均在函数头部(可能位于函数序言)一次分配。esp寄存器为栈顶寄存器,指向当前栈的起始地址,压栈操作会改变该寄存器值,ebp寄存器为栈底寄存器,指向当前栈的结束地址,用来保存和恢复函数栈帧。esp和ebp常用来取得栈变量,在一个函数中,栈顶经常会发生变化,而栈底相对不变。当发生函数调用时,控制权从一个函数进入另一个函数,就会针对该函数开辟出所需栈空间;当一个函数结束时,需要清除使用的栈空间,关闭栈帧,这一过程称为栈平衡,在MSVC的调试版程序中,会有库函数__chkesp专门检测函数调用之后的栈平衡。栈帧中可以寻址的数据有局部变量、函数返回地址、函数参数等。
分析栈变量生命周期:高级语言中,变量均有作用域,所谓作用域是指变量在源码中可以被访问到的范围。全局变量属于进程作用域,在整个进程中均可访问,静态变量属于文件作用域,在当前文件中可以访问。局部变量属于代码块作用域,从定义开始的代码块范围内可以访问,该代码块可以是整个函数、循环体中、甚至花括号内。由于所有局部变量均处于函数栈中,而汇编级别不存在高级语言的作用域,而在整个函数范围内均可访问,因此如果需要还原局部变量在高级语言的范围,可以分析引用该变量的代码块,例如仅仅在循环体中引用到该变量而其余代码未涉及,则可认为该变量作用域为该循环体。以上讨论的是非静态局部变量的情况,对于静态局部变量,其作用域也处于代码块中,然而在汇编级别,由于需要保持相对不变,因此位于全局变量区,而不存在栈中。因此静态局部变量的查找方式和作用域类似全局变量。
4.4.4 识别函数返回值类型
对于普通类型返回,编译器会根据函数返回类型大小不同进行不同的操作。表 4.2展示了常见的32位和64位程序的返回值情况。
表 4.2 程序返回值类型
返回长度 返回方式 返回类型
1字节 AL寄存器 按值返回
2字节 AX寄存器 按值返回
4字节 EAX寄存器 按值返回
8字节 EDX:EAX寄存器 按值返回
浮点型 协处理器堆栈或者EAX寄存器 按引用/值返回
双精度型 协处理器堆栈或者EDX:EAX寄存器 按引用/值返回
近指针 EAX寄存器 按值返回
3字节/5字节/6字节/7字节或多于8字节 引用方式的隐含参数 按引用返回
4.4.5 判定函数边界
在一些时候IDA采用反汇编算法会产生函数范围误判、无法识别甚至和数据混淆的情况,对于这种情况需要进行人工干预。对于函数起始位置和终止位置的判断可以采用以下方式进行试探。
1)对于起始位置,若出现下面情况之一,则需要试探该处是否为新函数起始位置:
该位置处为函数序言。
该位置处为某个远跳转jmp指令或call指令的操作数。
该位置邻接于2个函数之间的未识别区域。
该位置处于代码区未识别区域且之前为对齐字节。
2)对于结束位置,若出现下面情况之一,则需要试探该处是否为当前函数的结束位置:
该位置处为函数结语且之后不存在函数结语。
该位置之后临接某函数起始位置。
第五章 分析程序中的C++语言元素
C++机制是在C语言基础上构建的,所以在实现时借助了C语言的实现方法。下面两个等式从本质上形象地描述了C++语言的主要元素构成方式:
类=数据结构+方法
对象=分配的内存+数据+方法
本章介绍了C++区别于C语言的高级语言特性和实现原理,并提出了切实可行的恢复方法,其中包括对new和delete操作符的实现原理进行了分析;对一般类类型的对象内存布局原理进行总结;基于内存对象布局理论提出了一种恢复类结构(包括成员变量和成员函数)的方法,该方法可以用于重建类和识别程序中创建的对象;分析了SEH实现机制和32/64位Windows程序异常处理结构;基于SEH实现机制、C++异常处理底层实现原理和RTTI设计原理提出了Windows程序异常处理语句恢复方法,该方法支持32/64位Windows程序中异常处理语句的恢复。
5.1 识别new和delete操作符
MSVC的new操作符在编译时是以库函数和类构造函数实现的,内部调用operator new函数,该函数接受一个参数,为申请的空间大小(对象大小),operator new函数会调用malloc函数,而malloc函数调用Windows API函数HeapAlloc,返回分配指针。在代码中放置了new函数以后,为防止内存分配失败,二进制代码中首先会检测该地址是否为空,若为空则直接返回空对象,否则使用该地址作为this指针,传递给类构造函数执行,若构造函数执行成功,则会将地址返回。编译器在遇到new和delelte操作符时,会将它们转化成函数调用。
类似的,delete操作符是以库函数和类析构函数实现的,编译代码首先使用this指针执行类析构函数之后执行delete函数。delete函数接受一个地址参数,最终调用Windows API函数HeapFree实现分配内存释放。
5.2 识别类
类是C++面向对象机制的基础,因此了解编译器对类的处理机制十分重要。类布局由虚函数表(简称虚表)、虚基类表(简称虚基表)、成员变量构成。
5.2.1 编译器对类及类实例的处理行为
类成员变量通常按照声明顺序在内存中分配,和类的成员变量域相同的结构体仅仅是没有成员函数。下面以包含2个成员变量a1,a2的基类A为例说明编译器通常在实现中采用的类布局[10]。
简单继承中,派生类的成员变量在内存中的位置位于基类成员变量之后,这是大多数知名C++厂商采用的内存安排,这样的好处是,派生类获取基类指针时不需要计算偏移量,因为派生类对象地址同时基类指针。在单继承类层次下,每个新派生类都简单地把成员变量添加到基类成员变量之后,如果派生类既不重写也不增加新的虚函数,那么父类虚表可以重用。以类B为例,B继承于A且有一个b3成员变量,类布局如图5.1所示。
大多数情况下简单继承对于编程已经足够,然而C++为特殊原因也支持多重继承,如果当前对象同时兼有多个互斥对象的特性,需要对多个基类做交集,这时候要使用多重继承。内存中的布局是基类在先,派生类在后,与单继承相同的是,类C拷贝了类A和类B的所有数据,不同的是,类C的指针和类A相同和类B不同。以类C为例,C依次继承于A和B,且有一个c4成员变量,类布局如图 5.2所示。
图 5.1 简单继承类布局
图 5.2 多重继承类布局
图 5.3 虚继承类布局
在多重继承中,若派生类继承的基类也继承于同一个原始基类,如果该原始类成员较多,经过拷贝后每个基类都会含有相同的成员,而派生类进行继承就会产生较大资源浪费和内存开销,同时实例中本来相同的成员可以分别进行修改而不是共享关系造成数据不一致,为了解决这个问题出现了虚继承,在虚继承中继承的相同成员变量是共享关系,只有一份实例。虚继承中,虚基类的相对位置是不固定的,可能会根据派生类而不同,虚继承类布局如图 5.3上半部分所示。
编译器需要跟踪每个继承的虚基类的基址偏移,这部分在MSVC通过生成虚基类表vbtable实现从而实现间接计算虚基类位置的目的,该表存储的是相对该类的每个虚基类表指针与虚基类之间的偏移量,而GCC做法也较为相似,它会将该偏移存放在虚函数表(vftable)中,也就是说MSVC中的虚函数和虚基类使用的是不同的表(虚表和虚基表),而GCC中则是都写在虚函数表中的。以类D和类E为例,D虚继承于A,且有一个成员d5,E依次虚继承于A、继承于B,且有一个成员e6,类布局和相关的数据结构如图 5.3下半部分所示。
MSVC系列编译器与GCC系列编译器在虚继承上的不同实现如表5.1所示:
表 5.1 MSVC和GCC在虚继承的不同
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系列编译器中成立[28]:
1)首先排列非虚继承的基类实例,如果该基类存在重复继承的成分会将这些成员舍去。
2)有虚基类时,为每个基类增加一个隐藏的vbptr,除非已经从非虚继承的类那里继承了一个vbptr。
3)排列派生类的新数据成员。
4)最后排列每个虚基类的一个实例。
菱形继承是另一类较为复杂的对象结构,会将单一继承和多重继承进行组合,因此菱形继承可以很好地用来观察类布局。假设类A为基类,有成员a1,x,类B和类C分别虚继承于类A,同时类B和类C各有成员b1,x和c1,类D依次继承于类B和类C,且有成员d1,x。那么类布局如图 5.4所示:
图 5.4 菱形继承类布局
5.2.2 识别RTTI信息
RTTI存储了丰富的类型,可以给逆向分析C++的类结构带来极大帮助,同时如果使用了面向对象的异常处理机制,编译器也会产生相应的RTTI信息。可见了解RTTI结构十分必要。对于有虚函数的类,其布局中会产生虚表,而虚表所在地址之前的一个机器字长大小的元素,存放着该类的一种称为“RTTI完全对象定位定位符”(RTTI Complete Object Locator)的结构体指针[29]。它是一种用于描述类继承关系的结构体。该结构包含两个指针,该结构如下:
+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结构,从这些结构很容易确定出所有类之间的关系。
5.2.3 识别不同类型的对象
对象也有作用域,不同作用域的对象生命周期不同,因此构造函数被调用的时机也不同,如果可以从二进制代码中分析出对象构造函数和析构函数的调用时机,那么就可以推知该对象的作用域类型以及生命周期。对象按作用域类型可以分为下面几种:
局部对象:和栈变量类似,栈变量均在函数入口处统一分配空间,对象也相同,然而对象的构造函数是在作用域(块)开始位置调用的,析构函数是在作用域(块)结束位置调用的。识别局部对象的构造函数的必要条件有两个:该函数是这个对象调用的第一个函数;该函数返回this指针。
堆对象:和堆变量类似,要点在于堆空间的申请、使用和释放。C++中对象的堆空间申请使用new操作符,在Windows环境下,编译器解析new操作符时先对所要new的对象进行sizeof操作得到对应退化的结构体大小,之后将该参数传递给operator new函数执行,该函数接受一个表示申请空间大小的整形参数,new函数中会建立一些结构体以方便动态内存的管理,最终会调用Windows API函数HeapAlloc申请系统堆空间,在此时HeapAlloc接受的申请大小已不再是new函数所传入的大小,而是经过附加结构体和进行内存对齐之后的新大小,因此识别库函数new十分关键。相应地,堆对象的释放使用delete操作符,最终使用Windows API函数HeapFree释放申请空间。
参数对象:对于对象作为参数传递的情况,是一种局部对象的特殊情况,编译器会在默认情况下调用拷贝构造函数(该拷贝构造函数接受一个参数为对象引用)构造出一个作用域为子函数的新对象传参,同时将该新对象析构函数调用设置在子函数结束处,因为构造形式产生的类进行传递从语法上无法供父函数其它地方使用。在传递时,对象会退化为结构体方式传递,该结构体如前所述,主要包含虚表指针和成员变量域,函数体内所引用的this指针就是每个结构体在栈上的首字节位置。
返回对象:对于函数返回对象的情况,也是局部对象的特殊情况,可以看做返回结构体的操作,如果该对象在函数体内声明并返回,那么在子函数结束处会执行析构函数回收对象,此时该对象在不应该在函数外使用。而对于通过构造函数直接返回对象或返回对象引用的情况,在这种情况下的返回对象在父函数引用,同样会调用拷贝构造函数构造出一个作用域为父函数的临时对象,因此编译器会在父函数结束前调用前析构函数。
全局对象和静态对象:二者构造时机相同,程序中所有全局对象在同一处统一初始化,对于MSVC,此位置位于_cinit的_initterm这个运行库函数中。可见只要找到该函数所要处理的构造函数地址,就可以找到全局对象和静态对象。相似地,全局对象和静态对象的析构函数处理位于atexit函数中。
5.2.4 识别类构造函数和析构函数
构造函数和析构函数是类的重要组成部分。类构造函数和析构函数都是可选的。构造函数在类实例化对象时分配空间之后自动调用,是对象第一个被调用的函数,用来初始化类,在高级语言语法中,构造函数是禁止设置返回类型的,然而在汇编级别的实现中,总是返回传进来的this指针,并可以接受多个参数。根据C++标准,构造函数不自动激活异常,即使对象内存分配失败。大多数编译器在调用构造函数之前会放置检查空指针的代码,内存分配成功后,才会执行构造函数,而对象的其它函数即使在内存分配不成功的情况下也会被调用,而如果此时this指针为空,那么对象首次调用的非构造函数将可能触发一个异常。根据上述原理可知,以检查空指针代码作为函数结尾的函数可能是构造函数。在最坏情况下,构造函数会依次执行如下操作:
1)对于最终派生类,初始化vbptr成员变量,调用虚基类构造函数(递归过程)
2)调用非虚基类构造函数
3)若存在虚函数则初始化虚表
4)调用成员变量的构造函数
5)执行初始化的一系列操作,包括虚函数表成员变量
6)执行构造函数的列表初始化元素
7)执行构造函数体和用户初始化代码
在编译器执行代码优化后,上面的步骤可能顺序会被打乱,并且有些函数进行了内联操作(例如构造函数和析构函数)。
对于全局对象,其构造过程在启动代码中实现。一般的方式是使用编译器生成的函数表调用构造函数,构造函数的内存存于数据段,在这个步骤中编译器会设法在程序结束前调用类析构函数,MSVC会将析构函数添加到atexit()回调中,而GCC则会使用一种析构函数表(全局对象)完成该操作。
对于对象数组的构造,对象数组的每个元素会分别创建,如果任何元素的构造函数抛出异常,所有前面构造的元素都会析构,数组析构时,每个元素都要正确释放,即使数组大小不能确定也必须成功完成该操作。在这个过程中MSVC使用了一种称为向量构造迭代器(vector constructor iterator)的辅助函数完成该操作。
析构函数和构造函数相似,但是是无参函数,C++规定只在内存分配成功并且创建了对象的情况下才调用析构函数,因此析构函数代码中也会放置检查空指针代码。MSVC编译器会自动生成异常处理结构以保证异常发生时对象可以被销毁。
与构造函数不同的是,类只能有一个析构函数,而可能有多个重载的构造函数,而析构函数一般设置为虚函数以实现资源自动回收释放机制,因此构造函数不会出现在虚表中,而析构函数往往出现在虚表中。析构函数执行的操作和构造函数刚好相反,在最坏情况下析构函数会依次执行如下操作:
如果有虚函数则初始化虚虚函数表指针及成员变量(这样操作以后函数体里的虚函数调用会使用当前类的方法)
1)执行析构函数体中,程序定义的其他析构代码
2)调用成员变量的析构函数(与构造顺序相反)
3)调用直接非虚基类的析构函数(与构造顺序相反)
4)对于最终派生类,调用虚基类析构函数(与构造顺序相反)
由于简单的析构函数可能会在编译器优化期间内联,因此经常可以在汇编代码中见到虚表指针在一个函数多次加载的情况。在MSVC中有虚基类的类构造函数接受一个隐藏的“最终派生类”标志决定虚基类是否需要初始化。MSVC采用分层析构模型,在析构代码中加入了一个隐藏的析构函数用于析构包含虚基类的类(对于“最终派生类”而言);代码中再加入另一个虚构函数用于析构不包含虚基类的类同时前者调用后者。
对于不同继承类,虚析构函数可能结构不同,编译器需要保证在不知道指针类型的情况下进行正确的操作,因此MSVC使用了一种辅助函数(deleting析构函数)存放在虚表中替代实际析构函数,它会调用实际的析构函数,然后执行delete操作。而GCC则使用了多重析构函数(in-charge、not-in-charge和incharge-deleting函数),并通过调用相应的多重析构函数进行操作。
一般在没有显式定义构造函数时,在下面两种情况下编译器会提供默认构造函数:
1)本类、本类中定义的成员对象或者父类中有虚函数存在。由于要初始化虚表,因此编译器需要追加默认构造函数以完成虚表的隐式初始化操作。
2)父类或本类中定义的成员对象有构造函数。由于要先构造父类后构造自身,而调用父类构造函数的行为需要默认构造函数完成。
在构造函数和析构函数较为简单时,编译器通常会直接以内联函数对待。
5.2.5 识别创建对象实例行为
C++面向对象思想和高级特性是以C为蓝本实现的,对象从本质上讲就是含有属性(成员变量)、事件和方法(成员函数)的动态结构体,而类则是包含函数、静态数组(包含虚函数表、虚基类表、成员变量域)的具有保护属性(如public,private,protected,friend)的混合体。保护属性只在编译级别由编译器语法检查来维护,而在底层类布局中,基类所有成员无论保护属性如何都会被派生类继承。对象实例和结构体实例最大的不同在于对象实例会使用this指针。通过this指针可以分析出对象大小。
全局(静态)对象在编译期间被分配到数据段中,因此一般不会出现内存分配失败。为了实现构造函数只能调用一次的条件,通常编译器会使用一个初值false的全局变量标志,在首次调用构造函数时将该值置true。在类对象被使用时,先判断该标志是否为false,如果不为false就跳过构造函数语句。全局析构函数通常在_atexit之类的运行库函数内顺序进行注册,并在程序结束前由doexit函数倒序进行调用。
5.2.6 识别类成员函数
识别非虚函数:在调用类成员函数调用之前,通常会先获取到对象实例的this指针以便函数在需要的时候使用。因此如果在汇编级流程中已经分析出某函数中使用了this指针就可以利用该信息分析和使用到该变量的代码,通过函数调用识别出某个类成员。
识别虚函数和成员变量:如果找到了类构造函数就可以找到该类的虚函数表地址,一般来说虚函数表与静态变量和全局变量存放在数据段的不同地方,虚函数表中存在的虚函数和成员变量便容易分析出来。纯虚函数在函数表中是以指向库函数__purecall的指针代替。编译期间会做出语法限制,纯虚函数要求继承后才可调用,经过继承虚函数表中的__purecall被替换成相应的继承类虚函数地址,因此一般不会生成对纯虚函数的调用,如果运行时遇到了纯虚函数调用,程序会出现一个异常并终止运行;在发行版中,纯虚函数通常会被优化掉。
一般地,可以通过下面特征识别虚函数:
1)类中隐式定义了一个数据成员(虚表指针),该成员位于对象首地址处
2)构造函数将该数据成员初始化为某个数组首地址,该地址位于常量数据区,数组内每个元素都是函数指针。
3)数组函数被调用时第一个参数为this指针,函数内部会对this指针做间接引用。
因此,对于类虚函数识别,最终归结于对构造函数和析构函数的识别,如前所述。在这两个函数中,均会对虚表进行初始化。另外对于this指针的识别,如果发现某个寄存器在父函数调用子函数之前被赋值,而在子函数中该寄存器未经初始化直接引用的,则应判断该处是否存储了this指针。如果找到了初始化this指针的行为,那么就可以找到该类的继承关系。
5.2.7 重建类
重建类主要是重建类布局,手工重建类布局包括两方面,一方面是通过this指针的引用情况识别出类成员变量,另一方面,通过this指针找到构造函数并根据构造函数中设置this指针的行为找到该类所有虚函数。另外,对于引用this了指针却不在虚表中的函数,可以归并到类的普通成员函数中。由于对象内存大体上是虚表和类成员,且虚表中的虚函数与类成员均与声明顺序相同,这样就可以近似模拟一个和原始高级语言类功能基本相同的类。
5.3 识别异常处理
异常是对程序运过程中发生的异常情况的一种响应。异常可以是硬件产生的,也可以是软件产生的。当异常发生时,系统将程序控制权转交给异常处理代码,例如在32位Windows系统中,FS寄存器的零偏移处存储着线程相关的结构体,通过该结构体可以得到异常处理函数地址。异常流程通常包括三个部分:引发异常、捕获异常、处理异常,常用于创建对象、文件I/O操作中。异常处理机制由于其执行顺序的复杂性经常用于软件保护和代码混淆机制中,因此对异常机制的分析是必要的。这里以MSVC为例说明编译器如何利用Windows下SEH机制产生异常代码,MSVC支持三种类型的异常处理,包括C++异常处理、结构化异常处理、MFC异常处理。
5.3.1 C++异常处理
C++标准规定了异常处理的语法,各编译厂商都要遵循这些语法,但是由于C++标准没有规定异常处理的实现过程因此不同厂商编译器产生的异常处理代码不同,是C++程序常用的类型安全的处理方式,用来确保函数结束的栈解退(stack unwinding)过程中对象析构函数被正常调用[30]。栈解退是这样一种过程:函数由于出现异常而非因返回而终止,则程序会释放函数栈内存,但是不会释放到当前函数返回地址(正常函数调用指令会将CALL指令的下一条指令地址压栈)而结束,而是继续释放多级函数栈直到找到一个位于try块中的返回地址,之后控制权转到该异常处理程序。和函数返回一样,该操作会调用类的析构函数,然而函数返回仅处理当前函数在栈中的对象,而异常处理语句则处理try块和throw之间整个函数调用序列中存在的对象。
C++异常中使用的关键字有try,catch和throw。try块用于监视异常,一个try块后可以跟随多个catch块,每个catch块用于捕获一种异常,catch异常声明语句是省略号代表捕获判断之前未捕获的任何类型的异常,包括C类型异常和系统和程序产生的异常。throw表达式用来抛出各种表达式形式的异常。通常在捕获时可以指定标准库中定义的std::exception类及其派生的类作为异常捕获类型[31],同时,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++本身的异常处理更灵活,可以处理任何异常类型,使程序更好地移植。
5.3.2 32位程序 SEH结构化异常处理
Windows系统特有的异常处理机制,适合在C语言程序设计中使用。当进程无法从硬件和软件异常中恢复时,结构化异常处理机制会显示出相应错误信息并记录下进程内部状态用于诊断软件缺陷。这个机制对于不可复制的缺陷极为有用,在Windows程序设计中也很常见。编译器的SEH机制是建立在操作系统SEH机制之上的,在SEH中有三种形式的异常处理方式,包括异常处理(Exception handler)、终止处理(Termination Handler)和向量化异常处理(vectored exception handler)。其中异常处理是使用try-except语句;终止处理是使用try-finally语句[32];向量化异常处理是使用API调用AddVectoredExceptionHandler注册处理函数,RemoveVectoredExceptionHandler注销处理函数。
1)try-except语句语法为:
__try compound-statement
__except (expression) compound-statement
__try语句用于监视异常,__except用于异常处理,触发过程为:首先执行try块语句,如果过程中无异常发生,则执行__except语句之后的语句。如果执行过程中发生了异常或者被监视语句调用的任何子程序中发生了异常,程序会计算expression表达式决定如何处理异常.
2)try-finally语句语法为:
__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链增加新元素
经过上面操作,栈上的异常结构最终如图 5.5所示[33]:
图 5.5 SEH结构
32位Windows程序中,每个线程都有自己的异常回调函数,TEB结构体记载了线程的所有信息,该结构体可以通过FS:[0]找到,其第一个成员为指向EXCEPTION_REGISTRATION结构体的指针,结构如上所述。其中Handler成员指向一个运行库函数,该函数用来从SEH链向前索引得到作用域适合该异常的第一个异常回调。其中存储于SCOPETABLE_ENTRY结构体的HandlerFunc域为用户异常处理代码,而FilterFunc域用做异常类型过滤函数,通常SCOPETABLE_ENTRY结构随Handler变化[34],下面以MSVC6产生的的excep_handler3函数为例说明,查找用户函数过程如图 5.6所示。
当statement1的代码块中产生异常后,系统查找当前线程TEB结构,从中读取出异常结构EXCEPTION_REGISTRATION,使用结构中的运行库函数根据SEH链搜索适合处理该作用域的第一个异常处理,如果搜到就进行过滤函数的判断以及执行相应的用户代码。当含有异常处理的代码所在函数退出时,会将栈上的原始FS:[0](这一步包含在构建新SEH链中)覆盖现有FS:[0],从而完成异常处理回调例程的注销。
综上所述,对于32位Windows程序,恢复SEH代码的步骤为:首先查找函数序言的代码,如果有对fs:[0]处的操作就是在引用SEH,需要查看之前的压栈操作,根据上述结构找到用户异常处理代码块,包括__except的过滤函数和执行块以及__finally执行块。对于__try块的确定,每次该块代码执行之前,通常会将之前压栈的trylevel设置为0,而在执行之后会立即将该值设置为-1;trylevel的作用极其重要,可以用来分析多重异常布局情况下的异常语句恢复。
图 5.6 32位程序异常处理过程
5.3.3 64位程序SEH结构化异常处理
32位程序异常处理的实现需要借助函数栈,这种方式存在两个弱点,首先异常信息存储于栈上,容易被栈缓冲区溢出利用[35]。其次,异常情况一般出现的次数并不多,但是每次执行函数都需要为使用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块所包围的代码起始和终止位置,64位程序系统SEH异常处理结构如图5.7所示。
图 5.7 64位程序异常处理过程
如图 5.7所示,和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记录中,之后系统触发异常派遣功能,重复执行以下步骤:
1)使用存放在context记录中的RIP搜索符合当前执行函数RUNTIME_FUNCTION表项使其满足(BedinAddress<RIP<EndAddress)。
2)如果未发现表项则说明处于叶函数中,此时直接返回RSP上存储的函数返回指针,同时为了模拟函数返回过程,RSP会相应加8,之后重复第1步。
3)如果找到函数表项,此时RIP可能位于函数序言、函数结语或中间代码部分。若位于前两者中,则由于无法处理异常而重复执行第1步。若处于中间代码部分且表项分配了合法的异常处理函数,则会调用该函数,如果该函数无法处理该异常,则直接进行栈解退。
4)异常处理函数已处理结果,则程序使用原始context记录继续执行。如果异常处理函数返回“继续搜索”状态,则程序需要进行栈解退到context记录处于上级函数调用者为止。
第六章 逆向模型的测试
本章通过实例演示了如何利用文中提出的逆向分析模型,借助分析工具,对Windows下一般应用软件进行逆向分析的整个流程,输入二进制可执行代码,通过分析文件类型、查找程序入口、分析C/C++语言元素、分析函数功能、分析算法,输出软件设计流程、算法和文档,在这个过程中合理协调软件与人工分析共同完成软件逆向工作,例证了该模型的正确性和可用性。
6.1 使用代码分析工具辅助分析
IDA是用来做手工分析的辅助工具。IDA反汇编的时间与程序代码段大小有关,分为两个阶段,第一阶段将代码与数据分开,标记各个函数和符号并分析参数调用、参数栈、局部变量栈、跳转等指令关系,并分析数据结构,自动生成流程图和模块调用关系[36]。第二阶段识别出文件编译类型信息并加载对应特征库,这部分主要通过FLIRT技术(Fast Library Identification and Recognition Technology)实现,该技术可以通过对比特征码自动找出库函数调用;除此之外IDA的插件扩展性和交互性极强,较好的插件包括C语言伪代码分析工具Hex-Rays;支持多平台调试;支持IDC脚本[37]。
查看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的相应参数进行修改,必要时还需做堆栈平衡分析和修改。在了解函数功能后最好进行注释,并取符合功能的名称作为函数名。
6.2 使用该模型分析C++程序实例
本文研究已在普通PC机上进行了成功测试,操作系统平台为Windows 7 x64 Ultimate,测试工具为PEID、IDA,测试对象为Nisoft的AltStreamDump[38],该软件用于查找指定目录下的所有NTFS文件数据流。本文处于研究目的,版权归原作者所有。
6.2.1 实施逆向模型操作流程
去除软件保护:使用PEID查看文件信息,发现未加保护,采用MSVC2005编写。
确定软件编写语言和编译器:C/C++,和MSVC2005。
分析类型:查看输入表,发现使用了msvcrt.dll,而由后面分析的入口可知为使用了C运行时库的命令行应用层程序。
识别库函数:由于使用了运行时库,因此IDA自动加载的库函数为Microsoft Visual C 2-10/net runtime;使用的类型库为mssdk和vc6win,无需手工创建和加载它库。
查找程序入口:使用IDA加载,找到IDA输出表中的系统入口函数start,接着找到用户入口,该函数出现在环境变量初始化之后,退出函数之前,可以发现.text:00401D76 call sub_401914符合要求,该函数调用之前使用了三个压栈指令,说明需要三个参数,再根据前面_wgetmainargs获取这三个参数可以推断出为宽字符版本的main函数,其符号名和参数类型为:
int wmain(int argc,wchar_t* argv[],wchar_t* envp[]) 确定入口过程如图 6.1所示。
图 6.1 实例分析-查找入口点
重新标注符号:将前面的sub_401914改为wmain。
图 6.2 实例分析-分析局部变量
分析函数属性:以wmain函数为例,其他函数以此类推。父函数调用该函数前进行了三次压栈操作,同时在调用后进行了堆栈平衡,因此是_cdecl调用方式。由于IDA已经识别出该处代码,函数结尾之后的指令段已经处于其他函数范围因此终止位置正确。
分析局部变量:以wmain函数为例,函数序言部分有指令sub esp,454h,可见局部栈变量使用了少于454h字节的空间,因此需要从函数头部开始分析栈使用情况判断使用了哪些变量及这些变量的作用域,分析结果如图 6.2所示。
分析函数功能:以分析出的.text:00401004 sub_401004为例,该函数经过功能识别以后命名为GetPrivilege,使用的API调用序列为:
GetCurrentProcess
LoadLibraryW “advapi32.dll”
OpenProcessToken
LookupPrivilegeValueW
AdjustTokenPrivileges
CloseHandle
查询MSDN并进行详细分析可知该组调用序列为Windows系统提权的典型功能代码。
分析函数算法:以分析出的类成员函数RecurseFind为例。该函数采用回溯法遍历当前目录及其所有子目录,并分析和显示其中文件的数据流信息,由于分析过程较繁琐,流程分析结果如图 6.3所示。
分析类:以wmain函数(父函数)中的类为例。在分析多个函数中的代码时,发现调用很多函数之前都会设置esi寄存器,同时在被调用函数中也在未初始化状态下直接使用该寄存器,因此可以假设该寄存器存放的是this指针。以此为前提,在父函数中可以发现申请了很大的局部变量空间,对于类成员函数调用的线索,可以看到父函数最后在.text:00401AC0处有一条指令为lea esi,[esp+460h+var_448],紧接着调用了子函数sub_401848,而且在该子函数中直接使用了esi,因此认定这个函数为成员函数(所有使用了this指针的函数都可看做成员函数),同时关注之前esi和var_448的操作情况,将var_448作为栈上存储类的对象空间开始处,对于其结束位置,需要从两个方面推测,一方面是父函数中该栈上该类空间起始位置之后的第一个其他变量所在位置,该对象在栈上的结束位置不可能超过这个值,另外根据esi是this指针这个信息从函数列表中所有的子函数中进行搜索,查看对于this指针的最大偏移数,由于对象并没有赋予虚表指针的情况,因此最大偏移数就可以假定是临近最后的成员变量位置。
图 6.3 实例分析-算法分析
根据这种方式综合分析,得到程序中的两个自命名类,一个是用于控制显示部分的MainClass,另一个用于文件查找的FindFileClass。这样就根据this指针确定了所有成员函数和类成员。另外调用第一个成员函数之前存在对对象成员变量域的赋值操作,这种操作极有可能是类构造函数采用了优化而内联的形式存在于父函数之中,对于是否为构造函数,可以通过该类每次出现于内存中是否都执行了构造函数这个本质进行验证,如果不是则认为它是一般成员函数,对于析构函数同理。在分析了各个成员变量作用域以及搜索到所有关联的成员函数后可以得到下面的两个简单类结构,如图 6.4、图 6.5所示:
图 6.4 实例分析-重建类
图 6.5 实例分析-重建类
修正函数属性:将所有手工分析出的函数,对于IDA分析其参数类型、参数个数、起始位置、终止位置、调用方式产生错误的,在函数列表窗口选择该函数并右键选择菜单中的修改函数选项。
分析异常处理:由于在用户函数中没有发现中存在fs:[0]相关操作或异常处理相关函数,因此不予考虑。
分析RTTI信息:由于进行了优化,找不到相应RTTI信息。
6.2.2 重建开发文档
AltStreamDump v1.05 Copyright©2011-2012 Nir Sofer
系统配置:支持Windows 2000直到Windows7的系统
使用说明:AltStreamDump不需要任何安装过程或附加dll文件,打开命令提示符窗口就可以运行该程序。AltSrtreamDump默认显示当前目录的文件数据流,您可以通过使用-f和-d命令行参数查看其他文件夹的文件数据流。
命令行选项:
-h用于显示命令行帮助;
-f [Folder Path]用于指定要搜索的目录;
图 6.6 实例分析-运行结果
-d [Subfolders Depth]用于指定要搜索的父目录深度(0=不搜索子目录 1=搜索一级子目录,以此类推)
例子:AltStreamDump.exe –f “c:\myfolder” –d 3
经过逆向分析后,可知该程序是通过调用ntdll.dll中的API函数NtQueryInformationFile得到文件数据流的。将源代码采用MSVC6重新编译,生成的AltStreamDump.exe运行结果如图 6.6所示。
考虑到将来要出书,因此暂不打算放出原文
|
|