2024-02-10-CF1.0私服窗口化

0. 起始

元旦那会儿开了几个CF1.0的私服,爽了一阵之后发现一个问题,CF1.0那会儿的LithTech引擎过于老旧,不支持窗口化功能,而我用的一个仿MacOS UI的软件(MyDockFinder)会在切换分辨率的时候出现各种奇怪的Bug。

又因为私服被去掉了反外挂系统, 而群里又有位师傅折腾出了一个窗口化,但是并不愿意发出来分享,因此萌生了自己动手的想法。

首先我尝试找当年的CF窗口化工具,看有没有人发布易语言源码,或者直接对窗口化工具进行逆向,结果发现在12年前,做外挂的就已经给二进制攻防上强度了,动不动就是VMP保护,找了几个源码编译后对游戏也无效,遂放弃,开始自己动手调试。

1. 分辨率与全屏之谜

找到主程序crossfire.exe(中间还踩了个坑,进游戏需要带启动参数才行)x64dbg载入,随便单步按了几下,发现crossfireBase.dll有一个切换分辨率的Windows API(ChangeDisplaySettings)调用,调用完这个后屏幕分辨率会被强制设为800*600。

伪C代码:

1
2
3
4
5
6
7
8
9
10
11
   pfVar1 = ChangeDisplaySettingsA;
//... 不重要,略
iVar2 = ChangeDisplaySettingsA(&stack0xfffffea0, 2);
if (iVar2 == 0) {
iVar2 = pfVar1(&pdStack348, 4);
if (iVar2 == 0) {
goto code_r0x10001295;
}
}
sub_10001BAC();
return;

然后发现这个函数被cshell.dll调用,查了一下LithTech的资料,这个好像是引擎的核心逻辑,而且代码疑似是被动态加载的,每次在x64dbg里的地址都不一样,入口点在一个.data段里,而且还加了壳,IDA没法直接看到函数。。

研究了几天怒从心头起,我又不是来二开你引擎的,我是来实现窗口化的,直接给修改分辨率的汇编打了个patch,这里的汇编是先把改分辨率函数的地址扔给了寄存器,再调寄存器的函数,IDA的伪C代码识别成了把函数指针扔给临时变量后,用指针调用函数,也就是pfVar1(&pdStack348, 4);

对应汇编如下:

1
2
3
4
push 4
lea edx,dword ptr ss:[esp+10]
push edx
call esi

NOP填充掉push、call语句,结果如下:

解决掉分辨率切换之后,下一个问题出现了,当按ESC呼出菜单,再关掉菜单的时候,光标会被锁死在屏幕左上角400*300的地方。

无奈之下只能给ClipCursor下断点,发现引擎每帧都会尝试重置鼠标光标(FPS的正常特性),于是转头研究crossfireBase.dll的代码,发现修改分辨率是被d3d9.dll里面初始化d3d设备的函数触发的,研究了一下这个函数的入参:

根据入参定义,下断点看一下实参,结果发现CF1.0在初始化d3d设备的时候压根传的就是窗口化,整个游戏是一个假的独占全屏!

这里还踩了一个坑,由于我一开始设置了Win7兼容模式,导致代码走进apphelp.dll里,而且在兼容模式时会设置一次真全屏,多走了一些弯路。

难怪切屏的时候是先出现Alt+Tab的UI再切换分辨率,实质上是游戏窗口检测到失去焦点时,将分辨率手动重置到原始分辨率,在切换到游戏的时候再修改到800*600!

难怪整个游戏在高刷屏下画面撕裂,手感延迟,它压根就不是独占全屏的游戏。。

知道了这点并不有助于解决问题,而且新的问题又出现了,如果把窗口切出去,游戏本身会冻结,再切回来的时候才会恢复,这期间游戏似乎是不接受网络请求的,因此切久了会掉线。。

2. 窗口冻结

既然是切换窗口的时候发生的逻辑,那就找监听窗口事件的入口,结果非常惊喜的在IDA找到游戏里居然有输出调试日志:

这下好办了,你不是shutting down renderer吗,我直接把if块置空,从x64dbg里找到大致地址,定位到输出日志的汇编:

1
2
push crossfire.690554
call dword ptr ds:[<&OutputDebugStringA>]

然后往上往下阅读,得到一整个if块对应的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
; 对应开头的两次=1赋值
mov dword ptr ds:[6E9838],1
mov dword ptr ds:[6E9834],1
; 对应三次基于地址+偏移的函数调用
; (*(func**)(*dword_6E6E88 + 0xb8))(0);
push 0
mov eax,dword ptr ds:[6E6E88]
mov edx,dword ptr ds:[eax]
mov ecx,dword ptr ds:[6E6E88]
mov eax,dword ptr ds:[edx+B8]
call eax
; (*(func**)(*dword_6E6E88 + 0xb0))(1);
push 1
mov ecx,dword ptr ds:[6E6E88]
mov edx,dword ptr ds:[ecx]
mov ecx,dword ptr ds:[6E6E88]
mov eax,dword ptr ds:[edx+B0]
call eax
;(*(func**)(*dword_6E6E88 + 0x1c))(6, 0);
push 0
push 6
mov ecx,dword ptr ds:[6E6E88]
mov edx,dword ptr ds:[ecx]
mov ecx,dword ptr ds:[6E6E88]
mov eax,dword ptr ds:[edx+1C]
call eax
; 打日志
push crossfire.690554
call dword ptr ds:[<&OutputDebugStringA>]
; sub_5CDAD0(1, 0);
push 0
push 1
call <crossfire.begin of sub_5CDAD0>
; sub_622870();
add esp,8
mov ecx,dword ptr ds:[6E92E4]
call <crossfire.begin of sub_622870>
; if块的条件判断和内部的函数调用
cmp dword ptr ds:[6E983C],0
jne crossfire.62D9A5
call <crossfire.begin of sub_583B70>
mov dword ptr ss:[ebp-3FC],eax
mov ecx,dword ptr ss:[ebp-3FC]
mov dl,byte ptr ds:[ecx+12A]
mov byte ptr ss:[ebp-3FD],dl
movzx eax,byte ptr ss:[ebp-3FD]
test eax,eax
je crossfire.62D9A5
call <crossfire.begin of sub_583B70>
mov ecx,eax
call <crossfire.begin of sub_5834B0>
; 对应最后的函数指针调用
; (*(func**)(*dword_6E6E88 + 0xb8))(1);
push 1
mov ecx,dword ptr ds:[6E6E88]
mov edx,dword ptr ds:[ecx]
mov ecx,dword ptr ds:[6E6E88]
mov eax,dword ptr ds:[edx+B8]
call eax

暴力NOP填充这些汇编即可。

3. 窗口样式

解决了冻结和分辨率后,我得到了一个无边框窗口模式的CF,而很多现成的窗口化工具也是只设置了游戏的窗口样式,而游戏本体也应该是在指定窗口大小,或者创建窗口handle的逻辑附近去把窗口改成无边框的。

根据这个思路,给SetWindowLong下断点,兜兜转转又回到了crossfireBase.dll,找到如下汇编:

1
2
3
4
5
mov eax,dword ptr ds:[eax+2E981C]
push 94000000
push FFFFFFF0
push eax
call dword ptr ds:[<&SetWindowLongA>]

SetWindowLong的文档得知具体窗口的样式是用数字里的每个bit去控制的,这个94000000就是无边框窗口的意思,改成94CF0000就是正常窗口样式了。

4. 鼠标锁定

到这里我们得到了一个可以随意拖动,不切换分辨率,不切屏的CF,最后的问题是:按ESC呼出光标后,再按ESC,这时候鼠标会锁定在整个屏幕左上角400*300的地方。

在窗口化之前并没有什么问题,分辨率是800 * 600的时候,400 * 300一定是屏幕中间,但现在不是了,只有给SetCursorPos()下断点,然后按ESC再取消,看汇编,再去IDA找到对应函数,看伪C代码:

1
2
3
4
5
6
7
8
9
if ((dword_6DDB30 != 0) && (dword_6E9838 == 0)) {
qStack20 = (double)(&iStack108, pdStack12[0x11]);
pdStack24 = (dword*)0x63043c;
GetWindowRect();
pdStack24 = (dword*)((iStack96 - iStack104) / 2);
pdStack28 = (dword*)((iStack100 - iStack108) / 2);
piStack32 = (int32_t*)0x63045a;
SetCursorPos();
}

这里可以看出游戏用GetWindowRect()获取游戏窗口4个点,然后SetCursorPos()锁在屏幕左上角+游戏宽高/2的地方,(iStack96 - iStack104) 和(iStack100 - iStack108)就是宽和高;

对应汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mov edx,dword ptr ss:[ebp-8]
mov eax,dword ptr ds:[edx+44]
push eax
call dword ptr ds:[<&GetWindowRect>]
mov eax,dword ptr ss:[ebp-5C]
sub eax,dword ptr ss:[ebp-64]
cdq
sub eax,edx
sar eax,1
push eax
mov eax,dword ptr ss:[ebp-60]
sub eax,dword ptr ss:[ebp-68]
cdq
sub eax,edx
sar eax,1
push eax
call dword ptr ds:[<&SetPhysicalCursorPos>]

这里比较麻烦,观察x64dbg发现GetWindowRect调用结束后,EBP+偏移的这四个栈上地址分别是窗口的左上角、右下角的x y坐标位置,我们要做的是把鼠标锁在左上角xy坐标 + 游戏宽高,而由于汇编语句长度不足,游戏宽高我们不能计算,幸好可以写死400和300,才勉强塞下。

这里需要手写一点儿汇编,用x64dbg的16进制模式来替换,而不是逐语句替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
; 保持原语句不变,维持长度
mov eax,dword ptr ss:[ebp-5C]

; 把窗口左上角Y坐标覆盖到eax寄存器,而不是原先的用来做减法计算高度
mov eax,dword ptr ss:[ebp-64]
; 给Y坐标+300像素之后放到eax寄存器,16进制05 2C 01 00 00
add eax,12C
; 把eax的值入栈,准备当做SetPhysicalCursorPos的参数
push eax

; 保持原语句不变,维持长度
mov eax,dword ptr ss:[ebp-60]

; 把窗口左上角X坐标覆盖到eax寄存器,而不是原先的用来做减法
mov eax,dword ptr ss:[ebp-68]
; 给X坐标+400像素之后放到eax寄存器,16进制05 90 01 00 00
add eax,190
; 把eax的值入栈,准备当做SetPhysicalCursorPos的参数
push eax

; 触发调用
call dword ptr ds:[<&SetPhysicalCursorPos>]

改成这样之后,就可以把鼠标锁在游戏窗口左上角+400*300的位置,至此才达到和2.0一样可以随意拖拽窗口、ESC可以随意挪动鼠标、可以随意切换窗口焦点的效果。

这也是头一次做正经的x86逆向,也要感谢Stars师傅繁星天海的空间给的一些提示和引导,虽然最后这些1.0私服服务端泄露的泄露,倒闭的倒闭,外挂和恶意玩家泛滥,加上游戏bug众多,运营手段粗劣(有的直接发全道具,有的调物价后每周签到发游戏币),到重新整理本文(2024-11-1)的时候应该已经没有能玩的了,但这次逆向也是一次非常有趣的历程。

2024-02-10-CF1.0私服窗口化

http://blog.mothership.top/posts/75492f41.html

作者

Mother Ship

发布于

2024-02-10

更新于

2024-11-01

许可协议

评论