• 标 题:如何制作VB的P-Code调试器(译自:WKTVBDE的作者)
  • 作 者:virtualspace
  • 时 间:2003年11月22日 11:01
  • 链 接:http://bbs.pediy.com

如何制作VB的P-Code调试器(译自:WKTVBDE的作者)

VB P-Code Information by Mr Silver

源自:http://www.woodmann.com/crackz/VB.htm


P-Code简介

术语P-Code既不是一个新名词也不是Microsoft的发明,P-Code只是简单地被解释执行的伪指令。因此,我们并不需要通过什么复杂的专业词汇来描述它。P-Code可以被认为是一种普通的机器级代码(我们的微处理器不能解释它)。在运行P-Code之前,需要一个解释器处理它,转换P-Code为CPU可以理解的机器语言。这个过程看起来有点象JAVA, 为了执行JAVA语言写成的应用程序,我们需要一个进行解释和翻译的处理程序- 虚拟机“Virtual Machine”。这个负有想象力的术语实际意味着它是一个翻译的机制,将JAVA编写的程序转换成我们的CPU可以理解的操作码(指令)。

P-Code的优势是明显的。假如我们定义了一组特殊的专属性指令集,并且不公布它的详细定义规范说明,那么一般人很难理解我们生成的程序代码;另一个优势是减小了执行文件的尺寸:通过定义独特的单字节操作码,我们能够使得某些伪指令执行一系列的操作(相当于大量的机器码完成的工作)。Microsoft的 Visual Basic 包含的P-Code的确是如此:一个VB虚拟机翻译P-Code到我们本地的机器码。 虚拟机(以dll形式出现),在P-Code程序执行前被调用,用来解释相关VB的伪指令。有如你猜测的那样,那些DLL的名称是 :
MSVBVM50.DLL MSVBVM60.DLL
文件名称清晰的表明是Microsoft Visual Basic 虚拟机(Virtual Machine), 后跟不同的版本信息。两个版本的差异不大:版本6引入了一些新的指令,并采用了更直观的命名来标注某些版本5中的指令。换句话说,版本6只是改变了版本5中部分指令名称,而非其内在的功能。

虚拟机不仅解释Visual Basic 的P-Code文件,同时它也被用于执行编译过的机器码。这是因为VB 虚拟机(DLL文件)同时也包含所有VB程序要调用的API。 一个例子是rtcMsgBox, 这是个等价于标准Windows API MessageBox 的VB函数。P-Code代码被VB虚拟机解释执行,VB中所有的函数都是以这种间接的方式被提供的。

由于这个原因,当我们跟踪一个Windows API MessageBox被 P-Code程序时,产生了一个严重的问题:我们必须要跟踪P-Code伪指令。

SoftICE 无法跟踪P-Code伪指令, 它只能跟踪VB虚拟机的执行过程。更明确地说, SoftICE 只能理解CPU处理器的机器码,它不能理解任何伪指令。我们将尝试去跟踪P-Code(P-Code伪指令将被转换成可被我们的CPU理解和执行的机器码)。

起始表(Beginning of the Tale)
几乎所有的事情都是如此:好奇心引发了人们迎接一项新的挑战,我们的故事由此开始。

我记得曾在EFNet网站(论坛)与Green先生讨论有关VB P-Code的问题。他那时的工作正好涉及有关VB5编译的应用程序。他告诉我处理P-Code是非常的困难,所以我们有了制作一个VB P-Code的Debugger程序的想法。 实际上Black先生也认为这是个有意义的思路。考虑到这个项目,我说如果我们没有任何可用的有关资源,这可不容易实现。而后,我们查找了许多有关信息,但没有任何有意义的发现,空手而归。好奇心使得我更加努力去细心地发掘有用的信息资料。的确是不易呀…我曾和Snow先生探讨有关问题,他提供给我一个被Lazarus修改过的 MSVBVM50, 在其中,他描述了VB程序表现的所有可能的串比较。这促使我下决心制作一个VB Debugger。

我认为当MSVBVM50运行时注入代码是可能的。被注入的代码能够调用我的Debugger, 它在一个DLL中实现。  我决定告诉已加入这个项目的Snow先生, 由他负责制作代码注入器 (好像一个调用装入器Loader) ,我负责Debugger (DLL)编码,就是那个被装入的Debugger(DLL) 。 就在我们两个完成了一些工作后,我们进行了测试,令人振奋的是它真的可以工作!这个 Debugger项目已经迈出了它的第一步。

我们可以控制VB虚拟机(截获有关操作),并且在虚拟机与VB应用程序之间安置我们的Debugger。最大的问题已经得到解决,虽然在初始阶段,我们采用的解决方案(技术上如你所见)并不是最终我们采用的方法。在我们的大目标和指导思想始终如一的情况下,我们不断对它进行改进,一直到我们完全避免了对虚拟机本身的修改。

* 第一步

跟踪分析,控制虚拟机

为使我们的Debugger能够工作,有一个关键性必须解决的问题:发现P-Code代码的翻译转换是什么时候以及如何发生的。一旦我们认识到这一点,我们注入的代码将接管对被调试的VB应用程序的控制,并且发送有关数据到我们的Debugger。Debugger 依次处理操作码并返回到VB 虚拟机。 我本人以前在有关调试器Debugger编码方面的经验几乎为零。不过不久前我差不多完成了一个x86的反汇编器 , 因此我将那些知识用于我的VB Debugger 开发工作。我的构思是这样的:

对于反汇编/解释这部分代码包含以下基本原理:

- 一个指针(pointer)指向一个缓存区(buffer),它包含将被转换的数据。
- 一个控制程序,它从缓存区中读取操作指令(opcodes)并且重定向程序流,使其依据我们的意图,指向我们想要它执行的程序位置。

这个任务通常表现为两种形式: 1、一系列控制描述语句(对于每一个操作码);2、使用一个地址跳转表。 我放弃了第一种选择。因为P-Code中各不相同的操作码实在太多,这将需要一个巨大的条件控制结构(处理这样的工作将变成世界上最慢的事情)。 我猜VB虚拟机对P-Code的翻译转换过程采用的是地址跳转表方法去解释那些可能的操作代码。 这种做法同样出现在我设计的反汇编器中。 现在,我必须完成以下的工作:

- 定位缓存区中待解释的操作码,定位跳转地址表

我设计并且编译了一个小VB应用程序:

Private Sub Form_Load()

MsgBox "Hello this is P-Code!!!", VBInformation, "Example"

End Sub

我通过SoftICE的符号载入器(symbol loader)调入MSVBVM60.DLL (VB6虚拟机) ,设置BPX on _rtcMsgBox。 当SoftICE 中断时,我按 F12返回调用 _rtcMsgBox 的代码:

call eax  // 调用 rtcMsgBox
cmp edi,esp  // 我们在此
jnz 66105595  // 检查堆栈指针
xor eax,eax  // 准备寄存器 eax 去调用缓存区中的下一个操作码 :-)
mov al,[ESI]  // 在al中装入待执行的操作码, 上面的演示中,它是36h
inc ESI  // 增加指针偏移量(在 ESI 寄存器中)
jmp [eax*4+660FDA58]  // 跳转到解释伪指令操作码 36h 的处理程序

我们看到,就如推测的一般,解释器从缓存区中读入操作码(P-Code伪指令)并且放入8位寄存器AL, 将此操作码作为一个地址偏移量,跳转到相应的处理子程序。我上面提供的小例子中的 36h 既是作为了一个地址偏移量 (这是一个聪明,灵活的办法去处理不同的指令避免了成千的分支判断检查。利用如此聪明的设计方法还真不太象Microsoft一贯的作风)。 如果我们继续跟踪下去,我们会看到利用寄存器ESI作为伪指令操作码缓存区指针来解释VB应用程序的步骤被不断地重复执行着。 其实,在VB 的虚拟机运行时, ESI寄存器始终指向包含被执行的VB应用程序的全部P-Code操作码的一个缓存区。 因此,在SoftICE中,我们总可以利用“ d *ESI”发现将要被执行的操作码。

的确有如我们已经发现的: ESI寄存器包含了一个指向伪指令操作码的缓存区的导航指针; AL包含下一个将要被执行的操作码(1字节)。 最有趣的指令语句是无条件跳转: JMP [4*EAX+ADDRESS]。 你可以使用从缓存区中读取的字节作为一个偏移量,进入地址跳转表中的相应程序入口(基于ADDRESS)。 这个跳转地址表的最大尺寸很容易被推算出来: 最大的偏移量AL (256) * 我们程序中的索引偏移量4(因为每个地址长度占4个字节) : 256 * 4 = 1024 bytes

以上事实和发现证实了我对VB中采用了地址跳转表技术的推测。 基于Microsoft的文档,它声明标准的P-Code 包含 了256 个操作码,其它作为扩展的操作码。 这提醒了我,我的可爱的老PC也有256个不同的操作码可以执行。不过,实际上包含的操作码要多的多。如果仅有256独特的可识别值-伪指令操作码,那更多的操作码是如何工作的呢? 它们是什么值? (我从事反汇编器的经验使我认识到) 这很容易做到: 256个被保留的识别值中有些是作为(指令)前缀使用的。

当解释器发现这样的前缀,它指示一个扩展的指令集合(给出一组新的可能有256种可能性的指令集)。因此,使用前缀可以允许无限制的指令操作码设置。 注意,每个前缀应该有它自己的新的跳转地址表。 稍后,我们会看到许多在当前的VB P-Code代码中使用的前缀,以及如何定位它们的地址跳转表。 为了确认我们发现的跳转地址的正确性,我反汇编了VB虚拟机,并且探察研究了跳转地址表中的所有的情况。 正如我们期望的,所有伪指令都有对应的处理程序。 而后,我分析了VB虚拟机的引擎(DLL)中这个表的内容验证它在虚拟机中的地址入口,以及包含的内容。 我查看了一些伪指令的处理程序,它们有着同样的结构:

它们从寄存器ESI指向的缓存区中读取数据并执行某些指令。我找到了我想要的-每一条伪指令的跳转地址。

下一步是用我们自己的内容替换跳转地址表(包含全部操作码的处理程序入口地址)。 这些新的地址应该指向我们自己的处理程序,它们在我们的Debugger的 DLL中。 在所有操作码真正被VB虚拟机执行前,先通过我们预先设置的程序的处理。 VB虚拟机中的处理程序被我们自己的处理程序替换(以C的调用方式),所有的寄存器和标志位被保存,并在执行下一条P-Code指令前恢复为原先的内容。

这里是我们自己的Debugger处理程序的开始和结束 :

__declspec( naked ) void DebuggerProc()
{

_asm {
mov VBDebugger.OldStack_ESP,esp  // 我们保存VB 的堆栈
mov VBDebugger.OldStack_EBP,ebp  // 基地址指针和堆栈指针
pushad  // 保存所有寄存器状态
pushfd  // 保存所有标志

push ebp  // 现在我们放一个标准的调用框架
mov ebp, esp
sub esp, __LOCAL_SIZE  // 如果我们能有一些局部变量,这里应该预留出空间,从堆栈中减去相应的尺寸

// 这里是 Debugger的控制代码部分
// ...
// ...

// 这里我们修改跳转地址。由于它们不能在编码时修改,因此我们在VB虚拟机运行时采用自修改代码(SMC self-modifying code)设置跳转地址表:P

_asm {
mov eax, offset JmpOffset
add eax, 3
mov ebx, [VBDebugger.RedirTableAddr]
mov [eax], ebx
}

mov esp, ebp // 我们保存的堆栈的初始框架
pop ebp
popfd  // 恢复标志
popad  // 恢复寄存器

// 最后
// 如果我们要以内存编辑模式修改被调试的VB应用程序中的伪指令操作码,我们可以在AL中改变它。因此这样的改变只有当我们要修改的操作码将要跳转入VB虚拟机中相应的控制程序时才可进行。

_asm {
cmp [VBDebugger.OPCODE_CHANGE],0
je NoChange
mov al,VBDebugger.Opcode
NoChange:
mov [VBDebugger.VMAddress],0
}

JmpOffset:
// 将程序的控制权返还VB虚拟机。 注意这个代码是在运行时间自修改的
jmp [eax*4+VBDebugger.RedirTableAddr]

}

看到这个程序我们可以发问:谁说C不够强大? 就像你看到的,通过使用 “Naked” 指示,我们能凭着我们自己的品味要求创建程序。 事实上,这个指示(“Naked”)经常在为Window设计的驱动程序(VxD)中使用。 这个程序的行为有如一个hook(钩子程序)存在于原来的代码和虚拟机之间。 就像你所见的,这个程序除了返回控制到原始的跳转表什么也没做, 但它可以控制那些我们需要它控制的VB虚拟机执行的伪指令。

当我们开始有关P-Code的研究时,我们缺少有关资料,Microsft保留了有关技术规范说明。如果你想获知P-Code的秘密,你必须和微软签订一份叫做NDA (non disclosure Agreement)的保密协议,你保证不散布任何有关信息(这些信息会使得某些公司能够通过合法行对微软产生威胁)。因此你只能找到如下的可怜的一点零星资料,你可以在这里发现它们:

http://msdn.microsoft.com/archive/default.asp?url=/archive/en-us/dnarvc/html/msdn_c7pcode2.asp


此外 Exdec, 是一个由Josephco制作的 P-Code 反编译器。 Exdec可以反编译任何P-Code文件,但操作码的显示不够完善和全面(仅仅第一个字节)。 我们稍后会知道原因。

跳转地址表提供了一个基础。 我们准备了一个代码补丁,它将作为一个新的区段部分(section)被加到VB虚拟机(DLL)中。 这个补丁会获得初始数据并且调用我们的Debugger。 一旦Debugger准备好(安装好钩子程序“hooking”到虚拟机, 我们的补丁程序将继续载入VB的虚拟机,重置控制权到VB虚拟机DLL的OEP(程序入口)。 这个方法有一个主要的缺陷: 必须修改VB的虚拟机DLL。

我们可以通过创建一个载入器(loader)消除必须修改VB虚拟机代码的必要性。 一个小应用程序(载入器)以悬挂方式(suspended mode)开始一个可执行VB程序,使用 GetThreadContext获得其入口地址, 拷贝一个代码补丁(它负责装入Debugger的 DLL )。 一旦补丁程序被执行, 它使用synchronism APIs SetEvent 和 WaitForSingleObject通知主进程。 一旦补丁程序完成,他将恢复原始程序代码,并且通过调用SetThreadContext返回到原程序的OEP,就象什么都没有发生过。

补丁程序运行后,当我们的Debugger得到控制时产生了一个问题。当原始应用程序代码和部分VB数据被临时替换成我们的Debugger需要的数据时,我们必须运行我们的Debugger在一个独立的线程中,这个线程会检查一个内存位置是否包含VB5的签名,它(我们的移植检查过程)用于指示载入器是否成功执行,并且进行了我们预设的(各种替换)工作。

我们Debugger中的移植检查过程也会校验被装入的是否为一个可执行的VB 应用程序,这是通过察看输入表是否包含MSVBMXX。DLL(其中 XX 可能是 05或 06)。

另一个问题是不同版本的VB虚拟机操作码地址表存在差异。因此我们必须针对不同版本的虚拟机进行个案分析和处理。 这是个麻烦的方法。因为一旦有新版本的虚拟机被发行,我们就要修改我们自己的载入器代码。 这个问题一直没能得到解决,直到最近,我们才想出了比较理想的解决方案。 就如我以前说的,操作码地址表在VB虚拟机的引擎区域(在名为 .ENGINE 的区段中)。 这个表的特性之一就是它所包含的所有地址必须指向相同的区段空间, 因此我们设计了一个算法,它将定位第一组256个相邻的双字(DWORDS),这些值包含在.ENGINE 区段, 并且这将确定地址表:-)。

* 第二步

恢复操作码与重获VB伪指令操作码助记符

如果我们开始就注意到了虚拟机的符号文件(DBG),这一步对我们来说就简单了。 一个办法是利用JosephCo的 Exdec反编译器获得助记符。 这并不容易搞定。 我没有一个个去找操作码, 而是假设Exdec(它的DLL)包含了一个操作码列表。 它也正是那样(我用一个十六进制编辑器定位和确定每个助记符的位置)。关键点:我发现部分操作码使用了前缀-FF,FE,FD,FC,FB (Lead0, Lead1, Lead2, Lead3 and Lead4)字节。每个前缀产生一个新的操作码表,共有:

( 5 前缀prefixes + 一组标准操作码设置) * 256 伪指令操作码 = 1536 个伪指令操作码

正如你所见,VB的操作码不少,不过许多没有作用,而且一些是多余的。例如:在VB6的虚拟机中,执行同样的操作 Lead4 前缀不使用自己的操作码处理程序跳转表而是直接转到操作码46h的处理程序中(这可以通过反编译VB虚拟机得到验证)。 就象我上面提到过的,当我们已经确定了一些操作码的行为时,我们认识到VB的虚拟机Debugger 文件包含全部它自己的助记符信息(处理程序名称,地址,每个助记符的名称)。我们是使用SoftICE转存(dumping)DBG内容到文本文件获得这些信息的。

这里列出一小部分:

相对虚拟地址 符号名称
RVA Size   Symbol name
0F103D8Bh 34 CCyR4
0F103DADh 19 CCyVar
0F103DC0h 9  CBoolCy
0F103DC9h 0  CBoolR8
0F103DC9h 38 CBoolR4
0F103DEFh 32  CStrVar
0F103E0Fh 18 CStrBool
0F103E21h 34 CStrR8

通过上面的例子,你看到了一些P-Code助记符; RVA是它们在虚拟机中的相对地址偏移。 不幸的是,这些地址在不同版本的VB虚拟机中不相同,但我们的Debugger可以通过启发式搜索方法处理地址跳转表。 不同版本中的一些操作码助记符可能不同,但功能不变。 应用我们已经掌握的信息我们初步的做了一个伪指令操作码反编译器。它还仅能显示正在被执行的操作码,我们必须发现每一条指令的精确长度。这是全部工作中最艰巨的任务: 必须分析每一个操作码处理程序,在其开始和结束,检查操作码缓存区尺寸。

我们分析了1000多个操作码处理子程序,虽然有些很短,但花费了大量时间。 起初,我们假定操作码的缓存区尺寸是固定不变的,但不幸的是它们不同。 一些指令需要许多参数入栈,这使得它们的尺寸总是变化的。 这样的操作码不多,但如果我们不够小心谨慎就会在对相应处理过程的分析中产生错误的认识。大概这就是为什么JosephCo(在他的反编译器Exdec中)选择了不完全反编译的理由。 反向工程质量的保证就是必须对需要分析的目标(具体到每一个基础指令)进行最深入的理解,认识和解释。

我们将工作进行了分解; 每一部分基于不同指令组的尺寸。 这个工作结束后,我们有了全部的伪代码的尺寸,我们作出了一个能够产生比较象样的反编译代码的工具(WKTVBDE)。 勿容置疑地说,由于不可避免的人为错误,我们必须不断修正操作码的尺寸定义。甚至今天,我们觉得仍有一些错误存在。因为一些指令并没有在所有的应用程序中被使用,全部测试它们是不可能的。到目前为止,对于我们的Debugger (WKTVBDE 1.3)中还没有错误的操作码被报告。1


* 第三步

增加Debugger的基本功能特性

这一步有更复杂的编码和需要研究的问题。添加输出表(Export tTble)并不太难,这一步由载入器(loader)通过分析PE 文件头来实现。 然后Debugger 将获得跳转地址表。 随后,我们构造并建立一个断点状态表(激活/非激活/空)。 如此,我们可以在VB虚拟机的任何API处设置断点。 一个类似的方法是在伪指令上设置断点。 与常见的一般调试器(Debuggers)不同: 我们可以在任何一个给定的指令上设置断点。因为我们的Debugger在每一个操作码被虚拟机执行前已经全面控制了它们。

随后,我们在实际的代码中加入断点。比如在一个给定了地址的操作码上设置断点。 这些断点被保存在我们的Debugger中一个动态连接表中。这有几个优势:断点数量不受限制;对于大量的断点信息,可以动态调整内存的使用。

断点的基本功能并不复杂。简单地说,当Debugger获得控制,它为了自身的应用,保存操作码地址缓存区内容。 随后Debugger显示这个地址,并将存储的断点与之比较。如果这个操作码地址已经存在于用户断点设置列表中(状态为:活动),Debugger则停止被调试的VB应用程序的执行。 内存编辑器/查看器允许被调试的程序被编辑、查看和内存内容转存。

我们已经尝试尽量优化和确认指针操作的有效性。但我们依然无法全部避免和排除那些无效的内存访问操作(虽然象这样的非法操作在Debugger的内存编辑器中几乎是不可能的发生的)。 有一种可能,当你在调试程序进行内存修改时,毫无理由的程序突然中止。 这种情况可能发生在我们使用不当的操作码替换原来的操作码的时候(比如操作码要求的参数个数不同),这种行为可能导致虚拟机在解释执行时发生运行错误中断。

当用户清楚自己的调试行为时这种状况一般不会发生。在Debugger的新版中,我们将控制这种状况的出现,保存发生错误之前的各种状态,并允许程序继续处理随后的正常指令。无论如何这种状况仅发生在使用SoftICE替换了错误的代码时。 如果我们搞乱了什么,出现错误的结果是合乎逻辑的;-)。

现在,所有的处理过程已经被图形化地表现在Debuggers Window窗口中。我们使用不同的颜色代码来标识我们已经设置了断点的伪指令行。 我们的Debugger由显示一个控制列表框开始。 有如你所见,我们采用了怀旧式的颜色调配方案。 为了方便用户使用,快速上手,我们尝试采用一种符合用户平常使用习惯的主程序界面设计方案(我们的Debugger采用了基于绿色,黑色的配色方案)。

Mr. Silver
--------------------------------------------------------------------------------
& 1998-2003 CrackZ. May 2003。