【先声明,本人没有对该游戏服务器做任何数据篡改,也没有改动客户数据,仅仅是监控数据并模拟鼠标操作。本外挂仅当时自己用过一两天。】
最近在整理自己以前开的各种坑的代码,发现自己以前脑子发热做的一些稀奇古怪的东西。时间太久,感觉都忘记的差不多了。因此开个博客坑,写一下一些项目的开发心得。
大概是在研究生一年级的时候,2014年的时候,那时候的暑假,《幻想神域》端游的国服上线了,是搜狐畅游代理的,我当时是一个忠实的二次元+游戏玩家,所以义不容辞的去玩了一下。那时候,游戏里有个钓鱼玩法,可以钓到不少好东西,但是这个玩法实在是太枯燥啦。因此,当时心生邪念,我得做个工具辅助我通宵挂钓鱼。然而写完之后,我对这个游戏已经失去了兴趣,然后就被遗忘了。。
钓鱼玩法,其实主要是一个UI向的玩法,主要就是要通过点鼠标,让鱼的图标在下图的进度条的蓝色区域内。如下图所示,就是该游戏的钓鱼过程。
当时想到的第一个方案是,根据截图,进行简单的图像匹配判断鱼所在位置。但是这样的方法我觉得不够牛逼,因此选择了逆向去分析代码。
寻找突破口
突破游戏的反外挂措施
一般来说,首先逆向第一件事情就是进行难度分析。一般可先尝试一下使用OllyDebug进行动态调试,如果不行的话,一般就是被加了桩。这种情况下,我是没这个水平去壳了。但是,网上有一些现成的脱壳工具,对于常见的一些加壳工具,都有现成的脱壳工具。
刚好,我试了下,那个游戏没有被加壳,也没有进行反调试。
寻找突破口
一般,寻找突破口,我所知的几种菜鸡方法,主要有以下几种方式
- 直接搜索全局变量
- 寻找动态链接库的export函数,然后找出关键函数在主程序中的调用
- 使用Cheat Engine搜索内容动态但是地址不变量
- 使用Cheat Engine搜索内容固定但是地址变化的量
一般游戏都会选择静态Link,这样可以在某种程度上增加逆向的难度。刚好,这个游戏就是完全的静态Link,连VC的库都被静态链接了。实际去一点点看汇编,那是不可能了。
寻找突破口的另一个常见套路就是寻找全局静态变量,一般就是查找字符串。该游戏钓鱼失败或者成功的时候,都会显示特定文字,我想直接通过文字来查找全局静态的字符串。
这种方式也失败了,因为字符串是动态的。
动态的,那就比较麻烦,因为每次弹出字符串,虚拟地址都可能会发生变化(也有可能不会)。如果该界面每次都重新加载显示,虚拟地址就会发生变化。
这时候,有个神器就可以出来了,CheatEngine。这个工具经常可以用来搜索单机游戏的金币啥的,然后直接改内存。
当在有窗口弹出的时候,我用CheatEngine搜索到了好像是条普通的小鱼
,好像钩到了什么东西
等这些字符串。刚好,有些这个字符串的生命周期比较长,我得以用OlleyDebug监控哪个代码访问了它。
结果,刚好就找到了一个形如memcpy
,具体当时怎么发现这个是memcpy的,我已经忘了。
我找并且调试了好多处调用这个函数的函数,最终找到了一个调用这个函数的函数,它每次被调用时,游戏就会显示好像是条普通的小鱼
这种文字。
这样,第一个桩就找好了,通过Hook调用这个调用memcpy
的函数,我成功的监控到了每次钓鱼结束的信息。
知道钓鱼结束的信息并没什么卵用,重点还是在合适的时候点击鼠标。这个合适的时候就很难把握了。简单来说,要获得这个信息,只要能拿到进度条的百分比信息就行了,可是越简单的事情就越复杂。
寻找进度条的信息
为了获得进度条的信息,我费尽了各种心机,最终都失败了,以至于我当时差点放弃了。
首先,我通过CheatEngine的通过变量增加或者减少的功能来搜索变量。这个真的是个麻烦活,这个事情非常的不好弄,因为钓鱼需要按住鼠标,这要求游戏的窗口获得了焦点,并且鼠标是按住的。然而我在CheatEngine上操作,必须要切窗口到CheatEngine去。
因此,我先完成了Hook的框架,通过热键在没有焦点的情况下,发送鼠标信息给游戏。这样我的焦点可以一直在CheatEngine上。
最终,还是没能搜出这个进度条百分比数值,因为候选的值真的太多了,多轮增减搜索下来都有几十个。
另外的一种方法是,直接从DirectX的层面进行Hook,不过当时的我水平太次,找了半天没能找到文章教我怎么Hook COM组件。
换一个角度进行破解
这时候,我无意中发现了《幻想神域》采用的GUI是CEGUI,因为它偶尔写的报错日志文件里泄露了这个信息。以前在图形学作业的时候刚好使用过ORGE配合CEGUI。这货是用XML来定义界面信息。
然后,我就在想能不能破解文件信息。经过一些简单的努力,就完成了文件系统的破解。
《幻想神域》的文件系统每组文件有一个index文件,以及很多个pkg文件。
index文件是每个文件的信息头,每个文件头的信息结构如下:
struct pkg_fileinfo
{
uint32_t unkown_1;
uint32_t unkown_2;
uint32_t offset;
uint32_t unkown_3;
uint32_t stream_size; //the file compressed bytes
uint32_t crc32; //every each file is not close. it should be crc32
uint32_t unkown_5; //always 0x 00 00 00 00
uint64_t unkown_6; //always 0x 20 00 00 00 | 00 00 00 00 it should be file attributes;
uint64_t create_time; //this two time is unix time stamp . must be create time,modify time,
uint64_t modify_time;
uint64_t unknown_7; //adjacent file 's value is very close but the subtraction is not stream_size or file_size.
uint32_t file_size; //the file's original size
char filename[260];
char dir[264];
uint32_t idx; //which in the parted files
};
通过读取出这些文件,发现这些文件内容不是直接的文件内容,一般要么是加密了要么是压缩了。先试试压缩算法,考虑到这是游戏直接读取的数据,不会使用特别慢的压缩算法,因此我简单的尝试了zlib,刚好就解压成功了。
下图是一些解包后的效果
在文件里一番查找,就找到了钓鱼功能的UI定义文件。
在这里,我们可以看到UI定义的结构
Root/Fishing/BattleBar
总共有8个子节点,然后第一个Root/Fishing/BattleBar/Pointer
经过分析和猜测就是钓鱼的进度条上的当前位置的指针,其他分别是进度条不同区域的图。可以非常简单的推测,只要Pointer在,Break2Success, SuccessArea, Success2Miss区域,钓鱼就可以继续,否则鱼会跑了或者鱼线会断了。
接下来,就要讲到黑科技了,为了拿到Pointer以及其他几个区域的界面位置,我破解了这个XML的UI结构在运行时的UI树的结构。
怎么找到这里的树结构的内存结构呢,这里我花费了大量的时间,至少有好几天的时间。
先用CheatEngine搜索字符串Root/Fishing/BattleBar/Pointer
,得到好几个地址都有。
然后在OLLYDBG中监听这些地址的访问的代码,然后分析相关的上下文代码,通过多次调试分析,得到某个字符串,刚好就是UI树结构的成员变量。
这里分析还结合了CEGUI开源的源码,不过因为该游戏也不知道具体使用的是哪个版本的CEGUI,不能直接把他们的头文件拿来用。经过仔细对比分析,我得到了下面这个头文件
// my_cegui_window.h
struct cegui_window
{
unsigned char not_concern_0[108];
union{
//当name_str第一个wchar_t为0x0000时 表示这个字符串过长,以外部指针的方式链接到字符串。
wchar_t name_str[40];
struct {
unsigned short not_used_prefix[32];
//外部链接字符串
wchar_t *name__str_ptr;
unsigned short not_used[6];
};
} name;
struct{
void* not_concern_0;
cegui_window ** list_ptr;
void* not_concern_1[2];
} childs; //子窗口
struct{
void* not_concern_0;
cegui_window * list_ptr;
void* not_concern_1[2];
} rendered_childs;//需要被渲染的子窗口
cegui_window * parent;//父窗口
unsigned char not_conern_1[456];
float d_area[8];
float d_pixelSize[2];//very concern; [0]:x pixels, [1]: y pixels;
float d_minSize[4];// not important
float d_maxSize[4];//not important
HorizontalAlignment d_horzAlign;
VerticalAlignment d_vertAlign;
float d_rotation[3];
//! outer area rect in screen pixels
mutable Rect d_outerUnclippedRect;
//! inner area rect in screen pixels
mutable Rect d_innerUnclippedRect;
//! outer area clipping rect in screen pixels
mutable Rect d_outerRectClipper;
//! inner area clipping rect in screen pixels
mutable Rect d_innerRectClipper;
//! area rect used for hit-testing agains this window
mutable Rect d_hitTestRect;
mutable bool d_outerUnclippedRectValid;
mutable bool d_innerUnclippedRectValid;
mutable bool d_outerRectClipperValid;
mutable bool d_innerRectClipperValid;
mutable bool d_hitTestRectValid;
};
编写核心逻辑
创建注入工程
一个注入型辅助,正常需要两个模块,这两个分别是启动程序和Injected DLL。
我首先创建了一个启动程序,这个启动程序非常简单,主要是注入我写的DLL,以及跟这个DLL通信,当我结束启动程序的时候,DLL释放自己的监听行为和Hook逻辑。
由于该游戏也没有进行任何的反Hook措施,因此这个逻辑非常简单,查找游戏进程,并调用Win32 API SetWindowsHookEx
即可。
Injected DLL执行Hook行为
Injected DLL加载后,首先要做的事情就是Hook 需要的突破的函数。需要Hook的函数有2个,一个负责监听钓鱼结束的信息,一个负责监听窗口的信息,并实时获取窗口的钓鱼的指针图标所在的进度条。
由于游戏整个都是static编译的,没法进行IDT Hook,只能通过通过Inline Hook。 Inline Hook非常需要注意的就是栈平衡问题,在这里我搞了好久才理解。
Hook的核心代码如下
// inline hook, 修改CALL指令,进行远跳
call_ins hooked_window_mem_call;
hooked_window_mem_call.op = 0xE8;
hooked_window_mem_call.offset = (int)draw_window_mem_cpy - (int)window_mem_cpy_call_pos - 5;
VirtualProtectEx(GetCurrentProcess(), (LPVOID)window_mem_cpy_call_pos, 256, PAGE_EXECUTE_READWRITE, &oldProtect);
WriteProcessMemory(GetCurrentProcess(), window_mem_cpy_call_pos, &hooked_window_mem_call, sizeof(call_ins), NULL);
VirtualProtectEx(GetCurrentProcess(), (LPVOID)window_mem_cpy_call_pos, 256, oldProtect, NULL);
void *text_mem_cpy_call_pos = (void*)TXT_HOOK_POS;
call_ins hooked_text_mem_call;
hooked_text_mem_call.op = 0xE8;
hooked_text_mem_call.offset = (int)draw_test_mem_cpy - (int)text_mem_cpy_call_pos - 5;
VirtualProtectEx(GetCurrentProcess(), (LPVOID)text_mem_cpy_call_pos, 256, PAGE_EXECUTE_READWRITE, &oldProtect);
WriteProcessMemory(GetCurrentProcess(), text_mem_cpy_call_pos, &hooked_text_mem_call, sizeof(call_ins), NULL);
VirtualProtectEx(GetCurrentProcess(), (LPVOID)text_mem_cpy_call_pos, 256, oldProtect, NULL);
// 执行Hook
__declspec(naked) void __stdcall draw_window_mem_cpy(void *dst, const void* src, size_t size)
{
// 调用函数基本操作,旧的基址入栈顶,esp作为新的基地址
__asm
{
push ebp;
mov ebp, esp;
}
// 调用注入的函数
window_filter_process(dst, src, size);
__asm
{
mov ebx, MEM_CPY_POS;
// 压栈传旧的三个参数,这里比较难懂,它是被hook的函数的三个传入的参数,栈现在的结构大概是
// top: old ebp
// var1: dst pointer
// var2: src pointer
// var3: size
// 由于32位,因此这里的size_t是4字节的
push[ebp + 0x10];
push[ebp + 0x0C];
push[ebp + 0x8];
// 调用被Hook的函数
call ebx;
// 上面压栈了3个参数,结束后要按照约定清理堆栈,因为原来被hook的函数是stdcall的,由我调用的人来清理
add esp, 0x0C;
// 恢复ebp为旧的基址
pop ebp;
retn;
}
}
__declspec(naked) void __stdcall draw_test_mem_cpy(void *dst, const void* src, size_t size)
{
__asm
{
push ebp;
mov ebp, esp;
}
draw_text_filter_process(dst, src, size);
__asm
{
push eax;
mov eax, MEM_CPY_POS;
push[ebp + 0x10];
push[ebp + 0x0C];
push[ebp + 0x8];
call eax;
pop eax;
add esp, 0x0C;
pop ebp;
retn;
}
}
窗口对象的Hook时的结构体解构
draw_window_mem_cpy函数进行Hook的时候,我们会进行窗口信息的初始化,将整个UI的结构信息解构出来。主要核心逻辑如下
set_window在Hook的函数被调用时设置。init_window在监听线程中循环调用。
bool fish_window::init_window()
{
if (m_pointer_window != nullptr){
// 根据鱼图标的Window对象指针,获得父节点的指针
m_fish_battle_window = m_pointer_window->parent;
// 获得所有其他7个子节点的指针
m_progress_bar_windows[0] = m_fish_battle_window->childs.list_ptr[1];
m_progress_bar_windows[1] = m_fish_battle_window->childs.list_ptr[3];
m_progress_bar_windows[2] = m_fish_battle_window->childs.list_ptr[4];
m_progress_bar_windows[3] = m_fish_battle_window->childs.list_ptr[5];
m_progress_bar_windows[4] = m_fish_battle_window->childs.list_ptr[6];
m_progress_bar_windows[5] = m_fish_battle_window->childs.list_ptr[7];
m_progress_bar_windows[6] = m_fish_battle_window->childs.list_ptr[2];
// 执行一些逻辑计算供监听线程其他的逻辑使用
for (int i = 0; i < 7; ++i)
m_each_progress_bar_width[i] = m_progress_bar_windows[i]->d_pixelSize;
m_pointer_rect = &m_pointer_window->d_outerUnclippedRect;
m_min_pointer_value = 0.5*(m_progress_bar_windows[0]->d_outerUnclippedRect.d_right +
m_progress_bar_windows[0]->d_outerUnclippedRect.d_left);
m_increased_progress_bar_width[0] = *m_each_progress_bar_width[0];
for (int i = 1; i < 7; ++i)
{
m_increased_progress_bar_width[i] = m_increased_progress_bar_width[i - 1]
+ *m_each_progress_bar_width[i];
}
}
return true;
}
bool fish_window::set_window(void *pointer_window_name_ptr)
{
m_pointer_window = (cegui_window*)((unsigned char*)pointer_window_name_ptr-108);
return true;
}
Injected DLL开启监听线程
监听线程非常简单,主要就是循环的根据Hook的函数提供的信息进行相应的操作。大致逻辑如下
// 处理一轮内钓鱼的逻辑,在合适的时候,点击或者释放鼠标
void fish_window_process::process_fish_callback(const float **each_progress_width
, const float *increased_progress_width,
float progress) {
fwprintf(mem_set_log_file, L"leftborder: %.2f\t", increased_progress_width[2] + *each_progress_width[3] * 0.25);
fwprintf(mem_set_log_file, L"progress: %.2f\t", progress);
fwprintf(mem_set_log_file, L"rightborder: %.2f\n", increased_progress_width[3] - *each_progress_width[3] * 0.25);
fwprintf(mem_set_log_file, L"breakarea: %.2f\t", *each_progress_width[1]);
fwprintf(mem_set_log_file, L"break2success: %.2f\t", *each_progress_width[2]);
fwprintf(mem_set_log_file, L"success: %.2f\t", *each_progress_width[3]);
fwprintf(mem_set_log_file, L"success2miss: %.2f\t", *each_progress_width[4]);
INPUT ip;
if (GetKeyState(VK_LBUTTON) & 0x80) {
if (progress < increased_progress_width[2] + *each_progress_width[3] * 0.25) {
UpMouse();
}
} else {
if (progress > increased_progress_width[3] - *each_progress_width[3] * 0.25) {
DownMouse();
}
}
}
// 处理每一轮钓鱼的逻辑
void fish_mgr::fish_thread_proc()
{
POINT cursor_pos;
INPUT ip;
while (m_fish_thread_started)
{
//m_lock_thread_started.lock();
//if (!m_fish_thread_started)
//{
//m_lock_thread_started.unlock();
// break;
//}
//m_lock_thread_started.unlock();
switch (m_state)
{
case UNDEFINED:
Sleep(100);
break;
case LOADED:
Sleep(100);
break;
case INITED:
Sleep(1000);
// 有时候睡眠太久,可能没监听到结束的消息,没能释放鼠标
UpMouse();
set_state(fish_mgr::FISHING);
Sleep(1000);
init_fish();
break;
case FISHING:
Sleep(30);
m_fish_window.update_window();
break;
case COMPLETED:
Sleep(2000);
UpMouse();
SendInput(1, &ip, sizeof(INPUT));
break;
}
}
m_state = UNDEFINED;
}
总结
当初开发这个辅助,花了我正正2周的时间,刚开始还是玩游戏+写代码,中后期已经变成了不玩游戏,专心思考怎么逆向以及写代码(可能代码比游戏好玩多了)。当我成功的无失败钓鱼钓了一晚上把我背包钓满以后,我就觉得这个游戏索然无味了。
我当时玩幻想神域玩的天黑地暗,手动钓鱼钓到我手酸,然后在极度冲动的情况下才开了这个坑。现在,逆向方向已经成了一个非常冷门的方向,我自己也没有靠这个吃饭(我大概率也没那个水平吃这个饭)。现在,做逆向破解工作,基本上除了那种违法得利的灰色产业之外,其他人应该是靠着真正热爱在坚持着这条路了吧。
这里,我要感谢很多看雪论坛和很多逆向书籍的作者,让我有了一个美好的装逼的回忆。我当时主要参考了以下书籍:
- 《Windows程序设计》王艳平,我是在我这本大二买来学MFC的书上了解到Windows还能写进程内存,搜索进程内存以及Hook行为的。
- 《Intel汇编程序设计》汇编教材来着,很有用
- 《加密与解密》 看雪论坛大佬编著
- 《C++反汇编与逆向分析技术揭秘》 对我实际破解和阅读反汇编最有收获的书籍
- 《Windows内核原理与实现》
- 《天书夜读——从汇编语言到Windows内核编程》
- 《寒江独钓——Windows内核安全编程》
- 《Windows内核安全与驱动开发》 这是上面那两本书的前身,同一个作者
常用的工具有:
IDA Pro, MASM, Ollydebug
lumu聚聚牛逼