论坛前最大字数限制,发不出去,只好将不紧要的部分略了,就这样吧。有意细究的,去拖未删减的TXT原文看。
《Code Virtualizer逆向工程浅析》
☆ 背景介绍
"Code Virtualizer"的资料不多,可能与它不如VMP被广泛使用有关,OSForensics 9用了CV。若非现实世界有实用软件用CV保护,鬼才有兴趣对之进行逆向工程。之前没有接触过CV,用TTD调试OSF时被绕得七荤八素,后来无意中确认OSF用CV保护。上网搜了些CV资料,都比较老,适用于1.3.8或更早期版本,与OSF所用CV版本差别较大。还有一点,老资料出现在32位时代,现在是64位时代。
CV将CFG扁平化,实际上没有调用栈回溯一说。CV处处是间接转移,主要是jmp寄存器这种形式,其次是将目标地址压入栈中,靠ret转移,这样一来,IDA中几乎没有显式交叉引用。敏感字符串是可以混淆存放的,这条路也断了。
本文分享一些CV逆向工程经验,基于网上能公开下到的CV 2.x。OSF所用CV是何版本,我不知道,但实测发现本文的经验大多也适用于OSF逆向工程。我只关心C语言,其他语言不在本文范畴。
☆ Code Virtualizer 2.2.2.0
1) CV SDK
公开能下到的只有CV 2.x,以此为研究对象。压缩包展开后主要关注这些文件和目录
(略)
2) cvtest.c
没有实际意义,只是示例,要点如下
"EAGLE_BLACK"是CV虚拟机的一种,看上去保护强度最高。SDK自带的vc_example用的是"TIGER_WHITE",保护强度很低。
3) 编译
(略)
4) CV虚拟化
执行Virtualizer.exe
(略)
缺省LastSectionName可能是其他值,比如".vlizer"。cvtest_p.exe有2个".scz"。
同一个cvtest.exe,每次CV虚拟化生成的cvtest_p.exe都不一样。
前面简介了CV SDK的使用,最好是自己整一个cvtest.c,生成cvtest_p.exe,对后者进行逆向工程,积累经验后再去对付现实世界的例子,比如OSF。
☆ TTD
CV虚拟化本身不会增加反调试检查,调试cvtest_p.exe时不需要反"反调试"。OSF有反调试,但OSF没有考虑到TTD技术的出现,其反调试措施没有针对TTD录制。
即便不考虑反"反调试",对CV保护过的代码进行逆向工程时,条件允许的情况下,强烈建议TTD录制。若对CV有过经验积累,再动用TTD,能极大地抵消CV保护。
☆ CV虚拟机框架概览
1) 从cv_entry[0]到cv_entry[4]
VIRTUALIZER_EAGLE_BLACK_START那一对宏在编译后化身为两个call
这是cvtest.exe中的效果,cvtest.exe只是从C编译成PE,尚未进行CV虚拟化处理。151、551这种数字无关紧要,要点是它们成对出现。
Virtualizer.exe靠这两个call识别出待保护代码片段,对之CV虚拟化,将11KB的cvtest.exe膨胀成1649KB的cvtest_p.exe,这是加了多少垃圾代码?
CV虚拟化时将"call VirtualizerSDK64_151"就地转成jmp,这是cvtest_p.exe中的效果
cv_entry[0]还在.text中,但jmp的目标地址cv_entry[1]已离开.text,进入.scz。
cv_entry[1]的特点是pushfq,cv_entry[1]、cv_entry[2]之间无任何分支转移指令,二者就是块首、块尾,在IDA中用图形模式查看,非常明显,这是第二个特点。
cv_entry[3]的特点是"call $+5",一种自定位技巧,shellcode常用套路。在IDA中Alt-B搜索字节流"E8 00 00 00 00",找出所有"call $+5",基本上都是cv_entry[3]。
cv_entry[4]的特点是"jmp [rax]"。
即使在C代码中只使用了一对VIRTUALIZER_START/VIRTUALIZER_END,cvtest_p.exe仍有可能出现多个cv_entry[3],为什么?因为只要进入CV虚拟机一次,就会有一个cv_entry[3]等着经过,从CV虚拟机中调用外部库函数时,会临时离开CV虚拟机,执行完外部库函数,重新回到CV虚拟机。在这些进出CV虚拟机过程中,自然出现多个cv_entry[3],有些进出流程可能共用一个cv_entry[3],有些可能用自己的cv_entry[3]。
cv_entry_3_14018AA39可以p操作成函数,图形化查看时非常复杂,但把握住前述入口与出口特点,搞几次后就能轻松定位。
IDA可能缺省未将cv_entry[1]与cv_entry[3]识别成函数,我的事后复盘经验是,一定将它们p成函数,以降低静态分析难度,IDA的图形模式只能看函数。
CV虚拟机官方没有cv_entry[0]、cv_entry[4]这些概念,这是为了叙述方便自己给的定义。回顾一下流程框架
逻辑上cv_entry[0]在CV虚拟机外,一般在.text中,这个不绝对。之后cv_entry[1]至cv_entry[4]全部在CV虚拟机中,一般在.vlizer中。
2) 定位cv_entry[1]
已知从cv_entry[0]转向cv_entry[1],会从.text转向.scz(本例中的名字),可以用x64dbg调试,对.scz设内存访问断点,以此快速定位cv_entry[1]。这段话假设目标程序比较复杂,现在还在.text中,静态分析一时半会儿找不到cv_entry[0]。若肉眼就能发现cv_entry[0],则无需前述技巧。
定位cv_entry[1]之后,静态分析就能定位定位cv_entry[2]到cv_entry[4]。
3) 在cv_entry[4]处获取关键信息
假设调试器停在cv_entry[4]
在cv_entry[4]处有
在cv_entry[4]处可以找到VM_CONTEXT,这是CV虚拟机的核心组件之一,后面再说。
(略)
从cv_entry[0]到cv_entry[4]真正干的大事就是将cv_entry[0]处各寄存器压栈,一堆眼花缭乱的操作都是为了掩盖这个事实。最初我还老老实实在TTD调试中一步步跟,后来意识到它的意图后,采用污点追踪的思想快速定位cv_entry[4]处栈中诸数据。
前文所用术语都是自己瞎写的,结合上下文对得上就成。
4) 定位func_array[]
func_array[]就是老资料里说的handler[],CV虚拟化将每一条位于保护区的汇编指令转换成许多个func_array[i]组合。
在cv_entry[4]处有多种办法定位func_array[],比如
0x140180eae即func_array[]起始地址。
有个取巧的办法定位func_array[]起始地址。假设已知VM_CONTEXT在0x1400ab9d6,本例中该结构占0x174字节,但该结构大小并不固定,有可能是其他大小。在IDA中查看0x1400ab9d6处hexdump,大片的0,只有一处非零,就是VM_CONTEXT.func_array字段所在,静态查看时该值是重定位前的偏移值,加上基址才是内存地址。
IDA中看func_array[i],是重定位之前的偏移值,加上ImageBase才是函数地址。应在IDA中静态Patch,人工完成重定位,使得IDA分析出更多代码。func_array[]比较大,很可能没有以qword形式展现,一个一个手工加基址Patch不现实,写IDAPython脚本完成。
5) 确定func_array[]元素个数
没有简单办法确定func_array[]元素个数。在IDA中肉眼识别、逐步逼近当然可以,但不够放心,怕不精确。
有个辅助办法,图形化查看cv_entry[4],往低址方向找如下cmp、test指令,还是比较容易定位的。
找到0x14018B9D4、0x14018BABD这两个地址后,在TTD调试中对之设断点,从cv_entry[3]处正向执行,断点命中时查看寄存器,注释中写了。不一定TTD调试,普通调试就可以,但我一上来就TTD录制了,后面的分析都是在反复鞭尸,更方便。
精确知道func_array[]元素个数后,写IDAPython脚本对之批量qword化、加基址。这还不够,应该对每个func_array[i]加"repeatable FUNCTION comment",比如这种效果
CV虚拟机很复杂,给每个func_array[i]自动加注释,有助于聚焦。
EAGLE_BLACK虚拟机比TIGER_WHITE虚拟机复杂得多,func_array[i]只是个幌子。0x1400ABB4A处jmp到0x1400CA48E,后者也不是真正干活的handler,其实是另一个cv_entry[1],后面有另一个cv_entry[2]到cv_entry[4],最终会去找另一个func_array_2[j]。不建议初次接触CV的人一上来就逆EAGLE_BLACK虚拟机,可以拿TIGER_WHITE虚拟机练手。当然,前面我都给出提纲挈领的大框架了,再看EAGLE_BLACK虚拟机,也不是那么难。
6) 推断VM_CONTEXT结构
流程到达cv_entry[4]时,rbp指向VM_CONTEXT结构
(略)
本例中该结构占0x174字节,但该结构大小并不固定,主要是大片的0。流程到达cv_entry[4]时,VM_CONTEXT结构部分成员已初始化,包括
每个CV虚拟机要单独分析VM_CONTEXT结构各成员位置,总是在变,就是为了对抗逆向工程,上面只是一种示例。若非高价值目标,不建议与CV/VMP这类虚拟机搏斗,浪费生命。
可能过去VM_CONTEXT结构总是位于.vlizer起始位置,现在没这经验规律了,不能假设仍然如此,事实上OSF就不服从该规律。此外,VM_CONTEXT结构之后不能假设紧跟func_array[],应该用VM_CONTEXT.func_array定位。流程到达cv_entry[4]时,VM_CONTEXT.func_array已是重定位后的地址。
7) 从VM_DATA复制数据到VM_CONTEXT
VM_DATA是我给压在栈上的各寄存器布局瞎起的结构名字,便于叙述,不必当真。
在cv_entry[4]处查看VM_DATA
直接对栈中各寄存器值设数据断点
每次命中时重新设置上述数据断点,依次命中
用这种办法可以知道VM_CONTEXT.r8的偏移,还可以找到pop_to_context_,这种handler对应"pop [addr]"。EAGLE_BLACK有多种pop_to_context_,TIGER_WHITE只有一种,难度相差极大。
8) 定位cv_entry[5]
从栈中弹栈到VM_CONTEXT.efl,是最后一个弹栈动作,至此所有栈中寄存器均被弹入VM_CONTEXT结构相应成员。假设流程到达cv_entry[4],不必费劲地对栈中各寄存器设数据断点,只需要对栈中的efl设数据断点即可。
断点命中时,查看内存中的VM_CONTEXT结构
(略)
由于我采用了污点追踪的思想,肉眼就能识别各寄存器在VM_CONTEXT结构中的偏移,据此可进一步完善VM_CONTEXT结构定义。
cv_entry[5]是个虚概念,只是为了叙述方便。流程到达cv_entry[5]时,VM_CONTEXT中各寄存器已填写完毕。若在TTD调试中,记下断点命中时所在position值,方便回滚。
cv_entry[5]位于func_array_2[j]中,j不固定。func_array_2[j]没有显著特征,无法通过静态分析定位cv_entry[5],只能动态调试定位,这与cv_entry[4]不同。
cv_entry[5]之后的流程才真正对应"被保护代码片段",之前的流程都是CV虚拟机初始化。若不知道这点,一上来就楞调试,早早陷入CV虚拟机的圈套,很容易失焦。
cv_entry[5]之后也不见得马上对应"被保护代码片段",某些func_array_2[i]实际对应nop操作,看上去又很复杂,nop操作想插多少有多少,想插哪里插哪里。分析CV虚拟机时,还得动其他脑子。
☆ CV虚拟机逆向工程经验
为叙述方便,本节不区分func_array[i]、func_array_2[j]等,概念上它们地位相当。
1) VM_CONTEXT.dispatch_data
VM_CONTEXT.dispatch_data是个指针,指向IDA中静态可见的数据区域。每个func_array[i]都会从VM_CONTEXT中取dispatch_data指针,再从dispatch_data[]取
数据。
dispatch_data[]是一段字节流,没有固定的结构,没有固定的大小。使用它时,从哪个位置取几个字节上来,完全由当前用它的func_array[i]决定,几乎每个func_array[i]使用dispatch_data[]的方式都不一样,这是对抗逆向工程的手段之一。
以mov操作为例,可能wo(dispatch_data+5)是一个16位偏移,加上VM_CONTEXT基址后定位到VM_CONTEXT.rax成员;可能dwo(dispatch_data+0x13)是虚拟化之前的mov指令中的立即数。理论上,找到合适的dispatch_data[i]可以暴破CV虚拟化过的代码。
每个func_array[i]用完当前dispatch_data[]后,会更新VM_CONTEXT.dispatch_data,确切地说,是递增,使之对应func_array[i+1]。
2) 跟踪func_array[i]
前面讲过定位func_array[]起始地址,现在想知道依次执行了哪些func_array[i]。
已知在各个func_array[i]之间转移时,VM_CONTEXT.dispatch_data会递增,对之设数据断点,即可跟踪func_array[i]。前述数据断点命中时,有些CV虚拟机可能位于func_array[i]的最后一条指令,一般是相对转移指令,这是理想情况。OSF所用CV虚拟机更变态,更新VM_CONTEXT.dispatch_data的代码在func_array[i]中部,而不是尾部。
3) VM_CONTEXT.efl
虚拟化前add/sub/xor/cmp/test等指令在虚拟化后都有各自对应的func_array[i]。简单的CV虚拟机可能add指令对应唯一的func_array[i],早期CV可能就这样,现在不是了,多条add指令可能对应不同的func_array[i],防止在逆向工程中一次标定多次使用。好不容易标定某func_array[i]对应add操作,结果下一个add操作不过这个func_array[i],抓狂。
前述这些指令有个共同点,实际执行时会修改efl。CV虚拟化后,它们对应的func_array[i]会修改VM_CONTEXT.efl,可能是这样的片段
对VM_CONTEXT.efl设数据断点,能加快func_array[i]的标定。
上面是理想情况。EAGLE_BLACK虚拟机比较变态,test指令修改了efl,但当前func_array[i]不会更新VM_CONTEXT.efl,它将efl存到tmp中;然后其他func_array[i]不断搬运tmp,push/pop/mov操作对应的func_array[i]挨个来,无效搬运,很久之后才将源自tmp的数据搬运进VM_CONTEXT.efl。我碰上过test操作与最终更新VM_CONTEXT.efl的操作相差619个func_array[i],中间的全是垃圾操作,目的是让你搞不清发生了什么。OSF所用CV虚拟机更新VM_CONTEXT.efl时没这么变态,但有其他变态之处。
4) VM_CONTEXT.rsp
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课