这款游戏当年我说过,属于属于无壳、无混淆、无加密的“三无”游戏,但是游戏BGM嵌入在了CD中,就算成功破解,只要没有加载CD也无法播放BGM。由于当时对汇编语言的认知没有这么深,再加上还涉及一些Windows API的问题,所以并没有完成BGM本地化的破解,但当年已承诺会继续研究。
实际上当年破解的方法存在很大缺陷:
把CD验证结果取非处理来实现的破解,这样一来,如果插入了正版CD会识别成未插入CD,这是致命的!
一晃十年过去了,欠的东西总是要还的,好!今天兑现承诺!
那么BGM本地化怎样实现呢?好不好实现?我们今天就从汇编、Hook、Windows API这几个角度来重新分析一下这款游戏。
样本与使用工具
样本:
aHR0cHM6Ly9wYW4uYmFpZHUuY29tL3MvMTVOM0JHM1k0dFdGeHBVejVEX2ZVRUE/cHdkPThlMzg=
工具:
| 序号 |
工具 |
说明 |
| 1 |
虚拟光驱软件 |
任意一款即可,只要支持MDF格式的光盘镜像文件 |
| 2 |
Exact Audio Copy |
CD音轨抓轨工具 |
| 3 |
PEiD |
PE程序查壳工具,检查EXE文件是否被加壳保护 |
| 4 |
Interactive Disassembler |
下称IDA,很重要的静态分析工具 |
| 5 |
CheatEngine |
针对游戏修改的万能工具,支持动态调试 |
| 6 |
Visual Studio |
用于编写外挂BGM程序,其他IDE也可 |
| 4 |
010 Editor |
十六进制编辑器,以备不时之需 |
因为文章针对小白,所以我们还是要从基础知识开始讲解,暂时不讲流程,先讲基础知识。
0x00 基础知识
我们在之前的《血战上海滩》修改器的文章中已经列出了一些基础知识(如断点、栈、常用指令等),本文章将不再重复讲解,请同学们先移步至那篇帖子学习,给你10分钟时间,只需学习“知识储备”那一节即可。
【致初学修改器的你】如何优雅的杀敌以做到英雄无敌?记一次血战上海滩修改器制作过程
https://www.52pojie.cn/thread-2024353-1-1.html
接下来我们继续学习
你还必须知道的术语
除了了解了《血战上海滩》修改器教程中我列出的术语外,这些术语也必须了解:
(1)特征码 (Signature)
一串唯一的十六进制指令序列,用于在不同版本的文件中通过搜索来定位同一个功能函数。
(2)伪代码 (Pseudocode)
一种介于自然语言和编程语言之间的描述性语言,用于展示算法逻辑。
(3)交叉引用 (Cross-Reference / Xref)
查找某个函数或数据在程序中哪些地方被调用或引用,是定位关键逻辑(如查找某个函数的调用点)的利器。
(4)Windows API
应用程序接口,用于实现某个操作功能集合,这里单独列出一小节展开讲。
(5)钩子(Hook)
一种程序流程重定向的手段,这里单独列出一小节展开讲。
(6)Shellcode
一串可以直接在内存中运行的十六进制代码载荷。
(7)代码洞 (Code Cave)
利用程序中原有的空白内存区域,或使用 VirtualAllocEx 申请的新区域来存放 Shellcode。
(8)相对偏移 (Relative Offset)
JMP 指令跳转时计算的位移量。计算公式通常为:
目标地址 - 当前地址 - 5
(9)堆栈平衡 (Stack Balancing)
函数返回前必须将堆栈恢复到调用前的状态,本文的思路为向进程注入代码,所以注入前的返回的是几字节,注入后也得是几字节。
(10)句柄 (Handle)
Windows 系统分配给进程的一个唯一标识符(IntPtr),只有获取了进程句柄,我们才能进行内存操作。
(11)小端序 (Little Endian)
x86 架构下内存存储数据的顺序(低位在前)。
(12)进程标识符 (PID)操作系统赋予每个运行中程序的唯一数字编号,用于 OpenProcess 定位目标。
你还必须知道的以下跳转指令
除了了解了《血战上海滩》修改器教程中我列出的汇编指令外,这些指令也必须了解:
| 指令名称 |
英文全称 |
说明 |
| JZ ./ JE |
Jump if Zero / Equal |
当标志寄存器ZF=1(即前一步比较结果相等或运算结果为0)时跳转。 |
| JNZ / JNE |
Jump if Not Zero / Not Equal |
当 ZF=0 时跳转。 |
| JBE |
Jump if Below or Equal |
无符号数比较,低于或等于时跳转。 |
你必须知道的Windows API的基本定义
Windows API 是一套由函数、宏、数据结构、数据类型和接口组成的集合,允许开发者与操作系统进行交互,通过它,应用程序可以调用 Windows 操作系统的功能,以执行各种底层操作,如文件管理、进程和线程控制、图形用户界面创建、设备输入处理、网络通信等。它主要以 C 语言风格的函数形式暴露给开发者,通常通过动态链接库(DLL)如 kernel32.dll、user32.dll、gdi32.dll 等实现。如果把应用程序比喻成家用电器,那么API就好比墙壁插座,给家用电器供电时,只需要将家电插销插在墙壁插座上,即插即用,而不需要了解插座的内部构造及接线方式。同时,API具有封闭性,它只会给你提供服务但你并不知晓它的源码,就好比墙壁插座的材料配方是商业机密,并且内部设计也是专利。
你必须知道的Hook和代码注入的基本定义
Hook(钩子)与代码注入是常见的进程劫持手段。这两项技术通常结合使用,使原本封闭的程序按开发者的意愿执行特定的逻辑,Hook 的本质是程序流程的重定向。它像是在原有程序的公路上设置了一个“绕道”标志,强制 CPU 在执行到某处时跳转到我们自定义的代码段。其中,内联钩子 (Inline Hook)是最常用最强大的Hook方式,通常分为权限解锁、指令覆盖、地址计算、堆栈平衡几个步骤。如果说 Hook 是“路标”,那么代码注入就是“修建新路”。它负责申请内存空间然后植入我们编写的指令。
你必须知道的常用指令的十六进制操作码
程序也是文件,也要以十六进制字节的方式存储,在逆向分析中,汇编指令通常是以十六进制操作码存储的,为了方便阅读,逆向工具会帮你翻译成可阅读的汇编代码,了解汇编指令的十六进制操作码,是为了避免修改字节指令时破坏了偏移导致程序崩溃。下面列举一些常用十六进制操作码
| 指令名称 |
十六进制操作码 (Opcode) |
功能简述 |
| PUSH EBP |
55 |
将基址指针压入栈,常用于函数起始处。 |
| MOV EBP, ESP |
8B EC |
建立当前函数的栈帧。 |
| CALL (相对/直接) |
E8 |
后面接 4 字节偏移量,用于调用程序内部函数。 |
| CALL (间接/外部) |
FF 15 |
后面接 4 字节地址,通过 IAT 查表调用系统 API(如 ds:MessageBoxA)。 |
| JMP (短跳) |
EB |
短距离跳转(通常在 127 字节以内),仅需 2 字节空间。 |
| JMP (长跳/近跳) |
E9 |
后面接 4 字节偏移量,可跳转到当前区段内的任意位置。 |
| JZ / JE |
74 |
当运算结果为 0(ZF=1)时进行短跳转。 |
| JNZ / JNE |
75 |
当运算结果不为 0(ZF=0)时进行短跳转。 |
| XOR EAX, EAX |
33 C0 |
将寄存器清零,常用于设置函数返回值为 0(表示成功或逻辑失败)。 |
| MOV EAX, 1 |
B8 01 00 00 00 |
将 EAX 赋值为 1,常用于强行通过版本或 CD 校验逻辑。 |
| RETN |
C3 |
从当前函数返回到调用者。 |
| RETN X |
C2 XX XX |
返回并从栈中清理指定数量的字节(如 RETN 4 或 RETN 8)。 |
| NOP |
90 |
空指令。什么都不做,常用于填充多余的指令空间或抹除危险的代码逻辑。 |
你必须知道的EXE应用程序区段基本定义及常见区段的功能
“区段”(Section),也称为“节”,是PE(Portable Executable)文件格式的核心组成部分。它们将程序的不同类型数据(如代码、全局变量、资源)划分到不同的连续内存块中,每个区段都有特定的内存属性和功能。如果一个EXE文件比喻成一家公司,公司通常由生产部、技术部、财务部、经理办公室等多个部门组成,行使各自的职能,区段也是如此,各尽其责。
PE文件常见区段(Section)一览表
| 区段名称 |
中文名 |
存储内容与功能说明 |
| .text |
代码区段 |
包含程序的主要可执行指令(机器代码)。此区段通常具有可执行(IMAGE_SCN_MEM_EXECUTE)和只读(IMAGE_SCN_MEM_READ)属性。 |
| .data |
读/写数据段 |
存放已初始化的全局变量和静态变量等可读写数据。 |
| .rdata |
只读数据段 |
包含程序中的常量、字符串字面量、调试目录信息等只读数据。 |
| .idata |
导入表 |
存储程序运行时需要从其他DLL中导入的函数和数据的信息(输入表)。在Release版本中常被合并到.rdata等其他区段。 |
| .rsrc |
资源区段 |
包含程序的各类资源数据,如图标、菜单、位图、对话框模板等。此区段是只读的,不应被重命名或合并。 |
| .bss |
未初始化数据段 |
用于存放未初始化的全局变量。在磁盘文件中不占空间或占很小空间,程序加载时由系统分配内存并初始化为零。 |
| .reloc |
基址重定位表 |
若程序无法加载到首选基地址(ImageBase),此表提供了需要修正的地址偏移信息,对DLL尤为重要。 |
你必须知道的IDA常用操作
IDA Pro(Interactive Disassembler)是逆向工程领域的“瑞士军刀”。它是千万计算机安全行业从业者不可或缺的装备。下面列举一些IDA的常用操作。
1、 基础视图与导航快捷键
空格键 (Space):在图形视图(Graph view)与文本视图(List view)之间切换。图形视图方便观察 JZ/JNZ 构成的逻辑分支,文本视图则方便查看机器码和绝对偏移。
G (Go to):快速跳转。按下 G 后输入地址(如 455666),IDA 会直接定位到该指令处。
Esc:后退键。回到上一个查看的位置,类似于浏览器的“返回”。
N (Rename):重命名。选中函数名(如 sub_455666)或变量名按下 N,可以改为有意义的名字(如 LoadData)。
(Semicolon):添加注释。在关键指令旁写下你的分析情况,以备追溯。
2、核心分析功能
F5:反编译 (The Magic Key):
这是 IDA 最强大的功能。选中一个函数并按下 F5,IDA 会将晦涩的汇编代码转换成易读的 C 语言伪代码。
Shift + F12:字符串窗口 (Strings Window):程序中硬编码的文字(如"CD-ROM"、“Disk”)通常是破解的切入点。
X:交叉引用 (Cross-References):用于查找某个函数被谁调用或某个变量在哪里被修改过。
你必须知道的IDA自动命名的前缀
| 前缀 |
全称 |
描述 |
示例 |
| sub_ |
Subroutine |
子程序/函数。代表一段执行特定功能的可执行代码块。 |
sub_410086 |
| loc_ |
Location |
跳转目标/标签。通常是函数内部JMP或JZ指令的目的地址。 |
loc_433333 |
| off_ |
Offset |
偏移/指针。指向另一个内存地址的数据变量(通常是指针或地址表)。 |
off_4668866 |
| dword_ |
Dword |
双字数据。代表4字节大小的全局变量。同理还有word_(2字节)和byte_(1字节)。 |
dword_4006688 |
| unk_ |
Unknown |
未知数据。IDA识别出这里存放了数据,但无法确定其具体类型(可能是未初始化空间或特殊结构)。 |
unk_410086 |
| j_ |
Jump thunk |
跳转中转/Thunk。通常是一个仅包含跳转指令的短函数,用于跳转到真正的目标函数。 |
j_memset |
| struc_ |
Structure |
结构体类型。标识一个结构体或类(如果开启了类型信息或PDB解析)。 |
struc_XYZ |
| g_ |
Global |
全局变量。IDA自动添加的前缀,用于标识全局变量(不常见,通常是用户或自定义)。 |
g_pModule |
| nullsub_ |
Null Subroutine |
空函数。IDA识别出此函数不执行任何操作,仅包含RETN指令。 |
nullsub_1 |
| align |
Alignment |
对齐填充。为了满足内存对齐要求而插入的填充字节。 |
align 10h |
| def_ |
Define |
常量定义。通常与结构体/枚举成员或常量值相关。 |
def_FILE_READ_DATA |
| proc_ |
Procedure |
过程。与sub_类似,有时用于标识特定的系统/库函数。 |
proc_GetMessage |
0x01 绕过CD验证
得到样本后,首先使用虚拟光驱软件加载样本中的光盘镜像,并安装更新补丁。
由于我们研究的游戏不兼容Win10以上的系统,所以我不得不从Win8虚拟机中进行。
VMware虚拟机是可以直连物理机挂载的虚拟光驱的,所以我们在虚拟机设置中的CD/DVD栏选择物理驱动器,下拉菜单选择所挂载的虚拟光驱盘符即可。
游戏安装完成后,这时候断开虚拟光驱,运行游戏主程序,会弹出“Please insert the Doraemon CD into CDROM drive!”消息框,不多解释了吧,程序未通过CD验证。
我们得到了非常关键的线索那就是光盘验证未通过的文本提示。
这时候,先别急着上IDA,我们先确定一下程序有没有加壳,如图所示,使用PEiD进行分析,我们发现,EP段就是text,说明程序非常干净,emmmm……我喜欢!
接下来我们打开IDA,载入程序。如下图所示,会让你指定加载方式,通常IDA会给你提供最常用的设置,保持默认即可。
稍等片刻,当你听到Windows默认提示音的时候就说明IDA已经完成程序的载入,还会贴心地帮你跳转到程序入口点这一行,并切换到图形视图。
这时我们还要修改一项非常重要的设定,让 IDA 在代码左边把十六进制操作码也显示出来。为什么呢?因为各种汇编指令都是用十六进制字节来代表的,我们后面要执行内存里的字节替换。如果不知道指令到底占了几个字节,替换时多写或者少写了一个字节,就会破坏数据偏移,游戏就会崩溃闪退。
我们来打个比方:假如学校组织一个年级的同学们去影院看电影,如果大家的座次已经排好了,结果突然有一个同学来晚了没座位,他想插在某两个同学之间,这样一来大家都要错后一个座位,座次就会改变,大家想一想,已经就坐的同学可能会同意吗?当然不可能!于是同学们就会因此发生口角而打起来。同学们的座次就是偏移,偏移被破坏就是插进去一位同学后的所有人的座次,同学们打起来就是游戏的崩溃。
所以显示这些字节,就像是给代码加上了‘刻度尺’,让我们动手术时更精准。”
下面将显示操作码的步骤:首先打开菜单栏的Options,选择General菜单项,在Disassembly选项卡中,找到Number of opcode bytes (non-graph)这一项,它的中文意思是“操作码字节数(非图形视图)”,这一项默认为0,建议设置成8-15,最高只能设置成15。
点击OK按钮,这样汇编指令的左侧显示对应的十六进制操作码就显示出来了。
接下来,我们的破解之旅就正式启程了。
刚才我们看到,CD未通过验证的提示信息是通过信息框来实现的,这说明,程序使用了user32.dll中的MessageBoxA这个函数接口,我们先去查一下Import导入表,核实一下是否正确。
点击菜单栏的 View -> Open subview -> Imports菜单项,打开导入表界面,也可以通过点击“Imports”选项卡进入。
按函数名排列,向下拉,找到了MessageBoxA这个函数,它在EXE的的绝对地址是0x004B81F0。
刚才我们已知晓光盘验证未通过的文本是什么,那么我们就先搜索它,按下Shift + F12,打开字符串窗口
随后按下Ctrl + F,搜索这个字符串,一般我们直接搜索关键字眼,例如“CD”、"Disk"等即可。
双击搜到的结果,我们成功定位到它所在的地址。
我们看到,这里的代码如下
.data:004C0BB4 ; CHAR aPleaseInsertTh[]
.data:004C0BB4 aPleaseInsertTh db 'Please insert the Doraemon CD into CDROM drive!',0
.data:004C0BB4 ; DATA XREF: sub_437059+1E9↑o
这说明我们所寻找的文本在sub_437059中,这个函数因起始行是437059而得名,并且这个引用位于437059+1E9处。按X键即可查询交叉引用情况,注意,一定要把光标放在“; CHAR aPleaseInsertTh[]
”这一行才有效
按下X快捷键后,我们从弹窗中查询到“Please insert the Doraemon CD into CDROM drive!”文本只有一处引用。
双击后,就来到了它的附近。
我们看到,这里执行了几个压栈操作,随后就执行了call指令。
.text:0043723B 6A 31 push 31h ; '1' ; uType
.text:0043723D 68 AC 0B 4C 00 push offset aError_0 ; "Error"
.text:00437242 68 B4 0B 4C 00 push offset aPleaseInsertTh ; "Please insert the Doraemon CD into CDRO"...
.text:00437247 6A 00 push 0 ; hWnd
.text:00437249 FF 15 F0 81 4B 00 call ds:MessageBoxA
可以看出,程序在这里引用了Windows API的MessageBoxA函数。大家别急着继续,我们要知道为什么程序只push了四个数据?为什么0x00437249这一行call指令的操作码是“FF 15 F0 81 4B 00”,而不用E8 F0 81 4B 00?
先说第一个问题,MessageBoxA一共有四个参数,根据微软官方的开发文档,这个函数的用法是怎这样的:
int MessageBoxA(
[in, optional] HWND hWnd,
[in, optional] LPCSTR lpText,
[in, optional] LPCSTR lpCaption,
[in] UINT uType
);
参数1为所有者的句柄;参数2为提示框的文本内容;参数3为提示框的标题文本;参数4为按钮类型。
因为时间和篇幅原因,这里只作简单描述不展开讲,大家移步到微软官方的开发文档中去了解。
https://learn.microsoft.com/zh-cn/windows/win32/api/winuser/nf-winuser-messageboxa
再说第二个问题,刚才列出的汇编指令操作码中,操作码FF 15是间接引用的call指令,也说过MessageBoxA函数所在的地址偏移是0x004B81F0,小端存储方式为F0 81 4B 00。由于EXE内部函数的相对位移固定所以可以直接引用(E8),但是外部API函数地址是不固定的,所以idata区段就扮演一个通讯录的角色,告诉程序外部函数的地址是什么。
这时候再回过头来看这四个push操作,应该明白一些了吧。我们知道,栈是一种后进先出的数据结构,当函数在进入时,最先看到的、离它最近的是“第一个参数”,那么在调用之前,就必须最后压入它。
综上所述,这段代码的C语言格式应该是这样:
MessageBoxA(
0, // 参数 1 (hWnd): 窗口句柄,0 代表没有父窗口
"Please insert the Doraemon CD into CDRO...", // 参数 2 (lpText): 提示信息内容
"Error", // 参数 3 (lpCaption): 对话框标题
0x31 // 参数 4 (uType): 对话框类型
);
现在我们向上追溯,看看是谁引用了这里。
这时我们看到下面的代码,IDA也显示了它的跳转位置。
.text:0043721D 74 1C jz short loc_43723B
用人话讲,就是当ZF值为1时跳转到0x43723B行,也就是最开始push CD未验证通过的消息框参数的那一行。
可能会有人问了,jz和jnz都是一个字节,直接偷梁换柱不就行了吗?但你要想清楚jnz就意味着CD验证通过程序就会正常执行,这样一来,人人都是『正版的受害者』。
再想想,jmp短跳也是一个字节,直接改成jmp到下一行也可以?如果你这样想,那咱的文章索性到这里就结束吧!
作为一名cracker,你要有勇于探索的精神,如果简单的改个jmp来免CD还有什么意思?
既然如此,我们就继续向上追溯。
切换到图形视角
看到了有趣的一幕,0x004371CA行引用了函数GetLogicalDrives,经查询微软文档得知,它是一个通过一个位掩码,来标识存在的驱动器的函数,结合后续的程序逻辑框图的结构布局,疑似在遍历电脑上的驱动器。
看来是一个典型的老游戏对物理光盘验证的方法。按F5,得到C语言伪代码。
我们可以看出,这是是一个两层嵌套循环:外层处理用户交互,内层负责从 C 盘到 Z 盘的物理扫描。
接下来我们好好分析这段代码的关键区域
// 获取系统中所有逻辑驱动器的位图(Bitmask)
LogicalDrives = GetLogicalDrives();
while ( !v13 ) // 外层循环:如果没找到光盘且用户没点“取消”,就持续重试
{
// 内层循环:从索引 2 开始遍历到 25,索引 0=A, 1=B, 2=C... 所以是从 C 盘开始扫描到 Z 盘
for ( i = 2; i < 26; ++i )
{
// 将索引转换为 ASCII 字符
String1[0] = i + 65;
// 【核心校验函数】
// 调用 sub_437582 检查当前盘符是否为正版游戏光盘
if ( sub_437582(i + 65) )
{
v11 = 1; // 成功找到的代码
break; // 退出 for 循环
}
}
if ( v11 ) // 如果在刚才的遍历中找到了光盘
{
// 将找到的盘符格式化为路径字符串(如 "D:\")存入 Buffer
sprintf(Buffer, "%c:\\", i + 65);
break; // 彻底退出寻盘逻辑,准备启动游戏
}
// 如果遍历完所有盘符都没找到光盘,弹出“请插入 CD”对话框
// 0x31u 代表 MB_OKCANCEL | MB_ICONWARNING (确定/取消 + 感叹号)
v7 = MessageBoxA(0, aPleaseInsertTh, aError_0, 0x31u);
if ( v7 == 2 ) // 如果用户点击了“取消”按钮 (IDCANCEL = 2)
{
// 弹出“执行已取消”提示,并设置退出标志位
MessageBoxA(0, aExecutionCance, aNotice, 0x30u);
v13 = 1; // 终止外层 while 循环,随后函数将返回 0 导致游戏退出
}
}
这样,我们的免CD方案就清晰明了,我们来深入到sub_437582函数,强制修改返回结果,以达到欺骗目的。
在伪代码视图中,双击sub_437582函数,IDA会帮我们定位到0x437582行并反编译。
一个很简单的光盘内数据校验操作
// 按光盘文件内容校验指定驱动器盘符是否为正版光盘
int __cdecl sub_437582(char a1)
{
int FileHandle;
int FileHandlea;
char Destination[100]; // 用于构造完整文件路径的缓冲区
// 构造Source变量内存储的文件名的路径
Destination[0] = a1; // 设置盘符
Destination[1] = 0; // 字符串截断,确保 strcat 从索引 1 开始拼接
strcat(Destination, Source); // Source变量指向的字符串为"data1.cab"
// 以只读方式打开,如果文件不存在,直接判定校验失败,并返回0。
FileHandle = _open(Destination, 0);
if ( FileHandle == -1 )
return 0;
// 【核心校验】检查文件大小
// 150,000,000 字节约为 143MB。汇编中对应的精确值为 0x8F0D180 (149,999,488 字节)
if ( _filelength(FileHandle) >= 150000000 )
{
_close(FileHandle); // 大小符合预期,关闭当前句柄准备下一步检查
// 构造aRunmeExe变量内存储的文件名的路径
Destination[1] = 0;
strcat(Destination, aRunmeExe); // aRunmeExe变量指向的字符串为"Runme.exe"
// 检查"Runme.exe"是否存在
FileHandlea = _open(Destination, 0);
if ( FileHandlea == -1 ) // runme.exe 不存在
{
return 0; // 校验失败
}
else
{
_close(FileHandlea); // 所有条件均满足
return 1; // 判定为正版光盘!返回成功
}
}
else
{
// 如果 data1.cab 存在但体积太小,则判定失败(所以加载迷你镜像的方案是无效的)
_close(FileHandle);
return 0;
}
}
这样,我们只要是无条件返回1即可,涉及的汇编代码如下:
.text:00437582 55 push ebp
.text:00437583 8B EC mov ebp, esp
.text:00437585 83 EC 68
sub esp, 68h
这里是该函数起始位置,给EAX寄存器写个1然后返回就OK了。
moc eax的操作码为B8占1字节,返回值1也占1字节,retn的操作码为C3,剩下的用00填充即可
.text:00437582 B8 01 00 00 00 mov eax, 1
.text:00437587 C3 retn
选中0x00437582行,右键执行Patching -> Change byte....
按下图所示,修改并确定
再右键执行Patching -> Apply patches to,应用保存即可。
但不知道怎么回事,我的IDA在执行这个操作的时候,出现“Patching cancelled...” 错误,搜了很多解决办法还是没有解决,无奈之下,不得不改用010 Editor来替换。
由于PE程序的加载起始地址通常默认是 0x00400000,所以相对的Hex字节偏移就是PE程序地址减去0x00400000,也就是0x00037582。
将文件命名为DoraemonCracked.exe,来测试一下效果。
游戏完美免CD。
但是,BGM不见了,这是因为游戏的BGM在音轨中,没有了CD,BGM也不会出现的。
至此,第一节到此结束,这也把我十年前的破解思想进行一次大更新,接下来才是重头戏,兑现我当年许下的承诺——还原BGM。
0x02 还原BGM
老游戏如果选用通过播放音轨的方式播放BGM,那么程序会加载一个音轨ID,只要监听到音轨ID就可以实现外挂播放了。
为确保完整地加载BGM,我们还要对镜像文件样本进行检查,看看BGM是不是真的嵌入在音轨中。
如图,镜像文件大小390MB,但是加载到虚拟光驱的只有199MB,剩下的191MB不翼而飞。
没错,它大概率是音轨。
打开抓轨软件Exact Audio Copy,发现1号音轨并不包含音频数据,大小200MB左右,与虚拟光驱显示的大小相似,2号音轨起才是音频数据,打开播放,旋律是大家耳熟能详的XXX梦之歌,也是游戏主界面的BGM,确认无误。
那么,音轨播放会用到什么API呢?
其实我也不知道,我们来搜索一下AI
AI说:MCI (Media Control Interface)是早年最受欢迎的CD音轨播放接口,核心函数是mciSendCommand,那好,现在就去游戏EXE文件的导入表查一下。
在导入表搜索关键字“MCI”…
果然,搜到了mciSendCommandA函数!
查找微软官方文档,这个函数参数如下
MCIERROR mciSendCommandA(
MCIDEVICEID IDDevice, // 设备 ID
UINT uMsg, // 命令消息
DWORD_PTR fdwCommand, // 命令标志
DWORD_PTR dwParam // 指向参数结构体的指针
);
这里要从uMsg参数传入的命令下手,既然EXE通过音轨播放,那它就会通过一个播放命令来跳转到指定的音轨。
再来看看可接受的参数:
| 常量 |
十六进制 |
描述 |
| MCI_OPEN |
0x0803 |
打开并初始化 MCI 设备。 |
| MCI_CLOSE |
0x0804 |
关闭 MCI 设备并释放资源。 |
| MCI_PLAY |
0x0806 |
开始播放设备(如 CD 的特定音轨)。 |
| MCI_STOP |
0x0808 |
停止播放。 |
| MCI_PAUSE |
0x0809 |
暂停播放。 |
| MCI_RESUME |
0x0855 |
从暂停状态恢复播放。 |
| MCI_SEEK |
0x0807 |
移动激光头到指定的起始位置(如音轨起点)。 |
相信各位同学已经看出来了MCI_PLAY(0x0806)最显眼。
那就去交叉引用表去搜吧。
这个程序里有mciSendCommandA的操作屈指可数,对此而言没有什么简便办法来快速找到,一个个找就行了。之后,我在sub_4854B8函数的0x00485530处发现了我们要找的执行播放操作的mciSendCommandA函数。
接下来我们把这个函数还原成伪代码
不难看出,mciSendCommandA这行在整个函数中占据着举足轻重的地位,如果再翻译一下,它的操作应该是这样的:
mciSendCommandA(
DeviceID, // 光驱设备 ID
MCI_PLAY, // 命令:播放,也就是0x806
flags, // 标志:播放区间
(DWORD_PTR)&playParams // 具体数据的代号:包含起始和结束音轨号的“参数包”
);
此时的参数4为dwParam2,而sub_4854B8函数的参数2赋值给了dwParam2[1],所以可以确认这个参数是音轨ID。
实际上在这里的汇编代码就能找到哪个寄存器显示的内存指针指向音轨ID,但考虑到初学者这样研究有些吃力,所以我们使用交叉引用来继续追溯谁call了sub_4854B8函数
在如图所示的行按X打开交叉引用表,先从第一个开始排查。
双击进入后,来到了一个叫sub_403F10的函数,反编译一下伪代码。
可以看到,这里有一个if条件体,与之对应的汇编代码程序框图是下图蓝色框部分,涉及sub_4854B8函数引用的则是红色框部分。
刚才我们看到,sub_4854B8伪代码的函数用法是
__thiscall sub_4854B8(unsigned int8 *this, unsigned int8 a2, int a3)
又注意到00403F3Bh行,也就是call的上一行的代码为
.text:00403F3B 8B 0D F0 9D 4C 00 mov ecx, dword_4C9DF0
看到这里没有使用push,而是直接mov ecx,这说明dword_4C9DF0是一个this指针,而this是通过ECX寄存器传递,它享有特权,不需要走堆栈通道,鉴于这个知识点比较抽象不好理解,这里只作为相关链接来简单概述不细讲,而我们要的东西在第二个参数中。
继续往上看,这里有两行代码
.text:00403F37 8A 4D 08 mov cl, [ebp+arg_0]
.text:00403F3A 51 push ecx
用人话来解释,先把ebp寄存器+arg_0所指向的的内存区域放入cl寄存器中,再压入ecx,而IDA已经帮我们分析出arg_0的值为8了,所以,地址的最终之是ecx+8。好家伙,低八位寄存器都用上了,估计是个单字节的数据。
下面我们来验证一下,启动游戏后,打开CheatEngine并将游戏进程加载到CheatEngine,打开内存查看器,按Ctrl + G 切到00403F37h处,并设置断点,当游戏进入主界面的时候,CE断住,来观察一下ebp寄存器数值,并向后偏移八个字节。
实锤了,这里的值是0x02,就是代表2号音轨。
为了验证,我们把这个0x02改成0x03试一下。
按F9恢复运行,如果BGM变了那么恭喜你你已经成功了一半,你已经成功的找到了音轨ID这给后续通过Hook方式获取这些信息来外挂播放BGM打下基础。
顺便看看sub_403F10的交叉引用,好家伙,快刷屏了,一看就是各种游戏场景的和事件的BGM播放控制。
接下来我们就可以搞Hook了。
先来看一下sub_403F10的原始汇编代码
.text:00403F10 sub_403F10 proc near
.text:00403F10
.text:00403F10
.text:00403F10 arg_0 = byte ptr 8
.text:00403F10 arg_4 = dword ptr 0Ch
.text:00403F10
.text:00403F10 55 push ebp
.text:00403F11 8B EC mov ebp, esp
.text:00403F13 83 3D 70 00 4C 00 00 cmp dword_4C0070, 0
.text:00403F1A 74 34 jz short loc_403F50
.text:00403F1C 83 3D F0 9D 4C 00 00 cmp dword_4C9DF0, 0
.text:00403F23 74 2B jz short loc_403F50
.text:00403F25 8B 0D F0 9D 4C 00 mov ecx, dword_4C9DF0
.text:00403F2B E8 66 16 08 00 call sub_485596
.text:00403F30 8B 45 0C mov eax, [ebp+arg_4]
.text:00403F33 83 E0 02 and eax, 2
.text:00403F36 50 push eax
.text:00403F37 8A 4D 08 mov cl, [ebp+arg_0]
.text:00403F3A 51 push ecx
.text:00403F3B 8B 0D F0 9D 4C 00 mov ecx, dword_4C9DF0
.text:00403F41 E8 72 15 08 00 call sub_4854B8
.text:00403F46 C7 05 F4 9D 4C 00 01 00 00 00 mov dword_4C9DF4, 1
.text:00403F50
.text:00403F50 loc_403F50: ; CODE XREF: sub_403F10+A↑j
.text:00403F50 ; sub_403F10+13↑j
.text:00403F50 B8 01 00 00 00 mov eax, 1
.text:00403F55 5D pop ebp
.text:00403F56 C3 retn
.text:00403F56 sub_403F10 endp
相信同学们都猜到了如果在00403F37处执行Hook,后面还有一堆push,这让我们无从下手,不过好在00403F10处就已经把esp+8所指向的内存区域的值写好了,只能在此Hook。我们的编程思想是当个“传话人”,“偷听”这个内存字节值里的音轨ID,然后“传话”给公共区域,接着,我们就顺利地拿到了音轨ID就可以操控播放预先抓好的WAV了。
代码使用C#编写。
我们首先要做的是获取游戏进程句柄,并从游戏进程中申请一片内存空间,用于存放我们的音轨ID值和ShellCode代码,它是截获音轨ID的核心,是指示外挂播放程序的指路标。
为了对齐内存偏移,防止意外发生,我们申请1024个字节;
申请成功后,我们会得到一个地址,这个地址就存放音轨ID值;
俗话说,距离产生美,我们就从音轨ID地址+10h处放我们的shellcode代码,反正1024字节的空间足够用;
下面开始写我们的ShellCode代码:
55 push ebp ;模拟环境,避免打草惊蛇,欺骗程序让堆栈看起来没有什么破绽,为什么要欺骗,一会儿就知道了。
8B 44 24 08 mov eax, [esp+8] ; “偷听”音轨ID
A2 [SharedMemPtr] mov byte ptr [SharedMemPtr], al ;传话人去”传话“,SharedMemPtr是随机变量,它正是所申请到的内存地址。
5D pop ebp ;干完坏事,就当什么事情也没发生,把EBP弹出来,此时 ESP 回升 4 字节,指向了返回地址。
B8 01 00 00 00 mov eax, 1 ;对上一级调用撒谎,说”我们完事儿了“ 。
C3 ret ;偷到ID,溜之大吉,这时候MCI操作函数已经没有意义了。
然后我们就把写好的ShellCode代码挂到00403F10处。
修改前:
00403F10 55 push ebp
修改后
00403F10 55 jmp [ShellCodeAddress];这里指令完全改变,所以要在ShellCode模拟一下。
这时候就可以修改权限并执行Hook了,程序每当加载sub_403F10时候都会被Hook,并按照我们的要求取到B音轨ID。
执行完毕后,创建一个时钟周期事件,持续监听游戏进程是否存在,再把监听到ID读出来拼接成WAV文件名,播放前还要先停掉正在播放的WAV。
C#参考代码
'''
// 首先,获取游戏进程句柄,申请内存,在游戏进程内开辟一块空间。
// 前16字节作为“信箱”存放数据,0x10偏移处存放“代码”。
_sharedMem = VirtualAllocEx(_hProcess, IntPtr.Zero, 1024, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// 检查申请状态
if (_sharedMem == IntPtr.Zero)
{
ThrowLastWin32Error("劫持内存申请失败");
};
// 其次,构建Shellcode,一段获取音轨 ID 并直接返回的二进制指令。
// 设置用于存放Shellcode的地址
IntPtr shellcodeAddr = (IntPtr)((long)_sharedMem + 0x10);
// 构建 hellcode
List<byte> code = new List<byte>();
// 模拟原汇编代码,必须先压入ebp,让堆栈下沉4字节
// 汇编代码: 55 => push ebp
code.Add(0x55);
// 截取ESP+8处的音轨ID
// 汇编代码: 8B 44 24 08 => mov eax, [esp+0x8]
code.Add(0x8B); code.Add(0x44); code.Add(0x24); code.Add(0x08);
// 将 AL中的音轨ID写入申请到的内存区域
// 汇编代码: A2 [Address] => mov [Address], al
code.Add(0xA2);
code.AddRange(BitConverter.GetBytes(_sharedMem.ToInt32()));
// 弹栈,避免堆栈不平衡会导致返回地址错误
// 汇编代码: 5D => pop ebp
code.Add(0x5D);
// 模拟返回值return 1
// 汇编代码: B8 01 00 00 00 => mov eax, 1
code.Add(0xB8); code.Add(0x01); code.Add(0x00); code.Add(0x00); code.Add(0x00);
// [Step 6] 返回,结束操作
// 汇编代码: C3 => ret
code.Add(0xC3);
// 将Shellcode写入申请的内存
SafeWriteMemory(shellcodeAddr, code.ToArray());
// 然后,实施Hook,修改游戏原有的播放函数入口。
// 设置目标地址:0x00403F10
IntPtr hookPoint = new IntPtr(0x00403F10);
byte[] jmp = new byte[5];
jmp[0] = 0xE9; // JMP 跳转指令
// 计算跳转距离:目标地址 - 当前指令地址 - 指令长度(5)
int dist = shellcodeAddr.ToInt32() - (hookPoint.ToInt32() + 5);
Array.Copy(BitConverter.GetBytes(dist), 0, jmp, 1, 4);
// 写入 JMP 跳转,完成劫持
SafeWriteMemory(hookPoint, jmp);
'''
最后,我们把CD音轨抓出来,按如图所示,在游戏根目录下建一个Tracks文件夹。
把刚才抓出来WAV按如图所示命名。
执行外挂播放前,要先启动程序,然后附加进程,这样我们的代码就生效了。
我们的教程原本到这里就该结束了,贴一下完整版的C#代码就完结散花,但是我偶然发现样本压缩包里还提供了台配普通话版的语音补丁,让你通过文件覆盖的方式来切换粤配和台配语言,十分麻烦,我心里别扭。
那好!我们再给游戏程序做个手术,用Hook的方式来实现切换台配语音。
0x03 编写台配语音切换代码。
看一下游戏根目录的文件,发现了一个voice.dat。
回到IDA,同样按Shift + F12打开字符串窗口。
发现voice.dat位于data区段。
为了让台粤两种语音文件共存,我们把台配的语音文件命名一个其他名字。
同样,我们不能改变data区段原有的偏移,修改前后的字节数也要一样。数一下,不算拓展名,voice.dat的文件名有五个字符串,这样的话,我们就命名为yuyin.dat吧。注入方法与外挂BGM一样,这里就不多重复了。
借助一下AI将这些代码整理好并封装成一个class,设计一下GUI界面,进行一些测试,一切正常。
由于没有对消息框进行处理,所以使用未破解的原版EXE来外挂免CD时候依然会提示未插入CD,但实际上已经成功了,点确定即可,本文章不讨论此问题。
至此,一个集成外挂BGM、免CD、国粤双语音切换的启动器就做好了!
根据论坛规定,逆向破解文章禁止发布成品,所以本文不提供启动器成品,只提供主要代码,还请大家多多理解
0x04 学习总结
这篇文章主要是带大家了解API、Hook、注入的基本定义,你能读懂这篇文章对你的破解之路很有帮助,现代游戏的保护机制要比这个复杂得多。
本次外挂BGM的核心不在于“删除”游戏逻辑,而在于“重定向”。通过 mov eax, 1 向系程序“撒谎”,我们实际上是在代码层面上构建了一个虚假的平行世界。让程序认为CD光驱一切正常,音乐正在播放,而我们则在后台静默地接管了这一切。这说逻辑的终点不一定是执行,也可以是模拟。
汇编语言严苛至极,一个字节的偏移就决定着进程的安危。这种对底层数据结构的绝对掌控感,是任何高级语言无法给予的。每一个 push 都必须有一个 pop 呼应,这不仅是计算机的规则,更像是一种数字世界的哲学对称。
遗憾与不足
1、未能使用IDA来Patch文件,不知道“Patching cancelled...” 错误是怎么导致的,我按照网上现有的解决办法进行了逐一排查也没有发现问题所在,求大佬指教。
2、通过启动器注入方式免CD未处理消息框,这里面的机制还在研究中,后续有时间再说。
这次“大富翁外挂”BGM启动器制作过程,不只是我对10年前自己的承诺,更是一场与二十年前程序员的隔空对话。我们在旧时代的残骸中寻找缝隙,注入新时代的逻辑。从最初的内存溢出、游戏闪退,到最后游戏脱离光驱的状态下想起BGM的成就感,或许正是每一个逆向研究者最初的动力来源。不懂的问坛友、不会的问AI,同学们只要勤练习、多复盘,或许Cracker里的大富翁就是你!
拓展阅读
X86 Opcode and Instruction Reference(来源:外网搜索,汇编指令对应的十六进制字节值对照表)
http://ref.x86asm.net/
PE格式解析-区段表及导入表结构详解(来源:CSDN博客)
https://blog.csdn.net/qq_30145355/article/details/78859214
Windows API(来源:百度百科)
https://baike.baidu.com/item/Windows%20API/6088382
深入解析HOOK注入技术及实战应用(来源:CSDN博客)
https://blog.csdn.net/weixin_33431149/article/details/151844647
句柄(来源:百度百科)
https://baike.baidu.com/item/%E5%8F%A5%E6%9F%84/3527587
CTF逆向:IDA的基础操作(来源:CSDN博客)
https://blog.csdn.net/week67/article/details/154612024
进程句柄和标识符(来源:Microsoft Learn)
https://learn.microsoft.com/zh-cn/windows/win32/procthread/process-handles-and-identifiers
mciSendCommand function(来源:Microsoft Learn,微软官方对mciSendCommand的解释)
https://learn.microsoft.com/zh-cn/previous-versions//dd757160(v=vs.85)
完整的C#代码
回帖可见,没有论坛账号的朋友可在本帖发布之时起120小时后来看。
#region XXX梦大富翁劫持引擎
/// <summary>
/// XXX梦大富翁劫持引擎
/// <para>主要功能:负责内存读写、Shellcode 注入及 BGM 逻辑重定向</para>
/// </summary>
public class DoraPatchEngine : IDisposable
{
#region Win32 API 声明
// 内存访问常量
const uint PROCESS_ALL_ACCESS = 0x1F0FFF; // 满权限访问进程
const uint PAGE_EXECUTE_READWRITE = 0x40; // 读/写/执行 权限
const uint MEM_COMMIT = 0x00001000; // 提交内存
const uint MEM_RESERVE = 0x00002000; // 保留内存
// 成员变量
private Process _gameProcess; // 游戏进程对象
private IntPtr _hProcess = IntPtr.Zero; // 进程句柄
private IntPtr _sharedMem = IntPtr.Zero; // 申请的共享内存地址
private SoundPlayer _bgmPlayer; // 本地播放器
private byte _lastTrackId = 0; // 状态机:记录当前播放音轨,防止重复触发
/// <summary>
/// <句柄> 打开一个进程对象(无符号整数型 访问权限 ,逻辑型 是否继承句柄 ,整数型 目标进程ID)
/// <para>返回一个进程句柄</para>
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
///
static extern IntPtr OpenProcess(
uint dwDesiredAccess, // 访问权限
bool bInheritHandle, // 是否继承句柄
int dwProcessId // 目标进程ID
);
/// <summary>
/// <逻辑型> 取进程内存数据(句柄 欲读取的进程句柄 ,句柄 欲读取的内存地址 ,字节集 目标缓冲区 ,整数型 读取大小 ,输出 整数型 实际读取大小)
/// <para>成功返回真</para>
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(
IntPtr hProcess, // 进程句柄
IntPtr lpBaseAddress, // 欲读取的内存地址
byte[] lpBuffer, // 存放读取结果的缓冲区
int nSize, // 读取的大小(字节)
out int lpNumberOfBytesRead // 实际读取到的字节数
);
/// <summary>
/// <逻辑型> 写指定区域内存(句柄 欲写入的进程句柄 ,句柄 欲写入的起始地址 ,字节集 欲写入的数据 ,整数型 欲写入的大小 ,整数型 实际写入的字节数)
/// <para>成功返回真</para>
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(
IntPtr hProcess, // 进程句柄
IntPtr lpBaseAddress, // 欲写入的起始地址
byte[] lpBuffer, // 欲写入的数据
int nSize, // 欲写入的大小
out int lpNumberOfBytesWritten // 实际写入的字节数
);
/// <summary>
/// <逻辑型> 修改指定进程中内存区域的保护属性(句柄 欲操作的进程句柄 ,句柄 欲修改权限的地址 ,整数型 区域大小 ,整数型 新权限 ,整数型 修改前的就权限)
/// 用途:游戏的代码段通常是“只读”的,写入前必须用它改为“可读写(EXECUTE_READWRITE)”,否则会报权限错误。
/// <para>成功返回真</para>
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool VirtualProtectEx(
IntPtr hProcess, // 进程句柄
IntPtr lpAddress, // 欲修改权限的地址
int dwSize, // 区域大小
uint flNewProtect, // 新权限
out uint lpflOldProtect // 存放修改前的旧权限(用于稍后还原)
);
/// <summary>
/// 在目标进程的虚拟地址空间内申请内存。
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr VirtualAllocEx(
IntPtr hProcess, // 进程句柄
IntPtr lpAddress, // 指定申请地址
uint dwSize, // 申请的大小
uint flAllocationType, // 分配类型(COMMIT | RESERVE)
uint flProtect // 内存权限(可读写执行)
);
#endregion
/// <summary>
/// 检查游戏是否仍在运行
/// </summary>
public bool IsRunning => _gameProcess != null && !_gameProcess.HasExited;
/// <summary>
/// 取游戏进程对象并打开句柄
/// <param name="process">(进程 目标进程)</param>
/// </summary>
public DoraPatchEngine(Process process)
{
_gameProcess = process;
_hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, _gameProcess.Id);
if (_hProcess == IntPtr.Zero) ThrowLastWin32Error("无法获取进程句柄,请尝试以管理员身份运行。");
}
/// <summary>
/// 免CD补丁:
/// </summary>
public void ApplyNoCDPatch()
{
IntPtr addr = new IntPtr(0x00437582);
byte[] original = { 0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x68 }; // 原始汇编指令:push ebp...
byte[] patch = { 0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3 }; // 覆盖的汇编指令: mov eax, 1; ret
// 校验并写入
VerifyAndWrite(addr, original, patch, "免CD补丁位置校验失败,可能是游戏版本不匹配。");
}
/// <summary>
/// 使用中配语音
/// </summary>
public void ApplyChainseVoicePatch()
{
// 目标地址
IntPtr addr = new IntPtr(0x004C7D60);
// 写入新的语音文件名
byte[] bytes = System.Text.Encoding.ASCII.GetBytes("yuyin.dat");
Array.Resize(ref bytes, 10); // 强制规范长度,填充 \0 终止符
// 安全写入
SafeWriteMemory(addr, bytes);
}
/// <summary>
/// 安装外挂BGM补丁
/// <para>步骤:1、申请内存;2、注入Shellcode;3、修改原函数跳转</para>
/// </summary>
public void InstallBGMPatch()
{
// 首先,获取游戏进程句柄,申请内存,在游戏进程内开辟一块空间。
// 前16字节作为“信箱”存放数据,0x10偏移处存放“代码”。
_sharedMem = VirtualAllocEx(_hProcess, IntPtr.Zero, 1024, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// 检查申请状态
if (_sharedMem == IntPtr.Zero)
{
ThrowLastWin32Error("劫持内存申请失败");
};
// 其次,构建Shellcode,一段获取音轨 ID 并直接返回的二进制指令。
// 设置用于存放Shellcode的地址
IntPtr shellcodeAddr = (IntPtr)((long)_sharedMem + 0x10);
// 构建 hellcode
List<byte> code = new List<byte>();
// 模拟原汇编代码,必须先压入ebp,让堆栈下沉4字节
// 汇编代码: 55 => push ebp
code.Add(0x55);
// 截取ESP+8处的音轨ID
// 汇编代码: 8B 44 24 08 => mov eax, [esp+0x8]
code.Add(0x8B); code.Add(0x44); code.Add(0x24); code.Add(0x08);
// 将 AL中的音轨ID写入申请到的内存区域
// 汇编代码: A2 [Address] => mov [Address], al
code.Add(0xA2);
code.AddRange(BitConverter.GetBytes(_sharedMem.ToInt32()));
// 弹栈,避免堆栈不平衡会导致返回地址错误
// 汇编代码: 5D => pop ebp
code.Add(0x5D);
// 模拟返回值return 1
// 汇编代码: B8 01 00 00 00 => mov eax, 1
code.Add(0xB8); code.Add(0x01); code.Add(0x00); code.Add(0x00); code.Add(0x00);
// [Step 6] 返回,结束操作
// 汇编代码: C3 => ret
code.Add(0xC3);
// 将Shellcode写入申请的内存
SafeWriteMemory(shellcodeAddr, code.ToArray());
// 然后,实施Hook,修改游戏原有的播放函数入口。
// 设置目标地址:0x00403F10
IntPtr hookPoint = new IntPtr(0x00403F10);
byte[] jmp = new byte[5];
jmp[0] = 0xE9; // JMP 跳转指令
// 计算跳转距离:目标地址 - 当前指令地址 - 指令长度(5)
int dist = shellcodeAddr.ToInt32() - (hookPoint.ToInt32() + 5);
Array.Copy(BitConverter.GetBytes(dist), 0, jmp, 1, 4);
// 写入 JMP 跳转,完成劫持
SafeWriteMemory(hookPoint, jmp);
}
/// <summary>
/// 监听BGM切换状态
/// </summary>
public void MonitorBGM()
{
// 检查申请的内存是否有效
if (_sharedMem == IntPtr.Zero)
{
return;
}
// 存放BGM音轨ID的缓冲区
byte[] buf = new byte[1];
int read;
// 读取刚刚申请的内存地址头一个字节
if (ReadProcessMemory(_hProcess, _sharedMem, buf, 1, out read) && read == 1 && buf[0] != 0)
{
// 如果 ID 发生了变化,说明游戏场景切换了,需要切换BGM
if (buf[0] != _lastTrackId)
{
_lastTrackId = buf[0];
PlayLocalWav(_lastTrackId);
}
}
}
/// <summary>
/// 播放本地音乐
/// <param name="trackId">(整数型 音轨ID号)</param>
/// </summary>
private void PlayLocalWav(byte trackId)
{
// 拼接路径,因为是.NetFarmework2.0,所以不能用Path.Combine
string path = AppDomain.CurrentDomain.BaseDirectory + "Tracks\\" + $"Track_{trackId:D2}.wav";
try
{
if (File.Exists(path))
{
_bgmPlayer?.Stop(); // 停止上一首
_bgmPlayer = new SoundPlayer(path);
_bgmPlayer.PlayLooping(); // 本地循环播放
}
}
catch (Exception ex)
{
throw new Exception("外挂BGM播放失败:" + ex.Message);
}
}
/// <summary>
/// 安全写入
/// <param name="addr">(句柄 目标地址 ,</param>
/// <param name="data">字节集 欲写入的字节集数组)</param>
/// </summary>
private void SafeWriteMemory(IntPtr addr, byte[] data)
{
uint old;
// 解除内存锁定
VirtualProtectEx(_hProcess, addr, data.Length, PAGE_EXECUTE_READWRITE, out old);
// 写入指令/数据
WriteProcessMemory(_hProcess, addr, data, data.Length, out _);
// 恢复原始锁定状态
VirtualProtectEx(_hProcess, addr, data.Length, old, out _);
}
/// <summary>
/// 校验写入
/// <param name="addr">(句柄 目标地址 ,</param>
/// <param name="original">字节集 原始字节 ,</param>
/// <param name="patch">字节集 目标字节 ,</param>
/// <param name="errorMsg">文本型 抛出异常时要显示的消息)</param>
/// <para>先确认内存中的原始字节是否匹配,防止改错地址导致游戏崩溃</para>
/// </summary>
private void VerifyAndWrite(IntPtr addr, byte[] original, byte[] patch, string errorMsg)
{
// 读取当前内存数据
byte[] buf = new byte[original.Length];
ReadProcessMemory(_hProcess, addr, buf, buf.Length, out _);
// 逐个校验原始字节
for (int i = 0; i < buf.Length; i++)
if (buf[i] != original[i]) throw new Exception(errorMsg);
// 安全写入新字节
SafeWriteMemory(addr, patch);
}
/// <summary>
/// 取并抛出最后的Win32错误(文本型 错误消息)
/// <para>当Windows API失败时,抛出带详细描述的系统异常</para>
/// </summary>
private void ThrowLastWin32Error(string message)
{
// 取错误码
int code = Marshal.GetLastWin32Error();
// 生成异常并重新抛出
var ex = new System.ComponentModel.Win32Exception(code);
throw new Exception($"{message}\n错误码: {code}, 原因: {ex.Message}");
}
/// <summary>
/// 释放资源
/// <para>停止音乐并关闭句柄</para>
/// </summary>
public void Dispose()
{
_bgmPlayer?.Stop();
_bgmPlayer?.Dispose();
// 提示:此处可以添加对申请的 _sharedMem 的 VirtualFree 操作
}
}
#endregion