VB6 P-Code 函数的二进位重用 – Avast

重用来自恶意软体的二进位码是我最喜欢的主题之一。二进位重新工程以及能够按照自己的意愿操控编译后的代码,的确是一项惊人的技能。同时,将恶意软体的解密例程转变为为我所用,这一点颇具诗意。

这个话题这几年来反复出现。之前的文章涵盖了基于发射的撕裂技术 [1]、exe 转 dll 的转换 [2]、基于模拟器的方法
[3],甚至还有将恶意软体转换为基于 IPC 的解码服务 [4]。

上述都是本地代码的操作,因此可以直接处理。拆解简单、调试简单、修补亦然。(当然「简单」是一个相对的词 :))

最近我一直在研究 VB6 P-Code,并开发一个 P-Code 调试器。我的一个目标是找到一种方法,使用我的参数调用从恶意软体中提取出的 P-Code函数。能够利用现有的代码而无需重建它(包括所有细微差别)是一个非常强大的能力。

那么这在 P-Code 中是否可能呢?事实证明,这是可行的,接下来我将向你展示如何做到这一点。

下面提炼的知识是我在一个为期八个月的 VB6 运行时和 P-Code 指令集的研究项目中所揭示的一小部分。

本文包含 11 种代码范例,展示了利用这项技术的各种情境 [5]。

偏移专注
在本文的几个地方,可能会出现 VB 运行时的偏移。所有偏移均参考于 md5: EEBEB73979D0AD3C74B248EBF1B6E770 的副本
[6]。微软很友善地为这个版本发布了调试符号,包括 P-Code 引擎处理程序的符号。

进入障碍
VB6 运行时被设计为以未纪录的格式载入可执行文件、dll 和 ocx控制项。这一格式包含许多复杂的相互关联结构,布局了嵌入的表单、类结构、依赖等。在启动过程中,运行时本身还需要进行某些初始化步骤,以准备使用。

如果我们希望在不依赖 VB6 主可执行文件上下文的情况下执行 P-Code 缓冲区,有几个障碍需要克服:

VB运行时初始化

可执行文件的标准运行时初始化通过 ThunRTMain 导出进行。这是载入 VB6 可执行文件的主要入口点。这个函数接收一个参数,即顶级 VBHeader 结构的地址。这个结构包含了完整复杂的层次结构及其中所有内容。

虽然我们可以利用这一途径,但仍有更简单的方法可以达成。从 ThunRTMain 开始,也可能会在进程终止时引发一些问题,因此我们会避开它。

2003年,我在探讨 VB6 生成标准 dll 的能力时发现了一个通过 CreateIExprSrvObj 导出的第二条运行时初始化途径。

这个导出简单易调用,并自动执行大部分运行时初始化。不过,某些 TLS 结构字段仍然留空。在测试中,大多数情况运行良好。唯一发现的错误发生在使用本地 VB文件命令、MsgBox 或内建的 App 物件时。

经过一些额外的工作,发现可以手动填充 TLS 结构,以恢复大部分本地功能。

最后,如果 P-Code 缓冲区创建了 COM 物件,还必须手动调用 CoInitilize

复制基本物件结构

一旦 CreateIExprSrvObj 执行完毕,我们可以从加载代码中多次调用 P-Code 流。结构初始化非常简单,仅需要填写以下字段:

如果 P-Code 程序使用全局变量,则 codeObj.aModulePublic 字段也必须设置为一个可写内存块。这在 globalVar
complex_globals 范例中都有展示。我们甚至可以在这里提前初始化这些变量。

除了填充这些主要结构外,我们还需要根据特定的 P-Code 重新创建常量池。最后,我们还必须更新 P-Code 中一个结构字段,指向当前的物件资讯结构。

虽然这听起来很复杂,但有一个生成器工具可以在大多数情况下自动为你完成所有工作。接下来的部分将对以下代码进行更详细的解释。

寻找转入 P-Code 执行的进入点

VB6 P-Code 的执行发生在调用 VB 运行时的 ProcCallEngine 导出时。下面的存根是 VB编译应用内部用于在子函数之间转移执行的相同机制。

传入 EDXoffset_sub_main 参数是目标 P-Code函数的后结构地址,该结构定义了函数的属性。我们将在后面的部分中讨论这个结构。

上面的 asm 存根展示了无参数调用 P-Code 函数的默认情况。可以查看一个视频,展示了在调试器中运行的情况 [7]。

decrypt_test 范例中,我们探讨了如何调用一个被撕裂的函数,其具有复杂的原型和一个 Variant
返回值。这个范例展示了如何重用从恶意可执行文件中提取的 P-Code 解码器。在这里,我们可以调用提取出的 P-Code 函数,并将我们自己的数据传递给它:

理解 P-Code 函数布局

编译可执行文件中的 P-Code 函数由一个跟随在实际字节码后的结构相连。这个结构在 VB 运行时符号中称为 RTMI,反向工程社区则将其称为
ProcDscInfo。以下是这个结构的部分摘录:

当我们从编译的二进位文件中撕取 P-Code 专用函数时,我们也必须提取配置的 RTMI 结构。ProcCallEngine
需要这些信息才能成功执行 P-Code 例程。

当我们将 P-Code 块从目标二进位移到其他地方时,还必须更新连结到我们的新物件资讯表。

以下是生成代码中设置的内容:

在这里,rc4 缓冲区包含整个被撕裂的函数,开头是 P-Code,然后是从偏移 0x3e4 开始的 RTMI
结构。我们然后将手动填充的物件资讯地址补丁到 RTMI.pObjTable 字段中。一旦完成,P-Code 就准备好执行了。

代码生成

在开发这种方法时,我们必须从已知量开始。对我来说,我们自己编写的测试代码通常是在 VB6 集成开发环境中完成的。然后,这段代码会被提取,使用一种可以生成 C或 VB6 源代码的工具,以便独立执行。

本文使用的生成器工具是免费的 VBDec [8] P-Code 调试器。

在探索这项技术时,范例代码已被优化以遵循几种清晰的约定。为了这个研究,所有代码范例均从单个模块中的函数撕取。这一设计的目的是让所有子函数的访问都通过
ImpAdCall* 操作码直接与常量池中的函数指针相连接。

从实例表单或类编译单元中提取的代码需要支持以复制 VTable 布局以供 *Vcall 操作码使用。虽然这是可行的,但我将其留作未来的工作。

提供的范例大量使用回调,与主代码紧密集成。这样便于通过 C 主机以简单的方式集成调试输出。

回调通过标准 VB API Declare 语法访问,这是语言的核心部分并有充分的文档支持。以下是从 P-Code发送数字和字符串调试信息到主机的示例。

给 VB 直接访问主机函数只需将其地址设置在对应的常量池插槽中。

使用 VBDec 撕取函数非常简单。只需在左侧的 treeview 中右键单击函数,选择 Rip 菜单选项即可。VBDec
将自动为你生成所有嵌入数据。还可以通过右键单击顶级模块名称一次性撕取多个函数。

相应的常量池将与用于更新物件资讯指针和调用相互链接子函数的 asm 存根一起自动生成。

一旦提取/生成完成,开发者就需要将数据集成到提供的样本框架之一中。

提供了一系列从非常简单到相当复杂的范例。样本包括:

| 样本 | 描述 | | — | — | | firstTest | 简单加法测试 | | globalVar | 全局变量测试 | | structs | 将结构从 C 传递到 P-Ccode | | two_funcs | 两个 P-Code 函数的互相链接 | | ConstPool | 测试解码二进位常量池条目 | | lateBinding | 晚绑定 SAPI 语音示例 | | earlyBinding | 早绑定 SAPI 语音示例 | | decrypt_test | P-Code 解码器,带复杂原型 | | Variant Data | C 主机从回调返回变型类型到 P-Code。 | | benchmark | C/P-Code 和纯 C 中的 RC4 基准测试应用 |

理解常量池

每个编译单元,如模块、类、表单等,都会获得自己的常量池,该常量池为该文件中的所有函数共享。常量池条目是在文件被编译器自上而下处理时按需构建的。

常量池可以包含几种类型的条目,例如:

  • 字符串值(特别是 BSTRs
  • VB 方法的本地调用存根
  • API 导入的本地调用存根
  • COM GUIDs
  • COMDEF 结构保存的 COM CLSID / IID
  • CodeObject 基偏移(在本文中不适用)
  • 在启动时填充的内部运行时 COM 物件(不支持)

VBDec 能够自动解读这些条目并确定它们的代表意义。一旦确定了正确类型,便能生成将这些条目填充到宿主代码中所需的 C 或 VB源代码。常量池检视器表单允许你手动查看这些条目。

在测试中,它的表现相当良好,输出完整的常量池,几乎不需要任何修改。

要与主机集成回调时,如果将 dll 名称设置为 “dummy”,将自动被视为主机回调。否则它将被字面转换为
LoadLibrary/GetProcAddress 调用。

某些常量池条目可能会显示为未知。当你单击特定条目时,相应的原始数据将加载到下方文本框中。如果这些数据显示为所有 00 00 00 00,则这是内部 VB运行时 COM 物件的引用,通常在初始化过程中会设置为实例。

使用 App 物件时会出现这种情况。通常这会在初始化时设置为 @6601802F,位于运行时的 _TipRegAppObject
函数中。此类条目在此技术下目前不受支持(而且在我们的上下文中也缺乏意义)。

互链的子函数是被支持的。相应的本地存根将自动生成,并且在常量池中会有条目。

COM 物件的早绑定和晚绑定也是支持的。晚绑定完全通过常量池中的字符串进行。而对早绑定,会看到自动生成的 COMDEF 结构和 CLSID /
IID
数据。

以下是来自早绑定样本的摘录,用于加载 Sapi.SpVoice COM 物件。

此代码的生成通常由 VBDec 自动完成,但有时该工具无法自动检测出所指定的常量池条目的类型。在这些情况下,你可能需要手动探索常量池并提取数据。

在上述场景中,常量池地址的文件数据可能看起来类似于以下内容:

如果我们将其视觉化为 COMDEF 结构,我们可以看到值 0、0x401230、0x401240、0。查看这些虚拟地址的文件偏移,我们找到了上述
GUID。

字符串条目以 BSTR 形式保存,这是一种长度前缀的 Unicode 字符串。由于我们完全控制常量池,并且 BSTR
可以封装二进位数据,因此可以使用 SysAllocStringByteLen 将加密字符串直接包含在常量池中。binary_ConstPool*
范例展示了这一技术。你还可以动态替换常量池条目以随 P-Code 的运行而变更功能。早绑定范例中找到了此技术的示例。

注意: 使用 SysAlloc* 字符串函数来获取真实的 BSTR
对于常量池条目非常重要。当字符串被运行时使用时,它可能会试图重新分配或释放它们。

扩展 TLS 初始化

VB6 运行时在「线程本地存储 (TLS)」中存储若干关键结构。运行时的几个函数需要这些结构初始化。这些结构对于 VB错误处理例程至关重要,并且也可能涉及文件访问函数。

以下是 rtcGetErl 导出的代码。该函数检索用户指定的与最近发生的异常有关的错误行号。

从这段代码片段我们可以看到,运行时在偏移 66110000 存储 TLS 插槽值。一旦利用 TlsGetValue
获取到实际的内存地址,结构字段 0x98 将返回作为存储的最后错误行号。这样我们可以开始理解各种结构偏移的含义。

即使没有对完整的 0xA8 字节结构进行全面分析,我们也可以比较完全初始化进程的值与通过 CreateIExprSrvObj 导入初始化的值。

一旦进行比较,观察到两个主要空白插槽通常指向其他分配。

  • 字段 0x18 – 通常在 EbSetContextWorkerThread 中设置为 @66015B25
  • 字段 0x48 – 通常在 RegAppObjectOfProject 中设置为 @66018081

字段 0x48 是用于访问内部 VB App. COM 物件的。这个对象在我们的场景中没有意义,若不填写也不会引发异常。如果我们需要为兼容性复制
COM 物件,我们可以插入一个虚拟对象。

偏移 0x18 的分配仅在我们希望使用内建 VB 文件操作命令或 MsgBox 函数时才需要。

如果需要与撕裂的代码兼容,看看手动分配是否会让运行时正常运行是很有趣的。

以下代码旨在动态查找 TLS 插槽值,检索 tlsEbthread 的内存偏移,然后手动将新分配链入缺失的 0x18 字段。

一旦上述代码集成,对 VB 本地文件访问功能的完全访问便被恢复。再次强调,这种扩展的初始化并非总是必需的。

调试集成

在测试这种技术时,最佳的做法是从你控制的代码开始。这样你可以对其熟悉,并摸索出与不同函数原型一起工作(和识别)的感觉。

第一步是像往常一样在 VB6 IDE 中编写和调试你的 VB6 代码。为了准备运行作为字节缓冲区的代码,你可以在 VB 代码中到处添加回调,以调用 APIDeclare 常规,这些都能访问 C dll 的导出…其实你不必实际写出这个 dll,但你可以这样做。在本地 C 加载器(甚至 VB 托管的
Addressof 回调例程)中,调用是完全相同的。

如果你要调用带有特定原型的 P-Code 函数,那将是集成过程中最具挑战性的部分。提供的范例可接受
intstructuresreferencesVariantsboolsbyte arrays
的参数。你须非常注意参数是以 ByVal 还是默认的 ByRef(指针)传递。

此外,还要注意函数的返回类型。如果没有定义参数/返回类型,则默认为 COM Variant。VB函数通过在调用函数之前向堆栈推送额外的空返回值来接收变体返回值。简单的数字返回值则正常通过 EAX 返回。

在与回调交互时,确保回调被定义为 __stdcall。所有标准 VB6 <–> C 开发知识适用。你可以通过与标准 C dll 工作并在从 dll终端启动 VB6 exe 主机调试时掌握这些规则。

如果有疑虑,你可以创建简单的测试来调试函数原型。对于以上给出的复杂原型去密码化示例,我让 VB6 的 sub main() 代码调用 rc4
函数,使用预期参数在其自然环境中进行测试。我可以然后调试 VB6 可执行文件,以便观察传递到我的 C 加载器中,以获得更深入的见解。

这可以通过本地调试器完成,设置一个断点 @6610664E 在 VB 运行中的 ImpAdCallFPR4 处理程序上。在这里,你可以检查进入目标
P-Code 函数前的堆栈。在这方面,VBDec 的 P-Code 调试器也非常方便。

调试时,最好将 VB 运行时的引用副本放在目标可执行文件的同一目录下,以使所有偏移量与你的运行时反汇编调试符号对齐。如果你使用 IDA 作为调试器,请从
VB 运行时的反汇编开始,然后在调试器选项中设置目标可执行文件。Ollyx64dbg 等专注于 ASM 的调试器比 Visual Studio这种以源代码调试为主的工具更受推荐。

结论:

在进行恶意软体分析时,与各种类型的自定义解码例程进行互操作是一项常见任务。对此有几种方法。你可以坐下来仔细反向工程整个过程,确保你的代码与之 100%
兼容,或者你可以尝试探索基于撕裂的技术。

撕取解码器在我的个人计划中相当普通。在研究 VB 运行时的内部结构时,自然想知道是否可以将相同的概念应用于 P-Code 函数。

经过一些实验和合适的生成器,这种技术证明是稳定且相对容易实现的。这些实验也加深了我对运行时使用的各种结构的理解,并使我更加欣赏 VB6 如何能够与 C代码紧密集成。

希望这些信息能为你提供一根新的箭,或者至少是一段有趣的旅程。

[1]
[2]
[3]
[4]
[5]
[6]
[7]
[8]

标签:
,,,

分享:XFacebook

Leave a Reply

Your email address will not be published. Required fields are marked *