我的扫雷水平不高,所以干脆用debugger把雷都显示出来吧!
因为地雷的具体位置是随机的,所以扫雷程序里肯定会用随机函数安置地雷。这种随机函数不是某种自制的随机数生成函数,就是标准的C函数rand()。最为美妙的事情是,微软不仅公开了其产品的PDB文件,而且在PDB文件里提供了全部的函数名等符号信息。所以,当我们用IDA打开winmine.exe程序时,它会从微软下载PDB文件并且显示所有函数名称。
在IDA里可以看到,调用rand()函数的指令只有一处:
.text:01003940 ; __stdcall Rnd(x)
.text:01003940 _Rnd@4 proc near ; CODE XREF: StartGame()+53
.text:01003940 ; StartGame()+61
.text:01003940
.text:01003940 arg_0 = dword ptr 4
.text:01003940
.text:01003940 call ds:__imp__rand
.text:01003946 cdq
.text:01003947 idiv [esp+arg_0]
.text:0100394B mov eax, edx
.text:0100394D retn 4
.text:0100394D _Rnd@4 endp
IDA把这个函数显示为rnd() 函数,那就是说扫雷游戏的开发人员给它起的名字就是rnd。这个函数非常简单:
int Rnd(int limit)
{
return rand() % limit;
};
微软的PDB文件没有把参数命名为limit。为了便于讨论,本文给它起名为limit。可见,rnd()函数返回值是介于0~limit之间的整数。
Rand()函数的调用方函数也只有一个StartGame()函数。而且StartGame()函数应当就是安放地雷的函数:
.text:010036C7 push _xBoxMac
.text:010036CD call _Rnd@4 ; Rnd(x)
.text:010036D2 push _yBoxMac
.text:010036D8 mov esi, eax
.text:010036DA inc esi
.text:010036DB call _Rnd@4 ; Rnd(x)
.text:010036E0 inc eax
.text:010036E1 mov ecx, eax
.text:010036E3 shl ecx, 5 ; ECX=ECX*32
.text:010036E6 test _rgBlk[ecx+esi], 80h
.text:010036EE jnz short loc_10036C7
.text:010036F0 shl eax, 5 ; EAX=EAX*32
.text:010036F3 lea eax, _rgBlk[eax+esi]
.text:010036FA or byte ptr [eax], 80h
.text:010036FD dec _cBombStart
.text:01003703 jnz short loc_10036C7
因为扫雷游戏允许用户设置棋盘大小,所以棋盘的 X(xBoxMac)和 Y(yBoxMac)都是全局变量。Rnd()函数根据这两个参数生成随机坐标,而后0x10036FA处的OR指令设置地雷。如果这个坐标在以前已经设置过地雷了,那么0x010036E6的TEST和JNZ指令将再次生成一次坐标。
变量cBombStart不仅是设置地雷总数的全局变量,还是循环控制变量。
SHL/左移指令意味着棋盘宽度是32。
全局数组rgBlk的容量可通过数据段里rgBlk标签的地址与下一个数据的地址推算出来。数组容量应当是这两个地址之间的差值,即0x360(864)。
.data:01005340 _rgBlk db 360h dup(?) ; DATA XREF: MainWndProc(x,x,x,x)+574
.data:01005340 ; DisplayBlk(x,x)+23
.data:010056A0 _Preferences dd ? ; DATA XREF: FixMenus()+2
...
数组的元素数量为:864(总容量)/32=27。
那么,rgBlk是否就是27×32的数组呢?当我们把棋盘设置为100×100的矩阵时,它会自动回滚为24×30的棋盘。所以棋盘盘面的最大值就是这个值,而且无论棋盘有多大,程序都把棋盘数据存储在这个数组里。
接下来,我们使用OllyDbg进行观察。在OllyDbg中运行扫雷游戏,然后在内存窗口里观察rgBlk数组(地址为0x1005340)。[1]
与这个数组有关的内存数据如下:
Address Hex dump
01005340 10 10 10 10|10 10 10 10|10 10 10 0F|0F 0F 0F 0F|
01005350 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005360 10 0F 0F 0F|0F 0F 0F 0F|0F 0F 10 0F|0F 0F 0F 0F|
01005370 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005380 10 0F 0F 0F|0F 0F 0F 0F|0F 0F 10 0F|0F 0F 0F 0F|
01005390 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
010053A0 10 0F 0F 0F|0F 0F 0F 0F|8F 0F 10 0F|0F 0F 0F 0F|
010053B0 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
010053C0 10 0F 0F 0F|0F 0F 0F 0F|0F 0F 10 0F|0F 0F 0F 0F|
010053D0 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
010053E0 10 0F 0F 0F|0F 0F 0F 0F|0F 0F 10 0F|0F 0F 0F 0F|
010053F0 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005400 10 0F 0F 8F|0F 0F 8F 0F|0F 0F 10 0F|0F 0F 0F 0F|
01005410 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005420 10 8F 0F 0F|8F 0F 0F 0F|0F 0F 10 0F|0F 0F 0F 0F|
01005430 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005440 10 8F 0F 0F|0F 0F 8F 0F|0F 8F 10 0F|0F 0F 0F 0F|
01005450 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005460 10 0F 0F 0F|0F 8F 0F 0F|0F 8F 10 0F|0F 0F 0F 0F|
01005470 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
01005480 10 10 10 10|10 10 10 10|10 10 10 0F|0F 0F 0F 0F|
01005490 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
010054A0 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
010054B0 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
010054C0 0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|0F 0F 0F 0F|
与其他的16进制编辑程序相似,OllyDbg也采取了每行16 字节的显示风格。因此,一个32字节的数组对应着OllyDbg窗口里的两行数据。
启动程序的时候,我们把游戏设置为了“入门级”难度,所以棋盘大小是9×9。现在我们可以在每行0×10个字节的数据里观测到这种正方形结构。
接下来在OllyDbg单击“Run”以运行扫雷程序,然后随意点击、直到触碰地雷为止。此时即可看到棋盘中的全部地雷了,如图76.1所示。
图76.1 地雷
在比较内存数据之后,我们可得出下列结论:
0x10代表边界。
0x0F代表空白地段。
0x8F代表雷区。
现在我们可对内存数据进行标注了。然后我们再用方括号标注地雷:
border:
01005340 10 10 10 10 10 10 10 10 10 10 10 0F 0F 0F 0F 0F
01005350 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #1:
01005360 10 0F 0F 0F 0F 0F 0F 0F 0F 0F 10 0F 0F 0F 0F 0F
01005370 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #2:
01005380 10 0F 0F 0F 0F 0F 0F 0F 0F 0F 10 0F 0F 0F 0F 0F
01005390 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #3:
010053A0 10 0F 0F 0F 0F 0F 0F 0F[8F]0F 10 0F 0F 0F 0F 0F
010053B0 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #4:
010053C0 10 0F 0F 0F 0F 0F 0F 0F 0F 0F 10 0F 0F 0F 0F 0F
010053D0 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #5:
010053E0 10 0F 0F 0F 0F 0F 0F 0F 0F 0F 10 0F 0F 0F 0F 0F
010053F0 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #6:
01005400 10 0F 0F[8F]0F 0F[8F]0F 0F 0F 10 0F 0F 0F 0F 0F
01005410 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #7:
01005420 10[8F]0F 0F[8F]0F 0F 0F 0F 0F 10 0F 0F 0F 0F 0F
01005430 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #8:
01005440 10[8F]0F 0F 0F 0F[8F]0F 0F[8F]10 0F 0F 0F 0F 0F
01005450 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
line #9:
01005460 10 0F 0F 0F 0F[8F]0F 0F 0F[8F]10 0F 0F 0F 0F 0F
01005470 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
border:
01005480 10 10 10 10 10 10 10 10 10 10 10 0F 0F 0F 0F 0F
01005490 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F 0F
把所有的边界数据(0x10)去除,即可得到地雷的确切位置:
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F 0F 0F 0F 0F 0F[8F]0F
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F[8F]0F 0F[8F]0F 0F 0F
[8F]0F 0F[8F]0F 0F 0F 0F 0F
[8F]0F 0F 0F 0F[8F]0F 0F[8F]
0F 0F 0F 0F[8F]0F 0F 0F[8F]
上述数据的行和列与棋盘的相应信息一一对应。
在推导出数据结构之后,在OllyDbg里修改数据的尝试更为有趣。如果把所有的0x8F都替换成0x0F,那么扫雷游戏就可以如图76.2所示这样玩。
图76.2 没有地雷的扫雷游戏
我们还可以把地雷都安置在第一行里,如图76.3所示。
图76.3 用debugger设置地雷
不过,在玩游戏之前,用OllyDbg之类的debugger查看地雷分布毕竟不够方便。我们不妨写一个专用程序,专门导出棋盘上的地雷分布情况:
// Windows XP MineSweeper cheater
// written by dennis(a)yurichev.com for http://beginners.re/ book
#include <windows.h>
#include <assert.h>
#include <stdio.h>
int main (int argc, char * argv[])
{
int i, j;
HANDLE h;
DWORD PID, address, rd;
BYTE board[27][32];
if (argc!=3)
{
printf ("Usage: %s <PID><address>\n", argv[0]);
return 0;
};
assert (argv[1]!=NULL);
assert (argv[2]!=NULL);
assert (sscanf (argv[1], "%d", &PID)==1);
assert (sscanf (argv[2], "%x", &address)==1);
h=OpenProcess (PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE, FALSE, PID);
if (h==NULL)
{
DWORD e=GetLastError();
printf ("OpenProcess error: %08X\n", e);
return 0;
};
if (ReadProcessMemory (h, (LPVOID)address, board, sizeof(board), &rd)!=TRUE)
{
printf ("ReadProcessMemory() failed\n");
return 0;
};
for (i=1; i<26; i++)
{
if (board[i][0]==0x10 && board[i][1]==0x10)
break; // end of board
for (j=1; j<31; j++)
{
if (board[i][j]==0x10)
break; // board border
if (board[i][j]==0x8F)
printf ("*");
else
printf (" ");
};
printf ("\n");
};
CloseHandle (h);
};
指定扫雷游戏的PID[2]之后,这个程序将会导出0x01005340处[3]的地雷分布图。
上述程序可以把自身绑定到PID指定的程序上,然后读取指定程序的数据。
为什么扫雷游戏里有边界字节0x10?既然程序不会显示这些数据,那么为何还要保留这些数据?去除这些边界字节会发生什么情况?
棋盘上的每个点可被赋予不同的值,以表示“被点击过”“被用户插上棋子”等信息。请找出各个值的具体涵义。
请修改本章的最后一个程序,让它以固定的格局分布地雷。
请修改本章的最后一个程序,使它在没有PDB文件、也不使用预定地址的情况下,自动导出地雷分布图。在扫雷程序运行期间,程序可以在数据段里自动地找到棋盘数据。有关信息可参见附录G.5.1。
[1] 本章以英文版Windows XP SP3中的扫雷游戏为例。如果调试的是其他版本的扫雷游戏,那么内存地址会与本例不同。
[2] PID即Program/process ID。Windows的任务管理器能够查看程序的PID。
[3] 不同版本的程序,其地址会发生变化。