【先声明,本人没有对该游戏服务器做任何数据篡改,也没有改动客户数据,仅仅是监控数据并模拟鼠标操作。本外挂仅当时自己用过一两天。】
最近在整理自己以前开的各种坑的代码,发现自己以前脑子发热做的一些稀奇古怪的东西。时间太久,感觉都忘记的差不多了。因此开个博客坑,写一下一些项目的开发心得。
大概是在研究生一年级的时候,2014年的时候,那时候的暑假,《幻想神域》端游的国服上线了,是搜狐畅游代理的,我当时是一个忠实的二次元+游戏玩家,所以义不容辞的去玩了一下。那时候,游戏里有个钓鱼玩法,可以钓到不少好东西,但是这个玩法实在是太枯燥啦。因此,当时心生邪念,我得做个工具辅助我通宵挂钓鱼。然而写完之后,我对这个游戏已经失去了兴趣,然后就被遗忘了。。

钓鱼玩法,其实主要是一个UI向的玩法,主要就是要通过点鼠标,让鱼的图标在下图的进度条的蓝色区域内。如下图所示,就是该游戏的钓鱼过程。

2293e4ddb40e44d39a19efdeecda46a9.png

当时想到的第一个方案是,根据截图,进行简单的图像匹配判断鱼所在位置。但是这样的方法我觉得不够牛逼,因此选择了逆向去分析代码。

寻找突破口

突破游戏的反外挂措施

一般来说,首先逆向第一件事情就是进行难度分析。一般可先尝试一下使用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,刚好就解压成功了。
下图是一些解包后的效果
a2f210d8925544baa5332cd8e9c0a45c.png

在文件里一番查找,就找到了钓鱼功能的UI定义文件。

dcf975e23bea4f19bb1df3943fe24ed2.png

在这里,我们可以看到UI定义的结构

蜂蜜浏览器_5324db3320ff43959b1e134d925af4ad.png

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