65.9K
CodeProject 正在变化。 阅读更多。
Home

纸牌游戏的高分:一种更高级的方法

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (27投票s)

2008年9月28日

Ms-PL

13分钟阅读

viewsIcon

56907

downloadIcon

581

本文介绍了如何为纸牌游戏制作一个显示高分表的插件。通过Codecave,纸牌游戏将自动执行此插件的DLL——无需用户干预!

目录

SolitaireMain.jpg

1.0.0 简介

很久以前,我一直在寻找一种方法来为Windows纸牌游戏添加高分功能。我甚至在这个网站上偶然发现了一篇文章。然而,没有一篇具有我想要的功能。我想要的是一种能够与实际的纸牌游戏集成的东西。这样就不需要通过另一个进程运行纸牌游戏,也不需要任何需要用户操作的东西。经过几分钟的搜索,我意识到这是徒劳的——所以我决定自己动手。在本文中,我将详细介绍制作这样一个方便的插件的过程。需要对C++ Win32 API编程有扎实的了解。其中一个部分会涉及少量的x86汇编,但它足够简单,不需要任何真正的汇编知识就能理解。

1.0.1 所需工具

本文将使用TSearchOllyDbg v1.10

2.0.0 开始

也许,整个项目中最重要的就是获取分数。毕竟,如果无法获取高分,制作高分表有什么用呢?获取分数有几种方法,主要是长路和短路。长(且痛苦)的方法是其他纸牌高分程序的作者所做的那样。你可以选择逐段检查数以百计(数以千计?)的行,在有趣的部分设置断点,希望能找到那个难以捉摸的地址(那个可怜的家伙就是这么做的)。或者,你可以走捷径,找到静态指针并跟踪它到内存中的分数(这需要一两分钟)。那么,走捷径听起来更好?

2.0.1 获取当前分数

这种方法就是TSearch发挥作用的地方。要开始,我们需要打开TSearch,点击“打开进程”按钮(左上角),然后找到我们的纸牌进程(最有可能的是SOL.EXE)。我们要做的就是在内存中搜索我们的分数。现在TSearch已经聚焦于纸牌,我们可以开始了。我们注意到纸牌的起始分数是零。对于内存搜索来说,零不是一个好数字。为了节省时间,我们需要将分数提高到其他值。回到纸牌窗口,我只是点击了一两张A(Ace),露出了几张牌,得到了30分。我回到TSearch,搜索这个值(作为四字节类型),得到了74个结果。74仍然很多,那么接下来怎么办呢?我们可以等几秒钟,因为纸牌每十秒会将总分数减少两分。一旦分数再次降低,你就可以搜索一个新值,点击“搜索下一个”按钮(在“搜索”旁边,一个带...的放大镜)。这将搜索这74个地址以找到我们的新值。我的分数恰好降到了24,所以我搜索了24。最终我得到一个地址。

SolitaireScore.jpg

注意:如果你按照这个方法操作,但仍然得到多个地址,那么就找出哪个地址随着纸牌分数的改变而改变。

我们看到分数存储在地址0xAA268(你的可能不同)。如果我们这样做的话

ReadProcessMemory(hProcess, (LPVOID)0xAA268, &score, sizeof(int), 0);

分数将是24。但是,让我们重新开始游戏,看看这是否对所有实例都有效。重新启动纸牌并再次执行该行——分数是……0?如果我们重复TSearch过程,我们会发现分数现在位于一个不同的内存地址。

2.0.2 纸牌有DMA?真的吗?纸牌?

嗯,结果发现纸牌使用了动态内存分配(DMA)。这样,分数的值每次都会位于不同的内存地址。那么,我们接下来该怎么办?我们需要找到一个称为静态指针的东西,我们将看到,它将分数存储在一个寄存器中。幸运的是,TSearch使这个过程变得容易。我们通过单击“AutoHack”菜单项,然后选择“启用调试器”来开始。完成此操作后,右键单击地址窗口中地址前的空白区域会弹出一个上下文菜单——从中选择“AutoHack”。

AutoHack.jpg

现在我们已经将调试器附加到了纸牌,并且可以通过单击“AutoHack”菜单项,然后选择“AutoHack窗口”来查看正在发生的事情。一旦我们的分数发生变化,我们应该会看到类似这样的内容

Address.jpg

再一次,我们很幸运,因为发生的事情真的很简单。唯一发生的是,EAX被移动到了[ESI+0x30]。我们可以假设EAX保存着我们的分数,并且它被移动到了[ESI+0x30],其中ESI+0x30是我们的地址(在我的例子中是0xAA268)。知道了这一点,我们就可以开始追踪静态指针。如果我们找到了ESI的值,我们就可以看到它指向的地址,加上0x30,然后读取该新地址的值。但是,如何找到ESI的地址呢?我们需要看看是什么指向它。所以,让我们从0xAA268(ESI+30)地址减去0x30来得到ESI。0xAA268 - 0x30 = 0xAA238。转换为十进制是696888。现在,我们知道了ESI,所以我们来搜索它。

SolitaireDMA.jpg

我得到了四个结果。在内存中,搜索那个不寻常的通常是最好的选择。你可以尝试所有四个,或者听我的建议,相信0x1007170是ESI所在的位置。好了,这就是我们的静态指针。现在,要随时获取分数的值,我们执行我之前提到的操作。我们读取0x01007170处的地址,加上0x30,然后读取该地址的值。基本上,就像这样

ReadProcessMemory(hProcess, (LPCVOID)(0x01007170), &val, sizeof(int), 0);
//val would hold 696888 (0xAA238)
val += 0x30;
//Now it holds (0xAA268)
ReadProcessMemory(hProcess, (LPVOID)val, &score, sizeof(int), 0);
//Read value at 0xAA268 (our score)

就这样。通过读取0x01007170处的静态指针并对其进行操作,我们可以随时获取纸牌分数。无需逐步调试,无需扫描内存——我们直接访问分数。

3.0.0 制作插件

现在我们已经获得了分数,我们可以着手制作高分插件了。对此有什么方法呢?我所做的是制作一个DLL(它将被纸牌自动加载),该DLL继承纸牌窗口以添加一个高分菜单。我还制作了一个对话框,在游戏开始前弹出,询问玩家的名字(类似MS Hearts风格,稍后详细介绍)。

3.0.1 继承纸牌窗口

我为此项目采取的方法是继承纸牌窗口。

HWND hWnd = FindWindow("Solitaire", "Solitaire");
HMENU hMenu = GetMenu(hWnd);
HMENU hNewMenu = CreateMenu();
AppendMenu(hMenu, MF_STRING | MF_POPUP, (UINT_PTR)hNewMenu, "High &scores");
AppendMenu(hNewMenu, MF_STRING, 1234, "&Show high scores");
AppendMenu(hNewMenu, MF_STRING, 1235, "&Add current score");
AppendMenu(hNewMenu, MF_STRING, 1236, "&Clear high scores");
DrawMenuBar(hWnd);
SolitaireOrigProc = SetWindowLongPtr(hWnd, GWL_WNDPROC,
    (LONG_PTR)SolitaireNewProc);

我找到了纸牌窗口,获取了菜单句柄,然后添加了我自己的项。SolitaireNewProc负责处理我希望处理的所有重要消息。在这种情况下,它是1234/1235/1236,对应于菜单选项的ID。

SolitaireNewProc

    case WM_CLOSE:
    case WM_QUIT:
        if(saveScore == TRUE)
            SaveHighScore(SAVE);
        break;
    case WM_COMMAND:
        switch(LOWORD(wParam))
        {
        case 1234:
            DialogBoxParam(g_hDLL, MAKEINTRESOURCE(IDD_DLGSCORE), 
                           NULL, HighScoreProc, NULL);
            break;
        case 1235:
            SaveHighScore(ADD);
            break;
        case 1236:
            DeleteFile("SOLScores.txt");
            break;
        }

对于WM_QUIT/WM_CLOSE,我将其设置为在退出时保存高分,以防有人意外关闭游戏(他们的老板在附近)。事件1234,对应于显示高分,会弹出负责显示分数的对话框。事件1235,允许用户动态地将分数添加到高分表中,调用一个执行此操作的函数。事件1236删除高分文件,从而清除高分表。

3.0.2 处理高分:一个类

处理存储/检索/显示高分是这个程序的大部分工作。因此,为了处理所有这些相关操作,我决定编写一个类。

功能视图

Class.jpg

关系视图

ClassRelationship.jpg

这个类通过读写高分文件(SOLScores.txt)来工作。下面是函数的简要概述,因为有些将在后面的代码片段中调用

void LoadHighScores(void);
//Loads the fileContents vector from the high score file

void AddHighScore(char* name, int score);
//Appends a high score to the file

void ParseParts(void);
//Parses the individual name/score/date components of fileContents

void SortScores(void);
//Selection sorts the score to display from highest to lowest

void MakeProperSpacing(void);
//Further performs operations on the name string
//so it is displayed correctly in the high score table

int getLowestHighScore(void);
 //Returns the lowest score of the top 10

string getFullTable(void); 
//Returns the top 10 in a string so they can be displayed
//by the edit control

高分文件的格式如下

name|x|x|score|x|x|date

|x|x| 用作分隔符,因此解析文件和查找重要位会更容易一些。我想概述一下即将出现的处理重要对话框的代码片段的函数。如果你对函数内部的代码感兴趣,那么所有内容的源代码都已附带——并带有注释。

3.0.3 - 欢迎对话框

DLGNAME.jpg

我们需要一种方法来获取玩家的名字。我认为一个询问玩家名字的欢迎对话框(类似MS Hearts的那个)是最好的方法。为了玩游戏,玩家必须输入他们的名字,或者使用“默认”名字。稍后,当我们让DLL自动加载时,这个对话框将在游戏加载之前弹出。消息处理相当简单,最重要的一部分是获取玩家的名字。这是通过GetDlgItemText(hDlg, IDC_NAME, playerName, 32);实现的。32个字符的最大限制是在WM_INITIDIALOG消息中通过SendDlgItemMessage(hDlg, IDC_NAME, EM_SETLIMITTEXT, 32, NULL);设置的。

3.0.4 高分对话框

DLGSCORE.jpg

最后,我们一直期待的主要对话框

case WM_INITDIALOG:
{
    FileHandler* DisplayScores = new FileHandler();
    DisplayScores->LoadHighScores();
    string Table = DisplayScores->getFullTable();
    SetDlgItemText(hDlg, IDC_SCORETABLE, Table.c_str());
    SendDlgItemMessage(hDlg, IDC_CHKSAVE, BM_SETCHECK, BST_CHECKED, NULL);
    delete DisplayScores;
}

加载后,它会获取高分文件中所有重要信息,并将其显示在编辑控件中。它还(默认情况下)选中“退出时保存分数”选项。FileHandler类完成了文件读取和解析的所有繁重工作,所以剩下要做的就是显示它。

4.0.0 让我们的DLL自动加载

这个插件几乎完成了。但是,如果每次启动游戏都要加载它,那它真的算不上插件,对吧?所以,需要做的是让它在纸牌启动时自动加载。但是,怎么做呢?我选择的方法是硬编码一个CodeCave。

4.0.1 Codecave方法

这就是OllyDbg发挥作用的地方。打开OllyDbg,从菜单中选择“文件”,然后选择“打开”(或按F3)。找到纸牌(在WinXP上是C:\WINDOWS\system32\SOL.EXE)并打开它。模块加载后,按Ctrl+A让OllyDbg清理分析中的一些内容。我们要做的就是修改这段汇编代码来加载我们的DLL。

4.0.2 寻找位置

在我们做任何事情之前,我们需要在程序中硬编码我们的DLL名称。为此,我们需要一个有很多零的地方,因为我们也将把CodeCave指令放在附近。如果你滚动到模块底部,大约在0x01006D2D到0x01006FFF之间,有一个空隙。这是CodeCave的理想位置。选择空隙中的一条指令,然后向下滚动15条或更多指令(高亮区域将是灰色的)。我选择了从01006D32到0x01006D3E开始的值。一旦这些被高亮显示,我们就可以开始硬编码我们的DLL名称了。按Ctrl+E,我们会看到一个要求输入ASCII/UNICODE/Hex的对话框。在ASCII字段中,我们将写入DLL名称,在我这里是DialogDLL.dll

OllyDLL.jpg

完成后,我们可以点击“确定”。如果你真的在跟着操作,你会注意到,而不是文本,OllyDbg会产生一些不寻常的指令。要修复这个问题,选择该区域,然后按Ctrl+A重新分析。完成此操作后,多条指令应该会合并为一行,其中包含DLL名称。

01006D32   . 44 69 61 6C 6F>ASCII "DialogDLL.dll",0

字符串现在位于0x01006D32,这个位置稍后将用于调用LoadLibraryA。现在字符串已经硬编码了,我们需要制作实际的CodeCave。CodeCave通常所做的就是劫持程序的流程,在返回正常之前执行额外的指令。我们可以使用汇编JMP助记符来做到这一点。但首先,我们需要一个跳转的起点。我们需要一段代码,在游戏加载时至少执行一次,最好在卡牌和其他东西显示之前。这段代码对我来说看起来很有趣

01001468  /$ 837C24 04 00   CMP DWORD PTR SS:[ESP+4],0
0100146D  |. 74 1B          JE SHORT SOL.0100148A
0100146F  |. 6A 00          PUSH 0                                   ; /timer = NULL
01001471  |. FF15 FC110001  CALL DWORD PTR DS:[<&msvcrt.time>]       ; \time
01001477  |. 25 FF7F0000    AND EAX,7FFF
0100147C  |. 50             PUSH EAX                                 ; /seed
0100147D  |. A3 44730001    MOV DWORD PTR DS:[1007344],EAX           ; |
01001482  |. FF15 00120001  CALL DWORD PTR DS:[<&msvcrt.srand>]      ; \srand
01001488  |. 59             POP ECX
01001489  |. 59             POP ECX
0100148A  |> A1 70710001    MOV EAX,DWORD PTR DS:[1007170]
0100148F  |. 6A 00          PUSH 0
01001491  |. FF7424 0C      PUSH DWORD PTR SS:[ESP+C]
01001495  |. 6A 08          PUSH 8

为什么?因为srand肯定会在程序开始时至少被调用一次(可能只是一次)。我们的JMP指令需要5个字节,所以看起来有些东西会被覆盖。让我们从选择以下行开始

0100147C  |. 50             PUSH EAX                                 ; /seed

在OllyDbg中。这将是CodeCave的起点。我建议高亮显示0x01001376前后大约5条指令,并将结果复制/粘贴到记事本中。按Ctrl+Space会弹出一个菜单,可以在该地址汇编指令。让我们在文本附近创建一个(暂时的)空区域。我选择了01006D42。

JMP.jpg

4.0.3 编写CodeCave

你会注意到,一旦你这样做,就会有一个NOP(无操作)指令在跳转之后。这是因为为了满足CodeCave的5字节要求,我们不得不覆盖另一个指令。现在,我们转到0x01006D42。高亮显示新的JMP行,然后按Enter,或者按Ctrl+G,然后输入0x01006D42。OllyDbg现在应该指向空白区域。我们的CodeCave将执行以下操作:

  • 保存寄存器(将它们压栈)
  • 将我们的字符串加载到EAX寄存器
  • EAX寄存器压栈作为LoadLibraryA的参数
  • 调用LoadLibraryA
  • 从栈中弹出
  • 执行被覆盖的指令
  • 跳回原始函数

将其转换为汇编代码

01006D42   > 60             PUSHAD
01006D43   . B8 336D0001    MOV EAX,SOL.01006D32         ;  ASCII "DialogDLL.dll"
01006D48   . 50             PUSH EAX                     ; /FileName => "DialogDLL.dll"
01006D49   . E8 2DB07F7B    CALL kernel32.LoadLibraryA   ; \LoadLibraryA
01006D4E   . 61             POPAD
01006D4F   . 50             PUSH EAX
01006D50   . A3 44730001    MOV DWORD PTR DS:[1007344],EAX
01006D55   .^E9 28A7FFFF    JMP SOL.01001482

寄存器已保存,我们执行我们想要的操作,然后恢复寄存器并执行被覆盖的指令。就好像什么都没发生过一样。要保存更改,请选择CodeCave中的两条或更多行,然后右键单击。当上下文菜单弹出要求保存所有内容时,选择“复制到可执行文件”,然后选择“所有修改”。

SaveChanges.jpg

应该会弹出另一个菜单,有四个选项,“复制全部”是我们想要的,因为除了底部高亮显示的部分之外,还有更多修改过的代码。OllyDbg现在应该会显示新程序的内存转储。要进行更改并保存新文件,请关闭此窗口,您将看到另一个消息框。

FileDump.jpg

选择“是”最终完成了保存更改的任务。现在,如果一切都正确完成,并且编译好的DLL与修改后的纸牌游戏在同一个目录中,那么游戏加载时应该会弹出欢迎对话框。就这样。通过继承和CodeCave,我们现在拥有了一个支持高分的纸牌客户端。无需做任何额外的工作,只需像往常一样运行纸牌。我个人建议更改开始菜单中的纸牌快捷方式,使其指向其他位置,而不是替换system32中的SOL.EXE。Windows使用文件保护,如果没有麻烦禁用它,它会将其简单地替换回原始副本。

5.0.0 其他

附带的源代码包含了编译与纸牌交互的DLL所需的一切。在可执行的ZIP文件中,我包含了一个补丁,它将一个普通的纸牌游戏打造成一个支持高分的版本。这只是为了方便分发,因为否则就必须发送整个SOL.EXE文件。此外,版权问题或其他类似问题可能会阻止我上传我修改后的纸牌游戏。

历史

  • 2008.09.28 - 文章提交。
© . All rights reserved.