逆向工程随笔
序
这篇文章不是讲技术的,更不是什么实践教程。随笔嘛,随便写些什么,祭奠我暑假实习的时光。看心情和工作进度不定时更新吧。各位当个故事也好,当技术心得也罢,对我来说,这是一个咸鱼挣扎过的证明吧。
启程
既然题目是“逆向工程随笔”,那么就先讲讲逆向工程吧。在CTF比赛中,我们所遇到的是单个exe,亦或是elf,还有别的吗?很少。面对单个文件,我们通常的想法是通过字符串或者导入表来定位关键的函数,然后一路挖算法挖过程挖API,挖到逻辑清晰挖出flag为止,因为我们已经明确flag在可执行文件里,通过算法亦或是爆破可以得到flag,所以直接定位关键函数后深度优先遍历分析就能找到解决问题的核心。
在刚开始做项目的时候我沿用了这种问题导向的逆向思路,觉得一个软件无非就是关键功能要搞出逻辑嘛,有什么难的?
但是逆向工程与比赛逆向的区别在于,“工程”可不仅仅只是一个简单的exe。它是exe、dll、sys和其他杂项文件的集合,承载了软件运行所需的环境和功能。从比赛的角度看,每个文件都要定位关键函数后深度分析,一直挖,而且在工程中每个文件都或多或少会与其他文件通过导出表进行交互,交互后的逻辑与分析让复杂度指数级上升。而且,最重要的问题是——逆向工程没有所谓的“问题”
什么叫做问题?在比赛中,问题是获取到flag。你所做的一切都是为了拿到flag。但在工程中,你可能并不知道软件有些什么功能——类似于你不知道一堆文件中有没有flag。你甚至不知道问题是什么,功能的实现依赖于什么方法,唯一有价值的资料就是项目需求:把软件关键功能搞出来。
比赛是解决问题的过程,逆向工程中解决问题固然重要,但更重要的是发现问题所在。譬如通过查找网上资料,你发现软件有监控进程的功能,而且你觉得它挺重要的,于是IDA启动,找到导入表,查找OpenProcess()
的引用——根据比赛的经验,你觉得软件只有在监控进程、获取进程信息才会用到如此重要的API。引用结果出来了,成百上千个调用,而且都是来自不同的函数,于是你傻眼了。“逆个蛋啊!”
于是你发现,你陷入了一个问题的沼泽,当一切都是重要函数的时候,一切都变得不重要了。在沼泽中你试图挣扎,妄想着运气好能撞到关键功能的实现,于是花上三四天时间一个个函数深度递归分析,一条条汇编地深入下去,结果发现分析结果不是系统内置API的实现,就是关键功能实现的一个小小片段。在沼泽中挣扎会让你越陷越深,直到精疲力尽陷入其中。可以说,最开始一周,我就是在沼泽中无力挣扎,关键功能没看多少,对内存分配和资源处理的流程倒是看熟到了然于心的程度。
在沼泽中行走,需要增大施力面积,比如垫一块木板。逆向工程同理,在茫茫函数海中,让你浮起来的木板是广度优先遍历。相较于深度优先式的分析方式,广度优先可以让你更好地掌握函数的轮廓,对函数的功能有个大概的了解——当然你无法遍历完功能的具体实现。在分析的过程中,思维导图十分重要,它可以清晰地反映函数之间、功能之间的层级关系。通过广度优先分析完函数的轮廓后,你大致可以确定这个函数要不要跟进去、跟进去时可能遇到的问题和需要重点关注数据的流向。确定好目标,脑海中要有一条线,沿着这条线进行深度递归分析。换言之,广度递归分析有点像个大海捞针的过程,时间开销较大,但它为你的日后的分析道路指明了方向。纵观种种实现,万变不离其宗。但如何找到相对合适的根节点——亦或是如何更快地找到关键点,这是在进行逆向工程中挥之不去无法回避的问题。
总而言之先浮在沼泽上喘口气先。太特么累了。
浮在沼泽上,然后呢?
广度优先遍历只是让你浮在沼泽上,不至于沉底,可是当你找到了关键的小范围,还是得深度遍历,就像头朝下扎入沼泽,毕竟东西在沼泽底下。相比起第一周对无头苍蝇式突破,第二周有目的地下潜,在心态上就轻松了许多,现在累的主要是脑壳了。盯着关键的数据,分析数据的流向和变化,推测数据内容和功能,一天就过去了。
曾经我认为开发是搭积木的过程,逆向便是将已经组合好的积木一块块拆开的行为——现在看来,这感悟只适合比赛中模块数量少、依赖关系弱的小玩意儿。逆向工程中的逆向,其实也是一个搭积木的过程,不过是积木的粒度不一样罢了。对于开发来说,最小的粒度可能是类似于printf()
这类编程语言层面相对底层的函数,再底层些可能会用__asm
内嵌几句汇编——已经底层到驱动开发了,到头了。逆向工程呢?IDA可不会那么聪明把一句句汇编还原成高级语言的样子,你所见到的只是软件分析推测的结果。一个8字节的数据,它可能是数据,也可能是指针——CPU可不管你在高级语言里声明类型,它只是按部就班地按照长度来处理数据而已。一个指针,它可能是用于进程通信void*
,也有可能是指向关键结构体的地图,也有可能是废弃的指针;一个8字节数据,它有可能是立即数,有可能是数组首地址,也有可能是两个甚至多个数据的嵌合体....在你不了解软件的情况下,每种可能都是成立的,而且每种可能相互组合成的分析链,在工程的某个局部看起来无懈可击。于是我沿着一种可能,建立了分析链,把一堆细碎的积木拼凑成了一个相对完整的玩意儿,左顾右盼,把跟这玩意儿相关的几个模块拼一下,对不上。行吧,半天时间没了。“逆个蛋啊!”
同样的数据,不同的解释,意味着完全不同的分析链和挖掘思路,不同模块之间相互的调用只能用来佐证你的推理结果,但无法筛选最初的起点,拼凑就是在黑盒中摸索的过程。通过试错可以达成目的,但是时间的代价实在是太大了,在有限的时间内要想完成任务,需要的不是试错的教训,而是效率。
自顶而下能通过宏观分析思路提升效率,自底而上呢?
Following The Data
在沼泽底下能看到什么?
深入函数,挖到具体实现,眼前所现是数据的处理和流动。对数据处理,然后返回,亦或是将数据传给下一个函数,处理过程的载体是什么?数据的流动。
回到最初的起点,面对一望无尽的函数沼泽,我最初的下潜方法就是跟踪函数的调用。刚开始是无意义地跟踪重要API,后来通过广度分析定位到了关键的起点,然后看看这个函数调用了什么函数,一步步下去。这种分析的方式诚然可以完全地掌握函数的处理流程,但代价是分析的碎片化。过于深入地冒进会导致“一叶障目,不见泰山”,特别是挖掘由多个函数组合成的功能时尤为明显。比如说,你发现一个函数由两个功能组成——申请内存和释放内存,创建文件然后删除文件,一正一负,函数什么都做了,可看起来啥也没做,是不是很奇怪?觉得逻辑有问题,再往下挖,就到了系统底层API,挖不动了,这个函数的作用呢,写个无作用?觉得哪里不对,看看参数——一个指针,或一段数据,能说明什么呢?挖的越深,你所见的越是局限,有时候忙了一阵,想给之前的工作写个小结却无从下笔——一堆乱七八糟的东西揉成一团,黑人问号。
深挖函数就像是深入竖井,你需要一根绳索来保持与地面——或者沼泽表面的联系。在逆向工程中,这根绳索就是数据。函数的调用就是数据处理的过程,把数据想象成一股水流,函数就像是水处理厂,函数中的判断语句就像阀门,改变了流向,对数据的处理就像是给水流添加或删除元素——相比起分析函数做了什么操作,分析数据的变化更加有效。
换一个角度,一切都清晰了。首先,你可以揪出哪些是数据的必经之路,哪些是与数据无关的环境初始化和异常处理函数。在初步分析的过程中,深入挖掘无关函数是浪费时间的行为。以数据为判断函数重要性的标志,依据传入函数的参数进行筛选,将无关的模块放到一边,可以节省大量的时间。其次,在工程中,层层函数中流动的数据的类型通常是一个结构体,比如你看到许多函数的传参的形式类似于TraceMessage(v1+20, v1+24)
,参数都是对某个内存地址作不同的偏移——这就是关键的结构体/类或者函数表。通过API调用时传入的参数你可以对结构体的数据类型和作用有个大体的了解。
举个例子,当你看到以下的代码时
if(v1+16>2 && v1+20)
{
TraceMessage(v1+20, v1+24);
}
else if(!v1+20)
{
SendMessage(v1+30,v1+34)
}
函数的流程控制和信息的输出都与v1
有关,通过网上的资料,TraceMessage
是调试信息的输出,SendMessage
与信息的传递有关。接着,v1
这个结构体的部分成员就可以挖掘出来:
struct Guess_Struct
{
...
+16: U_INT TraceMessage_Switch;
+20: HANDLE TraceMessage_Event_Handle;
+24: PVOID TraceMessage_Context
...
+30: HANDLE SendMessage_Event_Handle;
+34: PVOID SendMessage_Context;
}
这个关键结构体就像是程序的灵魂,程序的关键信息传递、流程控制都绕不开这个结构体,犹如在森林中猎物的足迹一般,顺着足迹可以少绕很多弯路,直达目标。当你后续的分析中,紧跟在if(v1+16>2 && v1+20)
判断条件下的语句,可以当做调试信息的输出延后分析。与v1+30
有关的任何函数调用,都应该作为重点深入跟踪。挖的越深,结构体的成员信息和函数作用就越发清晰,等结构体的信息收集的差不多了,程序也基本被逆向干净了。
文章评论