现在的位置: 首页 > 综合 > 正文

扫雷游戏的秘密

2018年06月10日 ⁄ 综合 ⁄ 共 7863字 ⁄ 字号 评论关闭
作者: arikp
译者: 赖锋
前言

想知道扫雷游戏的秘密吗?

呵,我真的想知道,所以我决定去分析扫雷游戏.这篇文章是我的分析成果,不敢独享,所以在这儿分享给你们.


主要概念
  1. 利用 P/Invoke 调用 Win32 API.
  2. 直接读取另一进程的内存.

注意:这篇文章的第一部分涉及一些汇编代码,如果你感到有一些迷惑,这不会阻碍你理解扫雷游戏的秘密.你也可以略过这一部分,假如你想了解更多,欢迎你来信询问.

注意2:这个程序只是在WindowsXP测试通过,所以如果你的系统不是XP,这个程序可能不会运行......假如你可以在非XP系统上运行,请在这篇文章的回馈上写上你的操作系统类型,多谢.

注意2的更新:代码已经修改,现在可以在Win2000系统上运行了,感谢Ryan Schreiber找到Win2K的运行地址.


第一步:深入Winmine.exe

假如你不对汇编感兴趣,你可直接跳到这一步的总结部分......

为了更加清楚扫雷游戏背后,我用调试工具打开了这个文件(注:Windows/System32/WinMine.exe).我个人最喜欢的调试工具是Olly Debugger V1.08,这是一个非常简单易用的调试工具.我打开了这个文件并浏览了一下,我发现了在导出表部份(这部分列出了所有被这个程序调用的dll函数).有一个入口:

010011B0 8D52C377 DD msvcrt.rand

这意味着Winmine.exe调用Microsoft Visual C runtime 动态库的随机函数,所以我想这个会对有所帮助,我在Winmine.exe搜索调用rand()这个函数的地方,只在这个地方找到:

01003940 FF15 B0110001 CALL DWORD PTR DS:[<&msvcrt.rand>]

我在这个地方设置断点并运行Winmine.exe.我发现每次点击重新开始开始的按钮的时候,一个新的布雷地图就会产生.这个布雷地图的产生是按以下步骤产生的:

  1. 分布一块内存并且初始这块内存的所有字节(bytes)的值为0x0F.这意味着在布雷的地图中每个区域(cell)都没有地雷
  2. 跟着,为每个地雷区循环的次数为布雷图的地雷个数?
    1. 初始化雷区的x位置(1...width(雷区宽度))
    2. 初始化雷区的y位置(1...height(雷区高度))
  3. 设置布雷区中内存的适合位置的值为0x8F,这个值代表布雷图中该位置设为地雷.

这是原始的代码,我加上了一些标记,并在一些重要的地方加上了下划线.(注:我保存的是txt文档,原始标记的地方我忘记了,我只是凭记忆找了一些.呵)

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
         ; [0x1005334] = Width
010036A7 MOV DWORD PTR DS:[1005334],EAX
         ; [0x1005338] = Height
010036AC MOV DWORD PTR DS:[1005338],ECX
         ; Generate empty block of memory and clears it
010036B2 CALL winmine.01002ED5
010036B7 MOV EAX,DWORD PTR DS:[10056A4]
010036BC MOV DWORD PTR DS:[1005160],EDI
010036C2 MOV DWORD PTR DS:[1005330],EAX ; [0x1005330] = number of mines
                                        ; loop over the number of mines
010036C7 PUSH DWORD PTR DS:[1005334] ; push Max Width into the stack
      ; Mine_Width = randomize x position (0 .. max width-1)
010036CD CALL winmine.01003940
010036D2 PUSH DWORD PTR DS:[1005338] ; push Max Height into the stack
010036D8 MOV ESI,EAX
010036DA INC ESI ; Mine_Width = Mine_Width + 1
010036DB CALL winmine.01003940 ; Mine_Height = randomize y position
; (0 .. max height-1)
010036E0 INC EAX ; Mine_Height = Mine_Height +1

; calculate the address of the cell in the memory block (the map)
010036E1 MOV ECX,EAX
010036E3 SHL ECX,5 ;the calculation goes:
                   ;cell_memory_address = 0x1005340 + 32 * height + width
           ; [cell_memory_address] == is already mine?
010036E6 TEST BYTE PTR DS:[ECX+ESI+1005340],80
010036EE JNZ SHORT winmine.010036C7 ; if already mine start over this iteration
010036F0 SHL EAX,5 ; otherwise, set this cell as mine
010036F3 LEA EAX,DWORD PTR DS:[EAX+ESI+1005340]
010036FA OR BYTE PTR DS:[EAX],80
010036FD DEC DWORD PTR DS:[1005330]
01003703 JNZ SHORT winmine.010036C7 ; go to next iteration

正如你所看到的那样,我从代码中找到了四个重要的地方:

由地址[0x1005334]可以得到布雷图的宽度

由地址[0x1005338]可以得到布雷图的高度

由地址[0x1005330]可以得到布雷图的地雷个数

给出值x,y代表布雷图的坐标,在第x列,y行,由地址[0x1005340 + 32 * y + x]可以得到该布雷图坐标的值

分析到此,我们进入下一部分.....


第二步:设计解决方法

你可能想知道,我会取用哪种解决方法?在我发现这些对有用的信息后,我所需要做的是把这些数据从内存中读出,我决定写一个小程序读取这些数据并且实现出给你们.并且这个程序可以画出布雷图,并且在有地雷的地方放置一个地雷标记.

跟着,需要设计哪部份呢?我所需要的是把一个地址放入一个指针(想不到吧,指针在C#中也存在),跟着,读取指针值?呵呵,不全对,因为这些读取的内存数据是不存在我的程序中.如你所知,每一个进程有它的独立的地址空间以致它不能"意外"访问属于其它程序的内存区域,所以为了读取这些数据我需要找一种读取其它进程内存空间的方法,在这篇文章中,我要读取的是Winmine.exe的进程内存区域.

我决定写一个小类库,这个类库可以接收一个进程并且提供我读取这个进程内存地址的功能,我设计成类库主要是因为有时由于很多的情况下你也会用到它,为什么三番四次重新开发呢?把它设计成类库不更好吗?你可以在你的应用中更加方便自由地使用.例如你可以利用这个类写一个调试工具.据我所知,所有调试工具都有读取调试的程序的内存的功能.

我该怎么读取其它进程的地址空间?答案在一个名叫ReadProcessMemory的API.这个API允许你读取一个进程内存中的指定地址.在这之前你必须用一个特定的读取模式打开进程,并且在完成后为了避免资源泄漏必须关闭进程句柄.这些操作是由两个API,OpenProcess与CloseHandle帮助完成

为了在C#中使用这些API,你必须使用P/Invoke,意味着你使用这些API时必须进行使用声明.这些非常简单,你只需用.Net的提供的方法就可以做到,有时候这些又不是很容易.....我在MSDN找到这些API的声明.

HANDLE OpenProcess(
       DWORD dwDesiredAccess,// access flag
       BOOL bInheritHandle, // handle inheritance option
       DWORD dwProcessId // process identifier
       );

BOOL ReadProcessMemory(
       HANDLE hProcess, // handle to the process
       LPCVOID lpBaseAddress, // base of memory area
       LPVOID lpBuffer, // data buffer
       SIZE_T nSize, // number of bytes to read
       SIZE_T * lpNumberOfBytesRead // number of bytes read
       );

BOOL CloseHandle(
       HANDLE hObject // handle to object
       );

我们把这些声明转为C#的声明:

[DllImport("kernel32.dll")]
   public static extern IntPtr OpenProcess(
       UInt32 dwDesiredAccess,
       Int32 bInheritHandle,
       UInt32 dwProcessId
       );

[DllImport("kernel32.dll")]
   public static extern Int32 ReadProcessMemory(
       IntPtr hProcess,
       IntPtr lpBaseAddress,
       [In, Out] byte[] buffer,
       UInt32 size,
       out IntPtr lpNumberOfBytesRead
       );

[DllImport("kernel32.dll")]
   public static extern Int32 CloseHandle(
       IntPtr hObject
       );

假如你想知道更多在C++和C#之间的类型转换信息,我建议你在msdn.microsoft.com搜索以下主题"Marshaling Data with Platform Invoke".一般来说,假如你把这些代码放在程序中逻辑上是会编译通过的.但是有时一些转换是需要的.

我在声明这些函数后,所需要的是把它们封装在一个简单的类中使用它们.我把这些声明放在另一个名为ProcessMemoryReaderApi的类中,只是为了更好地组织这些类.主要的辅助类被名名为ProcessMemoryReader.这个类有一个被名名为ReadProcess的属性,这个属性来自类型 System.Diagnostic.Process,假如你想读取一个进程的内存,这个属性保存这个进程.

这个类有一个以只读模式打开进程的方法:

1
2
3
4
5
6
public void OpenProcess()
{
    m_hProcess = ProcessMemoryReaderApi.OpenProcess(
        ProcessMemoryReaderApi.PROCESS_VM_READ, 1,
        (uint)m_ReadProcess.Id );
}

常量PROCESS_VM_READ告诉系统以只读方式打开进程,m_ReadProcess.Id表明我想打开的进程.

在这个类最重要的是读取这个进程内存的方法:

1
2
3
4
5
6
7
8
9
10
public byte[] ReadProcessMemory(IntPtr MemoryAddress, uint bytesToRead,
                                  out int bytesReaded)
{
    byte[] buffer = new byte[bytesToRead];
    IntPtr ptrBytesReaded;
    ProcessMemoryReaderApi.ReadProcessMemory( m_hProcess, MemoryAddress,
                                  buffer,bytesToRead, out ptrBytesReaded );
    bytesReaded = ptrBytesReaded.ToInt32();
    return buffer;
}

这个函数声明需要一个传入指定数组大小的参数,一个字节类型的(byte)数组,和一个读取内存的API.就这么简单.

最后是一个关闭所有句柄的方法:

1
2
3
4
5
6
7
public void CloseHandle()
{
    int iRetValue;
    iRetValue = ProcessMemoryReaderApi.CloseHandle( m_hProcess );
    if ( iRetValue == 0 )
         throw new Exception( "CloseHandle failed" );
}

第三步:使用类

好了,到了最有趣的部分了.使用这个类读取Winmine.exe的内存和破解布雷图.使用这个类之前你必须初始化它:

1
2
ProcessMemoryReaderLib.ProcessMemoryReader pReader
         = new ProcessMemoryReaderLib.ProcessMemoryReader();

跟着,你需要设置你需要读取内存的进程,这儿是教你如何获得扫雷游戏进程,它只是装载一次,并设该进程为ReadProcess的属性.

1
2
3
System.Diagnostics.Process[] myProcesses
         = System.Diagnostics.Process.GetProcessesByName( "winmine" );
pReader.ReadProcess = myProcesses[ 0 ];

现在我们所需要做的是打开该进程,读这个进程内存,当完成后关闭进程.这是进行这一过程的代码.这儿我读取布雷图中的宽度的内存地址.

1
2
3
4
5
6
pReader.OpenProcess();
int iWidth;
byte[] memory;
memory = pReader.ReadProcessMemory( (IntPtr) 0x1005334, 1, out bytesReaded);
iWidth = memory[0];
pReader.CloseHandle();

就这么简单!

在总结部分,我会实现找出在布雷图所有地雷的代码.但是请记住,所有我所读取的地址都在这篇文章的第一部分找到...

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
56
57
58
59
60
61
// 第一步先找到扫雷程序的Instance
if ( myProcesses.Length == 0 )
{
    MessageBox.Show("No MineSweeper process found!");
    return;
}
pReader.ReadProcess = myProcesses[0];
 
// 以只读内存方式打开进程
pReader.OpenProcess();
int bytesReaded; int iWidth, iHeight, iMines;
int iIsMine;
int iCellAddress;
byte[] memory;
 
memory = pReader.ReadProcessMemory( (IntPtr) 0x1005334, 1, out bytesReaded);
iWidth = memory[0];
txtWidth.Text = iWidth.ToString();
 
memory = pReader.ReadProcessMemory( 
                 (IntPtr) 0x1005338, 1, out bytesReaded );
iHeight = memory[0];
txtHeight.Text = iHeight.ToString();
 
memory = pReader.ReadProcessMemory( (IntPtr) 0x1005330, 1, out bytesReaded );
iMines = memory[0];
txtMines.Text = iMines.ToString();
 
// 删除原保存的按钮数组
this.Controls.Clear();
this.Controls.AddRange( MainControls );
 
// 绘制布雷图所需要的按钮数组
ButtonArray = new System.Windows.Forms.Button[ iWidth, iHeight ];
 
int x,y;
for ( y = 0 ; y < iHeight ; y ++ )
    for ( x = 0 ; x < iWidth ; x ++ )
    {
         ButtonArray[x,y] = new System.Windows.Forms.Button();
         ButtonArray[x,y].Location =
                 new System.Drawing.Point(20 + x*16, 70 + y*16);
         ButtonArray[x,y].Name = "";
         ButtonArray[x,y].Size = new System.Drawing.Size(16,16);
         iCellAddress = (0x1005340) + (32 * (y+1)) + (x+1);
         memory =
              pReader.ReadProcessMemory((IntPtr)iCellAddress,1,out bytesReaded);
         iIsMine = memory[0];
         if (iIsMine == 0x8f)
                 ButtonArray[x,y].Image = ((System.Drawing.Bitmap)
                          (resources.GetObject("button1.Image")));
         this.Controls.Add(ButtonArray[x,y]);
    }
 
// 关闭进程名柄
pReader.CloseHandle();

这是代码的主要部分!希望你能从这里学到一些新知识!

抱歉!评论已关闭.