世界杯马上要开幕了,来聊个跟足球有关系的修改吧,这是一款由轨迹系列的游戏厂商早年开发的一款RPG游戏,分析一下如何通过技术手段让MiniGame的足球赛中,敌方进球给我方白送分,再分析一下免CD、一击必杀和无敌。
我本来不想写这篇文的,因为知识点涵盖了我前两篇精品文,不过我发现这个游戏基址固定在DLL上,比某上海滩简单一点,对于研究游戏内存修改的本质来说,还是有价值的。
P.S. 我有意没有缩图,因为缩图就糊了,建议在手机上浏览,自适应的。
这次将涉及到一点点MFC的知识,建议各位预习一下!
小时候逛夜市,来到了一家卖盗版游戏碟的地摊上,扩音器喊着:“精品游戏,5元一碟,即装即玩!” 于是我停下了脚步,在一堆光盘里翻着翻着……突然,一个手持钻头的橙发小女孩的一张光盘引起了我的注意,便犹犹豫豫地向地摊老板支付了5元"巨资"回了家,我玩的不亦乐乎,支线任务的MiniGame中的敲地鼠总是达不到数量就时间到;足球赛被敌方零封0:9;还有一个名叫黑豆的超级boss瞬间秒杀,到了初中,知道了如何使用金山游侠来修改内存地址来作弊通关,虽然可以修改血量和金钱,但还是无法锁定MiniGame的倒计时……
这一切成为了儿时最快乐的回忆,真的是越菜越爱玩,然而今天是越菜越想用科技手段来复仇,哈哈哈哈
由于这个游戏的各属性内存地址都是静态的,比我上次介绍的某上海滩的还简单,这个不需要寻找基址,只需要找到DLL的地址即可,如果你对某上海滩寻找基址的操作不太懂的话,不妨先从这个游戏开始练。
其实早在2007年左右就有前辈总结了金钱、无限跳跃、血量、各种道具的地址,但是很多道具是剧情道具,随意修改可能会引发未知错误,他那个是通过CE锁定内存地址的方式来实现的修改,而我们要讲的是从汇编码的层面来实现修改,并且还能实现其他修改,这就是本文存在的意义。
本文向大家介绍四个知识点:首先是知识回顾,通过IDA解析的伪代码分析游戏免CD方法;然后介绍主角免疫一切伤害与一击必杀;最后也是全文最高潮、最精彩的地方——MiniGame之足球赛实现敌方进球给我方白送分的修改。
目标样本
aHR0cHM6Ly9wYW4uYmFpZHUuY29tL3MvMUJEWk9nYWFWYTJCUGxUajhLY3hDT0E/cHdkPTUycGo=
样本包解压密码:52pojie
0x00 基础知识
大部分基础知识已经在上次的某上海滩和某大富翁的文章里基本上都介绍一遍了这次只讲MFC基本定义,其他的不再重复,请提前预习:
【致初学修改器的你】如何优雅的杀敌以做到英雄无敌?记一次血战上海滩修改器制作过程
https://www.52pojie.cn/thread-2024353-1-1.html
【兑现十年前的承诺】通过逆向分析老游戏XXX梦大富翁来制作外挂BGM启动器
https://www.52pojie.cn/thread-2089150-1-1.html
你必须知道的MFC基本定义
MFC(Microsoft Foundation Classes,微软基础类库) 简单来说,MFC就是微软当年为了方便C++程序员开发Windows图形界面(GUI)程序,而将Windows底层API打包起来的一个“面向对象的巨型大礼包”。如果说传统的Win32 API是用砖瓦自己盖房子,那么MFC就是直接给你提供了预制板和毛坯房。
你必知道的 CE 与 IDA 地址换算机制
在32位程序中,IDA 分析 DLL 时显示的是静态基址;而 CE 附加游戏后,DLL 载入内存的基址是动态的,所以 CE 通常用 DLL文件名 + 偏移量 来表示。
核心换算公式:两边的偏移量绝对一致!
如果 xxxxx 代表偏移量,那么:
- IDA 显示地址 =
0x100xxxxx
- CE 对应地址 =
Lib.dll+xxxxx
(例如:IDA 中看到 0x10012345,在 CE 中对应的就是 Lib.dll+12345)
【初学者闭坑】绝对地址干扰
有时候,CE 的 HEX 内存窗口、反汇编窗口会显示一串真实的“动态绝对地址”(每次重启游戏都会变)。请直接无视它!在 CE 中跳转或下断点时,不管它显示什么,直接在地址框强制输入 Lib.dll+偏移量,CE 内部会自动算出真实地址并跳过去。
本小节务必搞清楚,否则我一会儿说Lib.dll+xxxxx又说100xxxxx,你可能听不懂。
穿插一个拓展知识,为了让你在 IDA 中“看地址前缀就能盲猜编译器”,这里为你整理了一份更全面的常见编译器/环境 DLL 默认静态基址对照表。
| 编译器 / 开发环境 |
默认/常见 DLL 静态基址 |
识别特征与逆向经验谈 |
| Microsoft Visual C++ (MSVC) |
0x10000000 |
业界最通用标准(10开头)。 绝大部分商业游戏、主流软件自带的 DLL 都在这里起步。 |
| Borland Delphi / C++ Builder |
0x00400000或 0x00300000 |
重定位重灾区。 它最大的坑在于默认基址和 EXE 冲突!用它写的 DLL 载入内存时必定触发重定位,在 CE 里真实地址会被随机踢到各种地方。多见于早年台湾单机游戏和老外挂。 |
| MinGW / GCC / Cygwin |
0x61000000~ 0x6xxxxxxx |
开源跨平台系(6开头)。 为了避开微软系程序的内存冲突,GCC 常常默认把 DLL 放到高位段。常见于开源游戏引擎、模拟器或 Linux 移植项目。 |
| Visual Basic 6.0 (VB6) |
0x11000000 |
古董级产物(11开头)。 如果 IDA 分析出来是 11 开头,且导入表里全是指向 MSVBVM60.DLL 的函数,毫无疑问是老古董 VB6 写的 ActiveX DLL。 |
| 易语言 (E-Language) |
0x10000000 |
国内做挂神器。 因为静态编译通常调用 VC 链接器,所以也是 10 开头。特征是反汇编里有一大堆 krnln 核心库调用,以及肉眼可见的大量中文字符串。 |
| C# / .NET (x86) |
0x10000000 |
虽然基址和 VC++ 一样,但它的汇编入口通常非常短,上来直接 JMP 跳进 mscoree.dll(.NET虚拟机)。遇到这种千万别用 IDA 死磕,直接上 dnSpy 反编译看 C# 源码。 |
| FASM / MASM |
0x10000000,也有可能自行随意指定 |
大佬手搓系。 体积小得惊人(通常只有几 KB 到十几 KB),没有繁杂的 C 语言启动代码(CRT),代码逻辑极其干净粗暴。 |
| Windows 系统底层核心库 |
0x70000000 ~0x7FFFFFFF |
微软自留区(7开头)。 这是 Windows 为 kernel32、user32 等系统组件预留的高位空间。如果你在 x32dbg 单步调试时发现地址变成了 7 开头,说明你已经不小心“走太深”,掉进系统底层 API 里了,赶紧按 Alt+F9(执行到用户代码)退出来。 |
【小知识】Windows逆向大佬都知道的:
- 是 10 开头?常规 C/C++,准备找核心业务逻辑。
- 是 6 开头?可能是个开源改版,可以去 Github 搜搜有没有同类的开源代码对照着看。
- 入口点直接调
mscoree.dll IDA、x32dbg啥的可以关掉了,直接上 .NET反编译!
- 如果在x32dbg/ollydbg中载入一个原本应该停在
0040XXXX的普通 exe,却发现它的入口点(Entry Point,简称EP点)断在了 006XXXXX、007XXXXX 甚至更高的地方,那么恭喜你中大奖了,这百分之百说明这个程序被加了壳或者被加密,如果是弱壳弱加密,多读读前辈们的实战课问题不大,如果是强壳强加密,那必定是一场鏖战,AI也未必能帮得上忙。
你可以简单了解的Windows消息的基本定义和循环机制
系统中,消息(Message) 是系统与应用程序通信的最小单元,本质是包含 "HWND"、"MSG ID"、"WPARAM"、"LPARAM"等信息的结构体。消息所有者通常指产生消息的源头(如鼠标、键盘硬件,或系统内核),它们将事件封装成消息投递出去。
消息循环(Message Loop) 是应用程序的心脏。它通过
"GetMessage"从线程的消息队列中取出消息,经
"TranslateMessage"进行预处理,再由
"DispatchMessage"将消息路由至目标窗口的回调函数(WndProc/DialogProc)。程序必须持续执行这个循环,UI 才能保持响应。
Windows消息机制是Windows编程里较为抽象的知识点之一,为了让各位更容易理解,我们用点外卖来打比方。
- 消息所有者就如同商家,它可能是你的鼠标和键盘也可能是系统内核。你或者系统操作一下,就像商家做好了餐食,必须出餐。
- 消息就如同外卖骑手小哥,鼠标点的什么位置、键盘按的什么键就如同外卖小哥手中拿着的顾客的餐食,他不能乱跑,必须按导航走。
- 消息队列就好比商家的出餐台当你疯狂点击窗口时,就像商家忙不过来,骑手们就得在出餐台前排队等着取餐。
- 消息循环就等于是平台的调度中心,这是最核心的“大脑”。它在商家的手机上不停的语音播报:“某团外卖来订单了("GetMessage")”。拿到单后,商家的订单打印机自动打印出餐单,告诉商家和骑手:“要一份巴西烤肉拌饭,多加番茄酱,送到xx小区xx幢xx单元xx室("DispatchMessage")”。
- 窗口过程就像是顾客: 收到餐的顾客。顾客收到外卖了“我确认收餐(绘制窗口)或者“餐食有问题,仅退款(销毁窗口)”。
- 后果: 如果调度中心("while" 循环)罢工了,骑手小哥全堵在店里,愤怒的顾客要找平台投诉,餐馆的商家也因发不了单而心急如焚,那么“整个外卖平台就挂了”。
明白了前两篇文章的基础知识、MFC的基本定义、CE与IDE的地址显示方式,我们就可以来实战了。
0x01 热身运动
1、反和谐“健康游戏忠告”启动屏
我们的选取的样本为YLT代理汉化的版本,该版本最大的特色就是游戏启动时弹出的“健康游戏忠告”,非常碍事儿。
现在网上流传的是一个叫 “龙漫电玩” 的破解版,由于作者加了壳还是Aspack的变种,别说初学者,大佬也未必能脱个一干二净,而官方原版用的是商业反盗版强壳,更不好脱。我们讲的是游戏修改器又不是脱壳,所以我选取的样本是官方原版安装包+bug修复+官方免激活补丁的版本,只有一个简易的CD验证。
首先安装游戏(PS:记住ISO镜像的卷标名称,一会儿会考哦)
并安装好升级档+免激活补丁。
得到了如下文件
game.exe为游戏主程序,文件特征如下:
文件名称:game.exe
文件大小: 2.20 MB (2,314,240 字节)
文件版本: 1.0.0.1
修改时间: 2006年02月07日,19:09:30
MD5: 1EDA5A4FE51F5542C0788DC1C2324662
SHA1: 1D55094CE79B0BF6DC09B09FF99323C1FF99EFC6
SHA256: 4475E44EDFF6FCA8038795221E0BB81C387266E027FF07A370517095A541AEB2
CRC32: 0A5FB4D0
第一件事当然是查壳了,拖到ExeinfoPe里看看
很好!纯C++程序!emmmm……很温顺
下面打开Resource Hacker,看一下rsrc资源段的情况
在对话框资源列表里看到了“健康游戏忠告”启动屏,这里给我们释放了一个信号那就是“健康游戏忠告”启动屏是通过对话框来实现的。那好,接下来开始解决碍事的“健康游戏忠告”启动屏,一般来说,尽管是已编译的程序,汇编码里调用WindowsAPI的之前也要push参数,所以我们先从资源ID入手,看看能不能找到push 对话框资源ID这条指令。
将程序载入到IDA,等待IDA反汇编完成。
哦对了,我先插一嘴,以前有同学问我如下图所示的对话框是什么意思,选是还是否?先道个歉,上次我忘讲了,现在补充一下,这张图是询问你是否载入PDB文件,我们通常要选择否。
那么什么是PDB呢?为什么要选择否呢?我们都知道,C++程序需要进行编译,并且使用针对于目标平台的链接器连接才可以正常运行,而编译的时候,编译器并不会把原函数名也写进去,而是直接抹除所有名字,只留下内存偏移量。而PDB就是用来进行对照的,供程序开发者后续调试排障,它充当一个翻译字典的作用。很显然,游戏开发者是不不可能给我们PDB的,所以我们没有必要选是,也不能选是,否则IDA会以找不到文件为由而不能加载exe。
刚才我们已知“健康游戏忠告”启动屏的对话框资源ID是143,也就是0x8F,按Alt + T来搜索一下 push 8F 这条指令。
啊哦,空空如也!
不过也很正常啦!2004年的游戏,肯定是有一点对抗的,说明游戏厂商并没有按套路出牌,可能是使用了比较高级的框架,越高级的程序,代码和数据的耦合度就越低。开发者把对话框 ID 封装进了资源包,把弹窗动作封装进了虚函数。这种‘双重封装’导致你在反汇编代码里看不到直观的 ID。考虑到游戏的发行年代,那么MFC框架的嫌疑就比较大了,MFC 会把 GUI 层面的业务与程序自身内部业务分开进行,用C语言逆向的思路去分析,当然行不通。不过,都0206年了,MFC这种老掉牙的东西,它再怎么精辟,终究还是逃不了系统底层API的调用,现在换一种思路,上32dbg调试器,对可能使用到的API进行下断点。
在IDA中切到Imports选项卡,以“dialog”为关键词,查一下有没有相关的API引用。
果不其然,搜到了一些涉及对话框的API,其中CreateDialogIndirectParamA很重要,根据微软官方的开发者文档,我们得知它是用于创建无模式的对话框,将相关消息传给对话框,我们也别管消息啥的,既然已经知道“健康游戏忠告”启动屏是程序运行后可见的第一个对话框,那就在此函数下断,当命中第一次的时候,查一下调用堆栈。
如图所示,运行x32 dbg,将游戏主程序game.exe拖入到x32 dbg
随后通过命令来给CreateDialogIndirectParamA这个API下断点,在下方的命令输入框输入bp CreateDialogIndirectParamA
此时断点列表里会显示刚才设置的断点
按F9,开始运行程序,这时exe入口点会断住,再按一次F9才会断住CreateDialogIndirectParamA,并显示命中一次,如下图所示
现在不要再继续运行了,去查看调用堆栈
看到了它是由0x0043BFB5处发起的,
到IDA里定位到0x0043BFB5处
转成伪代码,发现了Afx 字眼,看来这是一个包装函数,涉及对话框创建的操作都要经过它,如果直接nop掉这里那整个exe就打不开了
所以还要看上一层调用,也就是0x0043C123,转成伪代码后,依然还有Afx 字眼,说明现在还是没有脱离MFC层面。
继续调查上一级——0x00405E4C,终于看到了游戏自身的逻辑业务
发现这里调用了CDialog::DoModal函数,典型的MFC对话框业务。
仅需对E8 08 62 03 00这5个字节进行改写
用010 Eidtor用nop指令的字节码0x90填充即可。
存盘exe,这时候你发现“健康游戏忠告”启动屏消失了,其余功能一切正常。
修改前
修改后
反和谐成功!
2、去除CD验证
与上次提到的的某大富翁一样,CD验证机制都是扫描指定光驱的文件,原理大同小异,既然到这里了,那我们就复习一下。
打开游戏后,并不会马上出现CD验证消息框,而是要从游戏的设定界面手动点击“启动游戏”才触发CD验证。
回到IDA,切到字符串表,搜索关键字。
双击搜索结果定位所在位置,按X键调出交叉引用表,选中第二个。
到了这里,来按F5进行反编译伪代码
这时你可能发现按F5根本没有响应,按空格键切换到程序框图时弹窗提醒没有识别这个函数。
这是怎么回事?我们知道,汇编语言的一个函数是从push ebp开始的,从endp结束,IDA解析函数的时候一般会默认每一个函数都是紧凑的,一个还是endp后即开启下一个push ebp,这中间应该是有其他代码在干扰,所以我们尝试寻找一下当插入CD的提示文本被push之前有没有push ebp,通过上翻,发现0x00408600处有,而且还发现了align 10h。
那么align 10h又是什么东西呢?编译器为了让 CPU 读取指令更快,通常会让每个函数的起始偏移是10h的倍数。所以align 10h并不是什么汇编指令,而是IDA标记出来的告诉你这里程序在此进行函数对齐,不妨切到HEX视图,确实在用0xCC来填充对齐。
如何解决呢?IDA有一个功能就是手动指定函数的起始偏移,我们只要在0x00408600处按快捷键P键就可以手动指定该函数的起始偏移了。重新制定后,应该是这样子的。
这时候按F5快捷键也能正常反编译成伪代码了。
看起来这是一段启动游戏之前初始化的操作,其中话红框的地方必然与CD验证有关。
这是一个while循环,循环条件为执行函数sub_405A60,结果取非,满足条件后调用MessageBoxA,根据微软官方开发文档,这一行用人话来解释就是显示对话框,提示文本为asc_46690所指向的文本,标题为“确定”,1u 代表 MB_OKCANCEL,即对话框类型为确定取消型对话框,2为点击取消按钮的返回码,所以==2的意思就是满足等于2 的时候,终止整个函数的执行,反之,点击确定后继续执行while的操作。
这样的话就好办了,我们直奔sub_405A60,来尝试修改它的返回状态。
双击while ( !sub_405A60(0) )的sub_405A60,进入该函数
接下来粘贴出这个函数的伪代码原文,并将重要操作以注释的方式介绍给各位。
int __cdecl sub_405A60(HMODULE hModule)
{
FILE *Stream; // [esp+4Ch] [ebp-528h]
size_t i; // [esp+50h] [ebp-524h]
char Buffer[256]; // [esp+54h] [ebp-520h] BYREF
char Destination[256]; // [esp+154h] [ebp-420h] BYREF
CHAR Filename[267]; // [esp+254h] [ebp-320h] BYREF
char j; // [esp+35Fh] [ebp-215h]
CHAR RootPathName[260]; // [esp+360h] [ebp-214h] BYREF
CHAR VolumeNameBuffer[260]; // [esp+464h] [ebp-110h] BYREF
DWORD FileSystemFlags; // [esp+568h] [ebp-Ch] BYREF
DWORD MaximumComponentLength; // [esp+56Ch] [ebp-8h] BYREF
DWORD VolumeSerialNumber; // [esp+570h] [ebp-4h] BYREF
Filename[259] = 0;
Filename[258] = 0;
Filename[257] = 0;
// 获取游戏程序在硬盘上的绝对路径
GetModuleFileNameA(hModule, Filename, 0x100u);
// 去除路径末尾的文件名,只保留文件夹路径
for ( i = strlen(Filename) - 1; Filename[i] != 92; --i )
Filename[i] = 0;
strcpy(Destination, Filename);
if ( Buffer[strlen(Destination) + 255] != 92 )
strcat(Destination, "\\");
sprintf(Buffer, "%s定时回来对吧", Destination); // 设置目标文件名
Stream = fopen(Buffer, "rb"); // 以文件流方式尝试读取该文件是否存在于游戏根目录
if ( Stream )
{
fclose(Stream); // 文件存在,关掉文件流,并返回1,即CD验证通过的状态码
// 不出意外的话,这是当年YLT留下的后门,只要是“定时回来对吧”这个文件哪怕是空白的,只要它存在,CD就能验证通过
return 1;
}
else // 若没有后门文件,开始遍历电脑上的一切盘符
{
// 65 是 'A' 的 ASCII 码,90 是 'Z' 的 ASCII 码
// 这个循环意味着:从 A:\ 一直检查到 Z:\
for ( j = 65; j <= 90; ++j )
{
// 格式化字符串,生成类似 "C:\" 的根目录路径
sprintf(RootPathName, "%c:\\", j);
// GetDriveTypeA 是 Windows 底层函数,用来判断该盘符是什么设备
// 这里的 5 是一个常量标识符,代表 DRIVE_CDROM(光驱)
// 这一步是为了过滤掉硬盘、U盘,精准定位光驱
if ( GetDriveTypeA(RootPathName) == 5 )
{
sprintf(RootPathName, "%c:\\", j);
// 获取该盘符内光盘的详细信息(卷标、序列号等)
if ( GetVolumeInformationA(
RootPathName,
VolumeNameBuffer, // 这里会存入光盘的名字,比如 "GURUMIN_CD1"
0xFu,
&VolumeSerialNumber,
&MaximumComponentLength,
&FileSystemFlags,
0,
0) )
{
// 比较光盘名字是否匹配预设的卷标 "GURUMIN_CD1"
// !lstrcmpA 的意思是:如果名字完全一样(返回0),则取反为真
if ( !lstrcmpA(VolumeNameBuffer, "GURUMIN_CD1") )
{
// 拼接路径检查光盘根目录下是否存在 autorun.inf
sprintf(RootPathName, "%c:\\autorun.inf", j);
// GetFileAttributesA 返回 -1 说明文件不存在
// 这里是在进行“双重校验”:不仅名字要对,盘里还得有这个启动文件
if ( GetFileAttributesA(RootPathName) != -1 )
return 1; // 验证通过!
// 如果没有 autorun,看看有没有游戏图标(补刀逻辑)
sprintf(RootPathName, "%c:\\gurumin.ico", j);
if ( GetFileAttributesA(RootPathName) != -1 )
return 1;
}
// 对第二张光盘执行同样的逻辑
if ( !lstrcmpA(VolumeNameBuffer, "GURUMIN_CD2") )
{
sprintf(RootPathName, "%c:\\autorun.inf", j);
if ( GetFileAttributesA(RootPathName) != -1 )
return 1;
sprintf(RootPathName, "%c:\\gurumin.ico", j);
if ( GetFileAttributesA(RootPathName) != -1 )
return 1;
}
}
}
}
// 如果 26 个盘符都看遍了也没找到,返回 0,这就会触发外面那个“请放入光盘”的弹窗
return 0;
}
}
其实读到文件判断环节就没必要继续分析了,只需要从游戏根目录下右键新建文本文档,创建一个名为定时回来对吧的无拓展名文件就可以触发CD验证通过的条件。
如果你非要尝试强制无条件返回1放到eax寄存器里,那就请在伪代码末尾的return 0;这一语句按Tab键,你会跳转到与之对应的汇编码
.text:00405D2B 33 C0 xor eax, eax
正常来讲,CD的卷标、文件存在的时候构成了CD验证通过的另一个条件,由于CD验证未通过,相当于没有对eax寄存器进行写1的操作,所以就是什么都没干,那就用异或方式对eax的值再进行确认。
因为33 C0是两个字节,而mov eax,1需要5个字节,已经超出原始大小了,毕竟eax是32位的计数器寄存器,需要占用四字节,所以我们用一个低八位的计数器寄存器al来偷梁换柱,到010 Editor里定位到0x5D2B处,将33 C0改成B0 01并存盘,游戏完美运行!
至此我们的热身到此结束!
0x02 前期工作:查找地址、汇编码
进入正题了,首先我们来了解一下游戏主程序的结构。
游戏主程序被拆分成两部分,分别为game.exe、APP.dll和APP8.dll,游戏厂商dll放在了游戏根目录下的bin文件夹。
其中APP.dll为非DirectX8模式下专用DLL,APP8.dll为DirectX8专用DLL。真正的游戏主程序在这两个dll里,而game.exe只是个壳子而已,里面并不包含游戏主程序,需要与APP.dll或APP8.dll合在一起才完整的组成一个游戏主程序,所以我们后续的游戏修改需要对DLL操作。这种双DLL的开发方案,是游戏厂商的无奈之举,2004年正是个人电脑大规模普及+显卡硬件极端割裂的剧变时代。新旧版DirectX的图形渲染机制不同,如果游戏厂商把所有代码死死写进一个game.exe 里,那么为了照顾老显卡,新显卡的特效就开不出来;为了照顾新显卡,老显卡用户一启动游戏就会直接崩溃,十分尴尬。因此游戏厂商只好退让一步,采用折中方案,当game.exe启动后的环境设定中选择了DirectX8模式,就使用APP8.dll运行游戏,否则就用APP.dll,但请放心,无论哪个DLL,都不会影响游戏修改效果,因为两个DLL里的汇编码偏移都是一模一样的。
现在我们由易到难,依次实现修改金钱和碎片、免疫伤害(即无敌)、一击必杀、足球赛修改。
建议在窗口模式下运行游戏,这样你会更方便地与CE修改器互相切换。
运行游戏和CE修改前,将游戏进程附加到CE上
1、【基础】查找金钱、碎片地址
随便读一个存档,这时候我们有3589金钱、268碎片。
回到CE,搜索3589,点击首次扫描
这时候你会发现有3条结果是绿色的,说明除了DLL基址会动态变化外,相对于app.dll的是静态的,不像上次某上海滩那样还要去推断静态基址。这就是为什么推荐各位学修改前先从这个游戏开始练习。
这时你可能会想,应该回到游戏打怪赚钱,开始下一次扫描,你说的确实没错,但是我有更简便的办法,这时候已经赚到3675了,其中一个绿色的app.dll+DAC7C0用红色字体标注出3675,说明它就是我们所要找的金钱地址。
双击这条搜索结果,加入到地址列表,起个名字。
同理,搜索到碎片的地址。
修改需要的数值,这样,就可以轻松实现金钱和碎片自由。
2、【进阶1】免疫伤害
要想实现免疫所有伤害,HP血量地址查找少不了。如图,现在的血量是50。
回到CE,查找50,接着让魔物打掉我们一些血。
好家伙,这货攻击力不低,轻轻松松干下去三分之一的血量。
继续搜索剩余血量,发现有两条搜索结果。
这时候你会想,有一条是真正的血量,另一个只是干扰项而已,再掉点血就找出来了,如果你这样想,你就太天真了,无论你掉多少血,直至GameOver的时候也都是这两条结果。那么开发者为什么要弄两个血量地址呢?这里就蕴藏着现代程序设计极其讲究分工,这两个地址分工明确,各尽其责,一个用于数据核心业务的处理,一个用于供图形界面渲染和显示。难道用一个变量不行吗?还真不行,一方面是出于视觉效果的考量,核心业务里的血量地址突然从50减到32,就好比用计算器按下“=”一样,会瞬间出结果,如果图形界面也使用这个地址,会使动画过渡效果显得生硬而突兀;另一方面,也是最关键的,就是减少跨模块访问的性能消耗,核心物理引擎和底层画面渲染通常在不同的“线程”或“模块”里。让负责画画的代码不停地去读核心结构体,会造成性能冲突。
还记得我们从某上海滩的教程里去怎样寻找子弹减少的汇编码吗?接下来我们就要寻找是哪里改写了血量值,哪里大概率会有一个sub减法操作。
因为我们不知道哪一条结果是核心业务的主血量地址,所以这两个都要排查。如图,先从第一个起,按Ctrl+F6,或者在第一条结果上右击“查找写入此地址的内容”,也有的汉化版本说的是“找出是什么改写了这个地址”,其实都是一样的,大同小异,无需纠结。
弹出了扫描窗口,放在一边不管,回到游戏,继续故意掉血。
我们看到,扫描了一条结果
点击显示反汇编器
定位到了这里
汇编码是这样的
app.dll+FD335 - A1 345CAC04 - mov eax,[app.dll+C65C34]
app.dll+FD33A - 8B 0D 0CC7C004 - mov ecx,[app.dll+DAC70C]
app.dll+FD340 - 3B C1 - cmp eax,ecx
app.dll+FD342 - 74 18 - je app.dll+FD35C
app.dll+FD344 - 7E 10 - jle app.dll+FD356
app.dll+FD346 - 8B 15 285CAC04 - mov edx,[app.dll+C65C28]
app.dll+FD34C - 2B C1 - sub eax,ecx
app.dll+FD34E - 03 D0 - add edx,eax
app.dll+FD350 - 89 15 285CAC04 - mov [app.dll+C65C28],edx
app.dll+FD356 - 89 0D 345CAC04 - mov [app.dll+C65C34],ecx
app.dll+FD35C - 39 3D 101A3204 - cmp [app.dll+4C1A10],edi
app.dll+FD362 - 75 2C - jne app.dll+FD390
app.dll+FD364 - A0 551D3204 - mov al,[app.dll+4C1D55]
app.dll+FD369 - 84 C0 - test al,al
这两行代码很是特别,正好是我们刚搜到的两个结果。游戏的大概操作是:将两个地址的数值分别放入 eax 和 ecx 寄存器,然后比较图形显示血量与核心业务血量的大小,即检测数据是否同步。如果条件成立,就会直接触发跳转,也就没有必要执行后续的减法操作并更新图形界面;如果条件都不成立,只能顺着汇编流滑落,执行后续的减法扣血操作并更新图形界面。所以我们可以非常确定,app.dll+DAC70C才是真正的核心业务血量的地址。否则游戏的总机内核干啥非要跟app.dll+C65C34拼死拼活地来回 cmp 对比?甚至还拿它的落差来计算受击伤害?如果它不是真血,这在逻辑上简直是子虚乌有、天方夜谭!
排除了app.dll+C65C34的嫌疑后,我们先回点血,省的意外GameOver,现在开始扫描app.dll+DAC70C被哪里改写。
经过扫描,原来是这里。
点进汇编码一看,这里也有一条sub操作,附近的代码是这样的:
app.dll+614D8 - 8B 3D 0CC7C204 - mov edi,[app.dll+DAC70C]
app.dll+614DE - 8B 15 2089AE04 - mov edx,[app.dll+C68920]
app.dll+614E4 - A1 F466AE04 - mov eax,[app.dll+C666F4]
app.dll+614E9 - 8B 0D F866AE04 - mov ecx,[app.dll+C666F8]
app.dll+614EF - 2B FE - sub edi,esi
app.dll+614F1 - 83 C4 0C - add esp,0C
app.dll+614F4 - 85 D2 - test edx,edx
app.dll+614F6 - 89 3D 0CC7C204 - mov [app.dll+DAC70C],edi
来到IDA,按G键输入偏移10000000h+614D8h,再按空格,看一下程序框图
用大白话讲,将主角血量地址的值移入edi寄存器(画黄色框位置),然后对edi寄存器的值进行减法操作,用esi寄存器的值减edi寄存器的值(画红色框位置),最后把edi寄存器的值写回主角血量地址(画蓝色框位置)。
综上所述,edi寄存器寄存的是血量,esi寄存器寄存的是减血量
在此期间,程序又对其他三种寄存器进行了操作,可能你会觉得满头雾水,但是我们不需要关心它在干什么,因为我们的目标是实现无敌,并且减血机制已经清晰明了。
这时候有同学会想:这个So easy!sub edi,esi改成sub edi,0就OK了,如果你这样想,等着游戏闪退吧!
在某大富翁的文章中已经说过,修改汇编码只能少于或等于原字节码,否则会破坏偏移,edi寄存器是32位寄存器,要减去某个数值是需要占字节空间的,哪怕是0,sub edi,esi的字节码是2B FE,占两个字节,而sub edi,esi的字节码是83 EF 00,多出来一个字节,这已经破坏了偏移。
所以,现在只能尝试nop。
双击汇编语句,改成nop,来看一下血量是不是不再减少了。
由于sub edi,esi字节长度是两个字节,而我们填了nop后就相当于在不破坏偏移的前提下填充了两个nop,所以才会出现如图所示的弹窗进行询问,此时一定要选择<是>,否则程序不认后面的0xFE,导致游戏崩溃闪退。
修改好后,回到游戏,受到伤害后血条停止减少了。
不过,新的问题出现了,都免疫伤害了,但是角色受伤害时候减少的血量值特效还是存在,显得满满违和感,原因很简单,我们刚才只是通过nop指令覆盖了sub edi,esi,从而达到屏蔽减法操作来实现免疫,当前esi寄存器的值是减数,edi寄存器的值是被减数,在mov血量到edi操作之前,发现了一个call了一个函数,其中还push了esi,不出所料的话,这里应该就是减血量特效的调用函数。
将它nop后,减血量特效消失了。
这样修改并不完美,要同时改sub和call这两处比较麻烦,另外游戏内核程序位于app.dll,而它的基址是动态的,还原的时候需要计算app.dll的基址再加上call的偏移,也很麻烦。所以我们就不该这样改,而是继续跟踪esi的源头,是谁写的esi。
再次来到IDA,跳转到10000000h+614D8h,按空格切到程序框图视图,这里有一个小技巧,IDA当选中某个地址、指令、寄存器的时候将会高亮选中的对象,同时其他同名的也会高亮,如图所示,我高亮了sub edi,esi的esi,其他的esi也会高亮,下面跟踪程序框图走,看谁操作了esi。
经过对esi的跟踪,来到下图所示的位置,画红框的地方就是我们要寻找的esp的源头,也就是伤害值的地址。
就在这时,下面的mov esi, 1引起了我的注意,当时在想为什么都把伤害值写进去了,还分出来一条分支,给esi写1,这让我想起了游戏的一个防御机制:当穿戴一些防具或者特定难度下掉下去受伤的时候,只减1点血量。就此可推理上面的cmp的是一个特定标记值,如果这个标记值等于0Ch,就会让ZF标记位置1,ZF为0,就不会触发jnz,mov esi, 1自然也就生效,否则就会按正常流程减血。
上面的并不是主要的,主要而是这些。
很显然,ZF为1的时候才会跳转到把减血量mov到esi的操作,否则直接进行下一行,也就是xor esi, esi,此时esi无论什么值,最终异或出来的都是0,后面再怎么操作,esi也是0了。
所以应该改的是下面这一行,使jnz跳转目标不再是mov到esi的地方。
0x100613F2行的jnz是一个短跳,字节码为74 07,往下跳7行到读取减血量到esi的位置,而esi通过xor置0的操作在它的下一行,所以把这里跳7行改成跳0行,这意味着一行也不挑,继续走下一行,把字节码变为74 00就可以了。
回到CE的内存查看器窗口,已知此处偏移是0x613F2,跳转地址输入app.dll+613F2就定位到这里了。
在此处双击汇编码,输入je app.dll+613F4,点击<确定>
跳转目标变过来了,app.dll+613F2的字节码也从74 07变为74 00了。
这样一来,即免疫了伤害又保留了减血特效。
就此,免疫伤害要修改的地方研究完成!贴一下这里完整的汇编码,关键操作进行一下注释。
前半部分略
app.dll+613F2 - 74 07 - je app.dll+613FB ; eax相等则跳转到取伤害值的操作
app.dll+613F4 - 33 F6 - xor esi,esi ; esi异或变0,然后直接跳转到减血操作
app.dll+613F6 - E9 99000000 - jmp app.dll+61494
app.dll+613FB - 8B 74 24 30 - mov esi,[esp+30] ; 将伤害值移入到esi寄出去
app.dll+613FF - 85 F6 - test esi,esi
app.dll+61401 - 0F8E 8D000000 - jng app.dll+61494
app.dll+61407 - 83 3D 10729D04 0C - cmp dword ptr [app.dll+C67210],0C ; 血量减1标记位应为0Ch,如果不符合,按正常流程进行。
app.dll+6140E - 75 05 - jne app.dll+61415
app.dll+61410 - BE 01000000 - mov esi,00000001
app.dll+61415 - A0 5A1F2304 - mov al,[app.dll+4C1F5A]
app.dll+6141A - 84 C0 - test al,al
app.dll+6141C - B9 30AEB204 - mov ecx,app.dll+DBAE30
app.dll+61421 - 75 2E - jne app.dll+61451
app.dll+61423 - 8D 44 24 1C - lea eax,[esp+1C]
中间部分略
app.dll+61494 - A0 B31C2304 - mov al,[app.dll+4C1CB3] ;这里是最关键的减血操作
app.dll+61499 - 84 C0 - test al,al
app.dll+6149B - 74 1C - je app.dll+614B9
app.dll+6149D - 8D 0C 36 - lea ecx,[esi+esi]
app.dll+614A0 - B8 56555555 - mov eax,55555556
app.dll+614A5 - F7 E9 - imul ecx
app.dll+614A7 - 8B CA - mov ecx,edx
app.dll+614A9 - C1 E9 1F - shr ecx,1F
app.dll+614AC - 03 CA - add ecx,edx
app.dll+614AE - 8B F1 - mov esi,ecx
app.dll+614B0 - 75 0B - jne app.dll+614BD
app.dll+614B2 - BE 01000000 - mov esi,00000001
app.dll+614B7 - EB 04 - jmp app.dll+614BD
app.dll+614B9 - 85 F6 - test esi,esi
app.dll+614BB - 74 11 - je app.dll+614CE
app.dll+614BD - 6A 64 - push 64
app.dll+614BF - 68 0000803F - push 3F800000
app.dll+614C4 - B9 289CC704 - mov ecx,app.dll+F09C28
app.dll+614C9 - E8 F2851300 - call app.dll+199AC0
app.dll+614CE - 6A 02 - push 02 { 2 }
app.dll+614D0 - 56 - push esi
app.dll+614D1 - 6A 00 - push 00 { 0 }
app.dll+614D3 - E8 782AFBFF - call app.dll+13F50 ; 压入减血量及其他必备参数,并调用减血量特效动画函数。
app.dll+614D8 - 8B 3D 0CC7B104 - mov edi,[app.dll+DAC70C] ; 将角色血量地址的值移入edi
app.dll+614DE - 8B 15 20899D04 - mov edx,[app.dll+C68920]
app.dll+614E4 - A1 F4669D04 - mov eax,[app.dll+C666F4]
app.dll+614E9 - 8B 0D F8669D04 - mov ecx,[app.dll+C666F8] ; 后续才知道这里是钻头能量值,每100是一级,四级钻头就是400
app.dll+614EF - 2B FE - sub edi,esi { 开始减血操作
app.dll+614F1 - 83 C4 0C - add esp,0C
app.dll+614F4 - 85 D2 - test edx,edx
app.dll+614F6 - 89 3D 0CC7B104 - mov [app.dll+DAC70C],edi ; 减血后,放回角色血量地址
app.dll+614FC - 8B 3D 10C7B104 - mov edi,[app.dll+DAC710]
app.dll+61502 - 75 22 - jne app.dll+61526
app.dll+61504 - 85 F6 - test esi,esi
app.dll+61506 - 74 1E - je app.dll+61526
app.dll+61508 - 8D 68 01 - lea ebp,[eax+01]
app.dll+6150B - 8B C7 - mov eax,edi
app.dll+6150D - 99 - cdq
app.dll+6150E - F7 FD - idiv ebp
app.dll+61510 - 8B E8 - mov ebp,eax
app.dll+61512 - 8B C6 - mov eax,esi
app.dll+61514 - 6B C0 64 - imul eax,eax,64
app.dll+61517 - 99 - cdq
app.dll+61518 - F7 FD - idiv ebp
app.dll+6151A - 83 F8 02 - cmp eax,02
app.dll+6151D - 7F 05 - jg app.dll+61524
app.dll+6151F - B8 02000000 - mov eax,00000002 ; 钻头能量值减少量操作
app.dll+61524 - 2B C8 - sub ecx,eax ; 开始减钻头能量
app.dll+61526 - 89 0D F8669D04 - mov [app.dll+C666F8],ecx { 减完钻头能量后放回原地址
后半部分略
免疫伤害大功告成!
3、【进阶2】一击必杀
我们并不知道魔物的血量,尽管是boss战也只能看到血条,没有具体血量数值。
这时候就要自己寻找了。
建议找血量少的大boss,这样查找地址会方便一些,我找的是遗迹场景的主boss,这个boss起初是穿着护甲的,直接攻击不会减血,减血量为0,只有主角长按攻击键对他的护甲发起蓄力攻击后,脱下了护甲,这时候再攻击才会减血。
开新局,此时boss为满血状态
回到CE,搜索数值,因为我们不知道boss的血量到底是多少,所以扫描类型只能选择“未知初始值”来发起搜索。
这时必然会搜到成千上万条结果,不用管它。
这个boss有护甲,普通攻击会造成0伤害,所以boss的血量一定没有变化,这时就可以过滤掉大量有变动的数值。
到CE里,将扫描类型改成“未改变的值”
搜索完成后,搜索结果过滤掉了很多。现在回到游戏,用蓄力攻击打掉boss的护甲,让他脱掉护甲,立马打掉他一部分血。
再次回到CE,boss掉血了,因此扫描类型就要选“减少的值”再发起下一轮搜索。
就这样如此循环搜索,打掉一点血就搜减少的值,再打再搜,然后我在改变方法,让他穿上护甲后再搜未改变的值,以此类推……
最终成功过滤出boss的血量地址,这也是为什么我选择这个boss的原因,他有特定情况下免疫伤害技能,找地址更快。
我们是在boss满血的时候发起的搜索,所以搜索结果栏的First列显示的值就是他的血量,为1200(为什么要满血搜,毕竟boss满血状态下通常是整数,更容易辨认)。
而当前值是895,再回到游戏,看到boss的血条比例。
现在血条卡在大概四分之一的位置,如果boss血量1200,当前895,百位数取整的话就是900,基本上确认这条地址就是boss血量。
现在尝试修改一下这个数值,改成600,看看血条会不会减到一半。
双击这个地址,加入到地址列表,如图所示,双击地址的值,改成600,并点击<确定>。
回到游戏,果然boss的血量到一半了。
下面我们就要找出是哪一行汇编码操作的减血。如图,按Ctrl+F6,或者在第一条结果上右击“查找写入此地址的内容”,也有的汉化版本说的是“找出是什么改写了这个地址”,其实都是一样的,大同小异,无需纠结。
把游戏进程附加到CE后首次进行汇编码跟踪会询问要不要把调试器付加进去,选择是。
随后出现了下面的窗口。放到一边别管它。
回到游戏,继续攻击boss,让它掉点血,经过激战后,这个窗口捕捉到了一条结果。
选中它,点“显示反汇编器”,看看它在干些啥。
随后CE会定位到这一行。
这一行的汇编码如下
app.dll+9A507 - 89 82 0CC7B104 - mov [edx+app.dll+DAC70C],eax
通过这条语句,我们只知道boss的血量是临时寄存在eax寄存器里的,而boss血量地址是通过edx加上dll基址再加上DAC70Ch算出来的,这一语句给我们的信息量很大,不同敌人或者魔物他的地址不同,其血量地址完全取决于edx的偏移量,对所有的血量地址操作那肯定是不可能的,所以这一行还看不出什么东西来,继续向上翻。
好家伙,它的上一语句就是:
app.dll+9A505 - 2B C3 - sub eax,ebx
简单的设置令人发指!由此看出,ebx是主角给出的伤害值。
这样一来,如果把sub eax,ebx改成sub eax,eax,不就一击必杀了吗?反正都是占两个字节。
不过先别急,我们是cracker,所以敌人要挂掉也要优雅地挂掉,如果这里用一下xor也就是异或操作来覆盖掉原有的eax的值,得出的结果也是0,这样改有一个好处就是xor的执行效率优于sub。
双击这条汇编语句,改成xor eax,eax,点击<确定>保存。
这时候回到游戏再去打boss,发现他哪怕是是有防御技能也会被我们一刀KO了。
我们看到boss的掉血特效还是0,这说明ebx的值是最终伤害值。
就此一击必杀搞定!
【高潮】MiniGame之足球赛修改
NPC的球技还是有两下子的,特别是敌方,周目难度越大,越不好进球,我们先来寻找剩余时间的地址。
足球赛的总时间是三分钟,我们还是在计时器归位成三分钟的时候搜索未知初始值,因为我们目前并不知道时间的存储方式究竟是整数型还是浮点型。
会和刚才一样,搜索出大量结果,我们还是要通过比赛时搜减少的值,暂停或者进球时候,搜索未改变的值,这里的操作略,经过多轮搜索,找到了一个绿色的静态地址。
经过反复确认。这个地址的值比赛期间减小,暂停期间不改变。发起搜索的时候为5400,说明前端显示3分钟,后端对应的数值就是5400。
我们知道,三分钟是180秒,5400除以180就是30,说明一秒钟减少30。
咦!这不就是日本电视采用的的NTSC制式吗?每秒钟30帧画面,但这并不重要,继续寻找它是被谁写入的。
右键这个地址,选择查找此地址写入的内容,跟踪到了一条语句,定位到了此处。
原来地址里的数值是ecx写入的,往上翻,好家伙,这么显眼的dec摆在前面。
果断改成nop,屏蔽掉时间自减
成功暂停时间
时间暂停没什么卵用,敌方球技太强了
那么,能不能让敌方进球,给我方白送分呢?
OK,现在就来皮一下,让敌方所有的辛勤付出化为乌有!
老规矩,先打一会儿比赛,一边打,一边搜索地址,双方积分都要搜。
搜索一方积分地址的时候,第一次搜索输入0,进球后扫描类型要选“数值增加了…”,积一分就在数值输入框填1.
我先找的是敌方积分,现在已经送他三粒球了,筛出了两个地址,不出意外的话应该有一个控制UI用的。
就这样,基本确认敌方和我方的进球积分地址分别为app.dll+C66F50、app.dll+20DF4C。
再让敌方进一球确认一下,并且找出改写积分地址的汇编码位置,定位到了app.dll+87672,同理找到我方的积分地址的汇编码位置定位到了app.dll+87791。
不得不佩服22年前的日游AI(不是指现在的所谓AI)系统是真的强,搜索双方积分改写操作的汇编码位置仅用不到两分钟就送了对方21粒球。
这就很国足了…
再回到IDA,来着重看这一部分程序框图。
黄框、蓝框为敌方、我方的进球操作,包括写入临时计数、写入正式计数、图形界面更新、后续收尾操作等,实际上进球检测的上面还有好几层检测,因为篇幅问题,无法呈现出来。只看到jnz short loc_100876C3语句直接伸向了敌方成功进球的操作,即使未触发ZF=0,后面也有多重条件跳转。
很显然,这里是进球状态的扫描操作,在满足特定条件的时候实行敌方或者我方的进球操作。
看完了框图,再捋一下汇编码:
.text:100875C2 DB 05 24 2C 21 10 fild dword_10212C24
.text:100875C8 D9 5C 24 18 fstp [esp+388h+var_370]
.text:100875CC D9 44 24 64 fld [esp+388h+var_324]
.text:100875D0 D8 5C 24 18 fcomp [esp+388h+var_370]
.text:100875D4 DF E0 fnstsw ax
.text:100875D6 F6 C4 41 test ah, 41h
.text:100875D9 0F 85 E4 00 00 00 jnz loc_100876C3
.text:100875DF DB 05 30 2C 21 10 fild dword_10212C30
.text:100875E5 D9 5C 24 18 fstp [esp+388h+var_370]
.text:100875E9 D9 44 24 60 fld [esp+388h+var_328]
.text:100875ED D8 5C 24 18 fcomp [esp+388h+var_370]
.text:100875F1 DF E0 fnstsw ax
.text:100875F3 F6 C4 05 test ah, 5
.text:100875F6 0F 8A C7 00 00 00 jp loc_100876C3
.text:100875FC DB 05 20 2C 21 10 fild dword_10212C20
.text:10087602 D9 5C 24 18 fstp [esp+388h+var_370]
.text:10087606 D9 44 24 60 fld [esp+388h+var_328]
.text:1008760A D8 5C 24 18 fcomp [esp+388h+var_370]
.text:1008760E DF E0 fnstsw ax
.text:10087610 F6 C4 41 test ah, 41h
.text:10087613 0F 85 AA 00 00 00 jnz loc_100876C3
.text:10087619 DB 05 38 2C 21 10 fild dword_10212C38
.text:1008761F D9 5C 24 18 fstp [esp+388h+var_370]
.text:10087623 D9 44 24 68 fld [esp+388h+var_320]
.text:10087627 D8 5C 24 18 fcomp [esp+388h+var_370]
.text:1008762B DF E0 fnstsw ax
.text:1008762D F6 C4 05 test ah, 5
.text:10087630 0F 8A 8D 00 00 00 jp loc_100876C3
.text:10087636 DB 05 28 2C 21 10 fild dword_10212C28
.text:1008763C D9 5C 24 18 fstp [esp+388h+var_370]
.text:10087640 D9 44 24 68 fld [esp+388h+var_320]
.text:10087644 D8 5C 24 18 fcomp [esp+388h+var_370]
.text:10087648 DF E0 fnstsw ax
.text:1008764A F6 C4 41 test ah, 41h
.text:1008764D 75 74 jnz short loc_100876C3 ;状态扫描,如果ZF为0,才会基本满足我方进球的先决条件,否则按正常顺序执行,即敌方进球。
.text:1008764F A1 F0 DE 20 10 mov eax, dword_1020DEF0;写入临时地址,并自增1
.text:10087654 40 inc eax
.text:10087655 83 F8 02 cmp eax, 2
.text:10087658 A3 F0 DE 20 10 mov dword_1020DEF0, eax
.text:1008765D 0F 8E 5C 01 00 00 jle loc_100877BF
.text:10087663 A1 44 DF 20 10 mov eax, dword_1020DF44;敌方积分地址移入eax,并自增1
.text:10087668 40 inc eax
.text:10087669 8D 54 24 18 lea edx, [esp+388h+var_370]
.text:1008766D A3 44 DF 20 10 mov dword_1020DF44, eax;写回敌方积分地址
.text:10087672 A3 50 6F C6 10 mov dword_10C66F50, eax;写入用于更新图形界面的敌方积分地址
.text:10087677 52 push edx
.text:10087678 8D 44 24 50 lea eax, [esp+38Ch+var_33C]
.text:1008767C C7 05 04 DF 20 10 0F 00 00 00 mov dword_1020DF04, 0Fh
.text:10087686 C7 44 24 1C 9A 99 99 3E mov [esp+38Ch+var_370], 3E99999Ah
.text:1008768E 50 push eax
.text:1008768F
.text:1008768F loc_1008768F: ; CODE XREF: sub_10082B70+4C34↓j
.text:1008768F B9 D8 56 80 10 mov ecx, offset flt_108056D8
.text:10087694 C7 05 F0 DE 20 10 6A FF FF FF mov dword_1020DEF0, 0FFFFFF6Ah
.text:1008769E E8 DD 7C 10 00 call sub_1018F380
.text:100876A3 50 push eax
.text:100876A4 B9 D8 56 80 10 mov ecx, offset flt_108056D8
.text:100876A9 E8 D2 7D 10 00 call sub_1018F480
.text:100876AE 89 1D 18 DF 20 10 mov dword_1020DF18, ebx
.text:100876B4 C7 05 28 DF 20 10 78 00 00 00 mov dword_1020DF28, 78h ; 'x'
.text:100876BE E9 FC 00 00 00 jmp loc_100877BF
.text:100876C3 ; ---------------------------------------------------------------------------
.text:100876C3
.text:100876C3 loc_100876C3: ; CODE XREF: sub_10082B70+4A4C↑j;此处是满足我方进球先决条件的判断,但凡有一处不满足都会跳转至100877A9结束判断
.text:100876C3 ; sub_10082B70+4A69↑j ...
.text:100876C3 DB 05 54 2C 21 10 fild dword_10212C54
.text:100876C9 D9 5C 24 18 fstp [esp+388h+var_370]
.text:100876CD D9 44 24 64 fld [esp+388h+var_324]
.text:100876D1 D8 5C 24 18 fcomp [esp+388h+var_370]
.text:100876D5 DF E0 fnstsw ax
.text:100876D7 F6 C4 05 test ah, 5
.text:100876DA 0F 8A C9 00 00 00 jp loc_100877A9
.text:100876E0 DB 05 44 2C 21 10 fild dword_10212C44
.text:100876E6 D9 5C 24 18 fstp [esp+388h+var_370]
.text:100876EA D9 44 24 64 fld [esp+388h+var_324]
.text:100876EE D8 5C 24 18 fcomp [esp+388h+var_370]
.text:100876F2 DF E0 fnstsw ax
.text:100876F4 F6 C4 41 test ah, 41h
.text:100876F7 0F 85 AC 00 00 00 jnz loc_100877A9
.text:100876FD DB 05 50 2C 21 10 fild dword_10212C50
.text:10087703 D9 5C 24 18 fstp [esp+388h+var_370]
.text:10087707 D9 44 24 60 fld [esp+388h+var_328]
.text:1008770B D8 5C 24 18 fcomp [esp+388h+var_370]
.text:1008770F DF E0 fnstsw ax
.text:10087711 F6 C4 05 test ah, 5
.text:10087714 0F 8A 8F 00 00 00 jp loc_100877A9
.text:1008771A DB 05 40 2C 21 10 fild dword_10212C40
.text:10087720 D9 5C 24 18 fstp [esp+388h+var_370]
.text:10087724 D9 44 24 60 fld [esp+388h+var_328]
.text:10087728 D8 5C 24 18 fcomp [esp+388h+var_370]
.text:1008772C DF E0 fnstsw ax
.text:1008772E F6 C4 41 test ah, 41h
.text:10087731 75 76 jnz short loc_100877A9
.text:10087733 DB 05 58 2C 21 10 fild dword_10212C58
.text:10087739 D9 5C 24 18 fstp [esp+388h+var_370]
.text:1008773D D9 44 24 68 fld [esp+388h+var_320]
.text:10087741 D8 5C 24 18 fcomp [esp+388h+var_370]
.text:10087745 DF E0 fnstsw ax
.text:10087747 F6 C4 05 test ah, 5
.text:1008774A 7A 5D jp short loc_100877A9
.text:1008774C DB 05 48 2C 21 10 fild dword_10212C48
.text:10087752 D9 5C 24 18 fstp [esp+388h+var_370]
.text:10087756 D9 44 24 68 fld [esp+388h+var_320]
.text:1008775A D8 5C 24 18 fcomp [esp+388h+var_370]
.text:1008775E DF E0 fnstsw ax
.text:10087760 F6 C4 41 test ah, 41h
.text:10087763 75 44 jnz short loc_100877A9
.text:10087765 A1 F0 DE 20 10 mov eax, dword_1020DEF0
.text:1008776A 40 inc eax
.text:1008776B 83 F8 02 cmp eax, 2
.text:1008776E A3 F0 DE 20 10 mov dword_1020DEF0, eax
.text:10087773 7E 4A jle short loc_100877BF
.text:10087775 A1 4C DF 20 10 mov eax, dword_1020DF4C;我方积分地址移入eax,并自增1
.text:1008777A 8D 4C 24 38 lea ecx, [esp+388h+var_350]
.text:1008777E 40 inc eax
.text:1008777F 51 push ecx
.text:10087780 8D 94 24 F0 01 00 00 lea edx, [esp+38Ch+var_19C]
.text:10087787 C7 05 0C DF 20 10 0F 00 00 00 mov dword_1020DF0C, 0Fh
.text:10087791 A3 4C DF 20 10 mov dword_1020DF4C, eax
.text:10087796 A3 80 6C C6 10 mov Value, eax
.text:1008779B C7 44 24 3C 9A 99 99 3E mov [esp+38Ch+var_350], 3E99999Ah
.text:100877A3 52 push edx
.text:100877A4 E9 E6 FE FF FF jmp loc_1008768F
.text:100877A9 ; ---------------------------------------------------------------------------
.text:100877A9
.text:100877A9 loc_100877A9: ; CODE XREF: sub_10082B70+4B6A↑j
.text:100877A9 ; sub_10082B70+4B87↑j ...
.text:100877A9 39 1D F0 DE 20 10 cmp dword_1020DEF0, ebx
.text:100877AF 7C 08 jl short loc_100877B9
.text:100877B1 89 1D F0 DE 20 10 mov dword_1020DEF0, ebx
.text:100877B7 EB 06 jmp short loc_100877BF
.text:100877B9 ; ---------------------------------------------------------------------------
.text:100877B9
.text:100877B9 loc_100877B9: ; CODE XREF: sub_10082B70+4C3F↑j
.text:100877B9 FF 05 F0 DE 20 10 inc dword_1020DEF0
相信同学们已经想出来如何让敌方进球给我方白送分的办法了,把1008764Dh行的 jnz short loc_100876C3的跳转条件和跳转目标行直接改到10087775h处,如果你这样想的话那你就太天真了。
首先,这确实是一个简单而粗暴的办法,程序框图也没有欺骗我们,双方进球的前几行操作一模一样,改跳转目标确实可以实现白送分,不过这里有一个非常严重的问题——字节空间。
请在翻回10087775h行,重新阅读一遍此行:
.text:1008764D 75 74 jnz short loc_100876C3
IDA已经给我们标注出这一行占两个字节:75 74,用人话讲就是当ZF标记位非零的时候在本行末尾处开始以短跳的方式跳转74h字节,这里的75是jnz的短跳字节码,也就是排除敌方一定进球的先决条件的地方100876C3h行,否则就按下一行继续走,下一行就是敌方进球的首行汇编语句了。
相信同学们已经想到让敌方进球给我放白送分的方法了,比如:直接nop掉啥也不做;jnz改成jmp强跳;jnz改成jz来去反结果。那么恭喜你,你都答错了,哈哈哈!
那我们就来分析为什么它们都不正确。
再看一遍程序框图
首先说如果直接nop掉,我们已知ZF为1的时候就表明排除敌方一定进球的先决条件,如下图所示,根据上图所示的程序框图,可以清楚的看到,这里ZF非零时候跳就是PF为1的时候跳,它们的共同点就是都跳向100876C3
这个时候ZF和PF标记位状态都是根据球场上的战况而变化的,如果直接nop则再也不会检验ZF的状态,自然也不会触发任何一方的进球积1分的操作,jnz改成jmp强跳和jnz改成jz来去反结果也是同理,因为已经跳过了ZF检查的相关操作。
这时候正确的做法应该是,压根就不该打1008764Dh行的主意,而是改1008764Fh行,我们在此先让程序构建好程序走正常流程即敌方进球积分的条件,然后从1008764Fh行进行jmp强跳,1008764Fh占五字节,用jmp长跳字节码EP再加上四字节再也合适不过了。我们肯定不能跳到100876C3h行,毕竟排除了敌方一定进球的条件后也是临门一脚球的状态,球不一定能进。所以我们直接粗暴地改成我方进球操作的的首行即10087775h就可以了。
计算10087765h-(1008764Fh+5h)=111h
因此要把1008764Fh行进行修改:
.text:1008764F E9 11 01 00 00 jmp loc_10087765
在CE中应该按下图所示修改
再回到游戏,敌方进球,就给我们白送分了。
由于帖子不支持插入视频,请大家移步到B站观看
传送门
https://www.bilibili.com/video/BV1ojEm6rEw3
本来想研究无限跳跃的,但是遗憾的是没有找到相关状态的内存地址,不知道前辈是怎么找到的,我尝试使用鼠标状态扫描的API来跟踪鼠标中键发起的跳跃操作,但它是一个异步API,只要给这个API下断就会停住,根本无法进行,求大佬想个办法。
0x03 编写修改器核心代码
老规矩,为了兼顾开发效率和界面美观,我们还是使用 C# 来编写这款修改器。
在前的实战中,我们通过CE和IDA确定了各修改效果的实施方案,现在我们要让修改器全自动地去完成这些操作。
首先要构建进程与底层DLL扫描器,这里分两步走:先搜索目标进程再搜索DLL。
考虑到进程名字会变,进程ID也是动态分配的,只有游戏窗口标题是不变的,所以我采用通过标题搜索进程;搜索DLL的时候就可以按文件名搜了,毕竟谁也不敢改DLL文件名,EXE加载的不是app.dll就是app8.dll两个都遍历就OK了。
所有的修改操作一定要先确定句柄是不是Zero,再根据DLL的句柄得到基址偏移到目标汇编行/属性地址。
功能1:加金钱
修改金钱须定位金钱的绝对地址,现在已知金钱的相对地址为0xDAC7C0,那就要先获取DLL的句柄,得到基址,然后两者相加就是金钱的绝对地址了,其他也是如此。这时候一对“亲兄弟”WriteProcessMemory和ReadProcessMemory就要登场了。
来看一下这段“加钱”的核心逻辑:
private const int RVA_MONEY = 0xDAC7C0; // 金钱
/// <summary>
/// 增加金钱方法
/// <param name="amount">(整数型 欲增加的金额)</param>
/// </summary>
public void AddMoney(int amount)
{
// 安全判定:如果没有连接成功,直接退出
if (hProcess == IntPtr.Zero || baseAddress == IntPtr.Zero) return;
// 计算绝对地址
IntPtr targetAddress = (IntPtr)(baseAddress.ToInt32() + RVA_MONEY);
// 读取当前金钱
byte[] buffer = new byte[4];
IntPtr bytesRead;
ReadProcessMemory(hProcess, targetAddress, buffer, 4, out bytesRead);
// 将读取到的4字节小端序转换成整数
int currentMoney = BitConverter.ToInt32(buffer, 0);
// 计算新金钱
int newMoney = currentMoney + amount;
if (newMoney < 0) newMoney = 0; // 防止变负数
// 将新数值转换回4个字节,写回内存
byte[] writeBuffer = BitConverter.GetBytes(newMoney);
IntPtr bytesWritten;
// 写回内存
WriteProcessMemory(hProcess, targetAddress, writeBuffer, 4, out bytesWritten);
}
功能2:免疫伤害
我们已经提到过,把app.dll+613F2行字节码74 07改成74 00,jz 7行改成jz0行。很简单,直接对app.dll+613F3处下手,把07填充00。
// 免疫所有伤害
// ------------------------------------------
private const int OFFSET_GOD = 0x613F3;
// 原字节
private readonly byte[] BYTES_GOD_ORIGINAL = new byte[] { 0x07 };
// 修改后字节
private readonly byte[] BYTES_GOD_PATCHED = new byte[] { 0x00 };
// ------------------------------------------
/// <summary>
/// 无敌开关
/// <param name="enable">(逻辑型 开启状态)</param>
/// </summary>
public void God(bool enable)
{
// 安全判定:如果没有连接成功,直接退出
if (hProcess == IntPtr.Zero || baseAddress == IntPtr.Zero) return;
// 核心寻址算法:DLL基址 + 指定偏移
IntPtr targetAddress = (IntPtr)(baseAddress.ToInt32() + OFFSET_GOD);
// 根据传入的布尔值,决定写入哪一套字节码
byte[] bufferToWrite = enable ? BYTES_GOD_PATCHED : BYTES_GOD_ORIGINAL;
IntPtr bytesWritten;
// 将字节码强行写入游戏内存
WriteProcessMemory(hProcess, targetAddress, bufferToWrite, bufferToWrite.Length, out bytesWritten);
}
功能3:一击必杀
在app.dll+9A505处把sub eax, ebx改为sub eax, eax,即字节码由2B C3改为29 C0。
// 一击必杀
// ------------------------------------------
private const int OFFSET_SUPERKILL = 0x9A505; // 相对基址的偏移量
// 原字节
private readonly byte[] BYTES_SUPERKILL_ORIGINAL = new byte[] {
0x2B, 0xC3
};
// 修改后字节
private readonly byte[] BYTES_SUPERKILL_PATCHED = new byte[] {
0x29, 0xC0
};
// ------------------------------------------
/// <summary>
/// 一击必杀功能开关
/// <param name="enable">(逻辑型 开启状态)</param>
/// </summary>
public void SuperKill(bool enable)
{
// 安全判定:如果没有连接成功,直接退出
if (hProcess == IntPtr.Zero || baseAddress == IntPtr.Zero) return;
// 核心寻址算法:DLL基址 + 指定偏移
IntPtr targetAddress = (IntPtr)(baseAddress.ToInt32() + OFFSET_SUPERKILL);
// 根据传入的布尔值,决定写入哪一套字节码
byte[] bufferToWrite = enable ? BYTES_SUPERKILL_PATCHED : BYTES_SUPERKILL_ORIGINAL;
IntPtr bytesWritten;
// 将字节码强行写入游戏内存
WriteProcessMemory(hProcess, targetAddress, bufferToWrite, bufferToWrite.Length, out bytesWritten);
}
功能4:足球赛白送分
我们要修改的app.dll+8764F行原始代码含有内存地址,而这个地址是根据DLL基址变化的,还原时需要先把这个地址算出来并转成小端序字节,才可以覆盖,其他操作照常。
// 敌方进球白送分
// ------------------------------------------
private const int OFFSET_FOOTBALL = 0x8764F;// 相对基址的偏移量
private const int RVA_ENEMY_SCORE = 0x20DEF0;//敌方进球积分临时地址偏移
// 原始字节
byte BYTES_FOOTBALL_ORIGINAL = 0xA1;
// 修改后字节
private readonly byte[] BYTES_FOOTBALL_PATCHED = new byte[] {
0xE9, 0x11, 0x01, 0x00, 0x00
};
// ------------------------------------------
/// <summary>
/// 敌方进球白送分开关
/// <param name="enable">(逻辑型 开启状态)<</param>
/// </summary>
public void Football(bool enable)
{
// 安全判定:如果没有连接成功,直接退出
if (hProcess == IntPtr.Zero || baseAddress == IntPtr.Zero) return;
// 计算绝对地址
IntPtr targetAddress = (IntPtr)(baseAddress.ToInt32() + OFFSET_FOOTBALL);
int baseAddr = baseAddress.ToInt32();
byte[] bufferToWrite;
// 根据传入的布尔值,取消勾选则取非执行还原版指令
if (!enable)
{
// Step1:计算绝对地址并转为小端序字节数组
int rtEnemyBackend = baseAddr + RVA_ENEMY_SCORE;
byte[] addressBytes = BitConverter.GetBytes(rtEnemyBackend);
// Step1:创建一个长度为的新字节集数组
bufferToWrite = new byte[5];
// Step3:将 0xA1 放入第 1 个位置
bufferToWrite[0] = BYTES_FOOTBALL_ORIGINAL;
// Step4:将计算出的 4 字节小端序地址追加到后面
Buffer.BlockCopy(addressBytes, 0, bufferToWrite, 1, 4);
}
else
{
// 执行敌方进球白送分补丁
bufferToWrite = BYTES_FOOTBALL_PATCHED;
}
IntPtr bytesWritten;
// 将字节码强行写入游戏内存
WriteProcessMemory(hProcess, targetAddress, bufferToWrite, bufferToWrite.Length, out bytesWritten);
}
最终,一个简易修改前就大功告成!
0x04 总结
20多年前夜市地摊上那张5块钱的光盘,曾经带给我们无数欢乐,也留下了诸如“被黑豆瞬间秒杀”、“被电脑踢成 0:9”的童年阴影。而今天,我们带着多年的执念与新铸就的技术利刃,完成了一场彻头彻尾的“科技复仇”。越菜越爱玩,越懂越想挖,这就是逆向工程的魅力,从来都不在于在游戏里获得多么变态的属性,而在于那种看透事物底层运转本质的掌控感。当我们跨越二十年的时光,通过一行行汇编指令与当年的游戏开发者进行跨越时空的逻辑交锋,找出他们设下的迷局——这种破解与解谜的快乐,远比游戏本身通关要震撼得多。
0x05 课后作业
作业1
根据下面现成的装备基址和物品偏移,尝试写一个一键加满消耗类道具的代码,注意,道具地址占一个字节。
道具基址 0x316CE0
| 消耗类道具名称 |
道具偏移 |
| 曲奇 |
0x4 |
| 巧克力 |
0x5 |
| 蛋糕 |
0x14 |
| 强化油 |
0x13 |
| 耐久油 |
0x16 |
| 迷之袋 |
0x23 |
思考一下,加满道具填数值的时候,应该写0xFF还是0x7F,为什么?
作业2
开一局打地鼠小游戏,自己分析汇编码,如何实现无限打地鼠?
作业3
开一局碎岩石小游戏,自己分析汇编码,如何实现敌方碎岩石我方白送分?
修改器已开源,参考答案也附在了源代码上,写完后请你打开我写的源代码来比对一下。
0x06 拓展阅读
MFC(来源:百度百科)
https://baike.baidu.com/item/MFC/2530850
MFC框架软件逆向研究(来源:CSDN)
https://hetian.blog.csdn.net/article/details/141193610
ollydbg中加载的DLL基地址确定 IDA里IDA:Edit->segments->Rebase program去设置同步(来源:51cto)
https://blog.51cto.com/u_11908275/6725956
可供学习的资料比较少,大家用AI去搜索最方便。
0x07 源码、课后作业答案、相关附件
还是老规矩源码五天后可见