《决战》是一款非常古老的RPG游戏,作为热血传奇同期的热门游戏也深受7080后的喜爱。
在十几年前玩这个游戏时,我也使用过瞬移穿墙,快速施法秒怪等功能的辅助。
下面我们就用一个自己架设的单机版来回顾一下当年辅助开发的流程,并从安全角度来对这些功能进行防护分析。
一、找到明文发包函数
首先我们对功能的数据需求进行分析。
瞬移穿墙是法师专利,常规情况下法师的瞬移是不可以穿墙的,为了实现穿墙功能,我们一定要从发包入手,所以首要目标是找到明文发包函数。
然后是快速施法,既然有这个功能说明这个功能也很可能是通过快速的协议重发实现的,所以我们也要从明文发包函数入手。
xdbg32附加游戏,在send下断,走路游戏断下
重复下断,做吃药或者瞬移动作,观察堆栈发现堆栈返回是不同的,说明游戏没有线程发包
多次执行到返回,找到公共CALL和功能CALL临近的位置,为明文发包函数头部
以上都是新手的基础操作,没啥难点。
二、瞬移穿墙分析
在技能CALL上下断,进行瞬移,我们来观察下参数
明文函数有2个参数,第一个是0,第二个是结构体
结构体中+8又是一个结构体,作为明文封包结构体,而+C则是包长,其他的都写固定值即可
分析明文封包结构体
+0的6D表示技能包头,BYTE型
+1是2712,应该是角色ID,DWORD型
+5的07是瞬移技能ID,这个通过和其他技能的对比能够观察出来
然后+6和+A则是目的地的X和Y坐标,DWORD型
我们自己申请内存构建结构体,用代码注入器进行测试,发现这个封包是可以穿墙的,但是正常游戏是会撞墙的
这说明在上面有某些代码对这个坐标进行了校验和修正
我们向上分析,首先找到了组包函数
这个函数的参数就是XY坐标,并且是修正过的
那么我们去追这两个坐标的来源
修正的X和Y分别来源于ecx+8和ecx+C,而最终来源则是上面的call 0x004A2C20的返回
我们在看下call 0x004A2C20的参数
发现这个CALL的4个参数分别是当前的XY和目的地的XY,而在经过这个CALL之后,目的地的XY会被修正为不可穿墙的坐标
如果我们想手动游戏实现穿墙,可以HOOK下面的4EFA9D来将目的地的坐标传递给修正后的坐标
如果觉得HOOK代码比较麻烦的话,也可以用其他的办法,就是讲004EFA9D处的机器码改写为8B 49 90
因为我们在这个位置下断后发现,ecx-70里面存放的恰好是修正前目的地坐标的结构,而[ecx-70]+8和+C指向的就是目的地坐标
这样我们只需要写内存就可以。
这样处理以后,我们就可以瞬移穿墙了,这里并没有用到其他的数据,接下来我们分析快速施法。
三、快速施法的实现
我们回到技能CALL的位置,对一个怪物释放技能,并观察参数
封包结构体和瞬移很像,其中的包长变为了6
+0同样是包头0x6D
+1是怪物ID,DWORD型
+5是04,代表技能“冰魄”,BYTE型
我们用代码注入器对这个技能进行快速释放,发现技能的释放频率明显提升,以下是普通情况和直接发包的对比
在这个明文封包里面,我们的技能ID可以固定,其实需要传递的只是一个最近的怪物ID
所以我们接下来要对角色和周围对象的数据进行分析
四、角色对象分析
通常情况下角色对象我们要从角色的血量入手,但是当你去扫描角色血量时,会发现直接扫到一个非游戏模块的基地址
无论我们切换装备还是吃药,得到血量都是基地址,此时可能有些人认为自己已经找到了角色对象,就凑合用了。
其实这个数据并不是我们想要的答案,因为这个游戏的对象下血量只有在被近战攻击时才会被正确的写入,其他时候都是不变的或者有错误的。不止是角色,怪物也是如此。
所以我们想找角色对象,需要让怪物攻击掉血后进行扫描。
重新扫描可以得到一个非基地址的结果
对这个结果下访问断点,可以得到+58偏移
ce扫描rbx可以得到两个基地址,其中的一个基地址的结果可能会变化,所以我们选择另一个
对005780C4进行访问,可以得到1ACC偏移和基地址0x5765F8
血量公式[0x5765F8+1ACC]+58
角色对象搞定了,接下来分析周围遍历
五、周围对象遍历
周围对象遍历还是从血量入手,既然前面说到了远程攻击无法改变怪物对象下的血量,那我们就找个怪物去砍他
用CE扫描到怪物血量
用xdbg观察地址,发现怪物对象和角色对象的结构基本是一样的,也就是说第一层偏移也是+58
在血量上下访问断点
向上分析eax,得到来源[esp+10],继续分析得到来源于CALL的返回
在这个CALL里面我们可以得到一个二叉树的遍历,过程就不详细分析了,最终的公式如下
[[0x5765F8+1B0C]+4] 根节点 节点==[0x00590790]结束遍历
+0 左子树
+8 右子树
+C ID
+10 对象
对象+58 当前血量(没啥用的数据)
[对象+2C]+0 名字ASCII
对象+224 X
对象+228 Y
这些数据都是最基本的,没有什么难度,通过观察对比可以得到+158是死亡标志,怪物死亡后
六、周围对象分类
对象类型的重要性往往会被忽略,但是当你用到它时又会发现这个数据无从下手。
如果不对类型进行判断,我们就会对NPC,建筑物,甚至自己发送技能包,所以这个数据是不可或缺的。
最常见的入手点是通过和对象的交互进行分析。比如打开NPC,攻击怪物等等。
我们找到一个NPC,通过明文发包函数断到打开NPCCALL
再次返回一层
接下来找一个怪,用左键攻击他,得到普通攻击CALL
再次返回后发现和打开NPC的外层是一样的
这说明普通攻击和打开NPC是在同一个函数下的,我们需要找到区分这两个功能的跳转。
在头部下断,立刻断下,单步执行找到跳出到返回的跳转,然后绕过这个跳转,在下面下断做打开NPC动作
单步执行到一个dec代码下面时发现打开NPC不跳,攻击怪物跳
到CALL里分析eax的来源
这里跳转的ecx决定返回的eax值,所以我们只要分析ecx的来源即可,向上分析,经过一个inc后来源于+248偏移
这个偏移恰好在对象下,经过多个对象的观察,我们得出了以下结论
这里的建筑指的是传送门之类的,未知是一些看不到的对象
分类解决了,接下来就是怪物的死亡标志,这个数据是通过对对象的属性进行观察得到的,因为怪物死亡后0.5秒左右才会改变,所以如果用CE搜索的过快可能会把这个标志过滤掉
死亡标志的偏移是+158
七、封装数据,编写逻辑,实现功能
搞定了数据以后,我们就可以实现这个自动释放技能打怪的功能了,
首先我们需要通过二叉树读取到周围对象的信息
void objTree(DWORD node)//遍历周围对象二叉树
{
try
{
DWORD left = (DWORD)(node + 0);
DWORD right = (DWORD)(node + 8);
DWORD ObjID = (DWORD)(node + 0xC);
DWORD dObj = (DWORD)(node + 0x10);
objarr[i].setID(ObjID);
objarr[i].setDeath(*(BYTE*)(dObj + 0x158));
objarr[i].setType(*(DWORD*)(dObj + 0x248));
objarr[i].setCoord(*(DWORD*)(dObj + 0x228), *(DWORD*)(dObj + 0x22C));
char* name = (char*)(*(DWORD*)(dObj + 0x2C));
objarr[i].setName(name);
char* isdeath = "";
if (objarr[i].getDeath())
isdeath = "死亡";
else
isdeath = "活着";
DbgPrintf_Mine("%X ,%s ,%X ,%s ,%d ,%d ,%s", dObj, objarr[i].getName(), objarr[i].getID(), objarr[i].getType(), objarr[i].getCoord().X, objarr[i].getCoord().Y, isdeath);
i++;
if (left != *(DWORD*)Base_End)
{
objTree(left);
}
if (right != *(DWORD*)Base_End)
{
objTree(right);
}
}
catch (...)
{
DbgPrintf_Mine("二叉树读取异常" );
}
}
然后我们要在这些对象中筛选出最近的、活着的、在技能射程范围内的怪物
Object getNearest()//取最近距离的,活着的怪物
{
getObj();
DWORD a = 1000;
DWORD b = 1000;
Object cNest = Object();
for (int j = 0; j < i; j++)
{
b = getdistance(objarr[j]);
if ((a > b) && (objarr[j].getDeath() == 0) && (objarr[j].getTypeid() == 0))
{
a = b;
cNest = objarr[j];
}
}
return cNest;
}
在发送封包的代码中我们加入射程判断,并对射程在15以内的怪物释放技能
void sendPacket() //发送封包
{
Object obj = getNearest();
if (getdistance(obj) < 15)
{
static char buf[0x500] = { 0 };
static char buf1[0x500] = { 0 };
memset(buf, 0, 0x500);
memset(buf1, 0, 0x500);
packet1* pkt1 = (packet1*)buf;
packet2* pkt2 = (packet2*)buf1;
//组包过程
pkt2->packHead = 0x6D;
pkt2->id = obj.getID();
if (pkt2->id == 0)
return;
pkt2->num = 2;
pkt1->base = 0x517788;
pkt1->a1 = 1;
pkt1->pack2 = pkt2;
pkt1->lenth = 6;
pkt1->a2 = 1;
pkt1->a3 = 0x400;
pkt1->skillId = 0x6A5A;
//内联汇编调用明文发包函数
DWORD call1 = call_mingwen;
DWORD b = (DWORD)&buf;
__asm
{
pushad
mov eax, b
push eax
push 0
mov eax, call1
call eax
popad
}
}
}
最后我们只要开一根线程去不断的调用技能函数即可
int ii = 0;
void myThread()
{
ii = 0;
while(ii == 0)
{
Sleep(100);
sendPacket();
}
}
void endThread()
{
ii = 1;
}
以上就是这个功能实现的全过程,非常的简单,除了数据分析的过程稍长一些,逻辑代码非常少
后面我们还可以加入一些远程传送,自动打怪的功能
八、游戏安全角度的对抗
以上的功能虽然很暴力,但是从游戏安全角度去做检测也是非常简单的
1、首先是穿墙瞬移的部分,我们可以通过CRC去进行校验,保证代码不被普通改写。
2、其次是快速施法,我们可以在封包中加入时间戳,或者在服务器对封包间隔做校验,这个处理起来其实是很容易的
3、如果后面用到了远程传送,我们也可以做传送距离的校验,一般来说,封包传送的最大距离是能够遍历到的最远距离,
而玩家能够实现的距离则是一个屏幕内能看到的距离。
其实所有的功能只要在服务器做好严格的校验,就很难出现类似的BUG,本地的检测在协议面前很难发挥作用。