FreeCell & Hearts,幕后揭秘






4.93/5 (121投票s)
本文介绍了 Freecell 和 Hearts 游戏的内部机制,其中使用了读取和写入另一个进程内存的库。
引言
是的,又来了。对于喜欢我第一篇文章《扫雷,幕后揭秘》的读者,我在这里呈现它的自然续集。这个想法源于我的一位朋友读了之前的文章后和我开玩笑说,“现在做个 Hearts 吧……”,所以我做了,这篇文章就是最终结果。在此,我想感谢一些帮助过我的朋友,Itay Langer、Michael Kuperstein 和 Yoav Sion(按字母顺序排列)。
两周前,当我写第一篇文章时,我解释了如何使用 C# 中的 API 函数(通过 P/Invoke)来读取另一个进程的内存。我还写了一个示例,说明如何使用这个类库来读取扫雷游戏的雷区地图。在那篇文章中,我曾声称(现在仍然坚持)文章的目标不是如何破解扫雷。文章的目标是为读者提供一个简单的 C# 类库,让他们能够读取另一个进程的内存。令我惊讶的是,许多读过这篇文章的人对我的示例非常感兴趣。因此,我不能声称这篇文章的目标是学习如何使用一些新的 API 函数来实现 C# 不支持的功能。这篇文章的目标是看看微软的这两款纸牌游戏:Freecell 和 Hearts 的幕后发生了什么。尽管如此,我的良心不允许我发布一篇没有新增编程特性的文章,所以我增强了这个库,现在它也支持写入另一个进程的内存。我在其中一个示例中甚至使用到了这个函数。
主要目标
- FreeCell 幕后花絮以及开发一个利用这些知识的酷炫应用程序。
- Hearts 幕后花絮以及开发一个利用这些知识的非常酷炫的应用程序。
- 使用
WriteProcessMemory
API 函数写入另一个进程的内存。
注意:前两部分使用了调试器来调查游戏,我个人最喜欢的调试器是 Olly Debugger v1.08,但任何调试器都可以。
第一部分:FreeCell,它是如何工作的?
我猜你想知道 FreeCell 有什么好破解的?你已经看到了所有的牌!问题在于理解它做了什么以及如何得到这些牌。另外,假设你能读取牌,你就可以编写一个能自己玩 Freecell 的程序……但你必须有一种方法来读取 Freecell 的内存。
第一步,和往常一样,是在调试器中打开文件。在这种情况下,我们需要 freecell.exe 文件,它位于 windows\system32 文件夹中。打开文件后,我们需要找到一个有趣的起始点。当你查看文件开头时,你会看到一个从 dll 导入的函数列表,如下所示:
01001188 DD USER32.GetDlgItemInt
0100118C DD USER32.SetWindowTextW
01001190 DD USER32.wsprintfW
01001194 DD USER32.GetSysColor
01001198 DD USER32.GetWindowDC
0100119C DD USER32.IsIconic
010011A0 DD 00000000
010011A4 DD msvcrt._except_handler3
010011A8 DD msvcrt._controlfp
010011AC DD msvcrt.__set_app_type
010011B0 DD msvcrt.__p__fmode
010011B4 DD msvcrt.__p__commode
010011B8 DD OFFSET msvcrt._adjust_fdiv
010011BC DD msvcrt.__setusermatherr
010011C0 DD msvcrt._initterm
010011C4 DD msvcrt.__getmainargs
010011C8 DD OFFSET msvcrt._acmdln
010011CC DD msvcrt.exit
010011D0 DD msvcrt._cexit
010011D4 DD msvcrt._XcptFilter
010011D8 DD msvcrt._exit
010011DC DD msvcrt._c_exit
010011E0 DD msvcrt.isdigit
010011E4 DD msvcrt.time
010011E8 DD msvcrt.srand
010011EC DD msvcrt.rand
这只是列表的最后一部分,但如果你仔细看,你会发现最后列出的导入函数是来自 msvcrt dll 的 rand 函数,当然,这是微软 Visual C++ 运行时 dll 的随机化函数。所以我决定查找这个函数的使用位置。
我找到了两个使用这个函数的地方。然后我在两个地方都设置了断点并运行了程序。开始新游戏后,程序在第一个断点处停了一次,在第二个断点处停了 52 次。这时就需要动脑筋了,我的分析是,第一个断点用于随机化游戏编号,第二个断点用于随机化每张牌的位置(或者每个位置的牌)(记住,你有 52 张牌……)。
于是,我做了一个小测试来证明我的理论。我运行程序直到第一个断点,然后单步执行 rand 函数,并查看 eax 寄存器的值,该寄存器的值为 0x00006A26,然后我继续运行程序,窗口标题栏上显示的游戏编号是 25254…… bingo,0x6A26 = 25254。现在我知道我是对的,我需要找到 Freecell 存储这个值和牌值的位置,这样我以后就可以用我的程序来读取它们了。我开始新游戏,现在我调查第二个断点。在断点前几条指令,我发现了一段非常有趣的代码。我找到的代码将一个值存储在内存中的特定位置,然后格式化字符串“FreeCell Game #%d”以及这个数字,所以这就是它存储游戏编号的地方……代码如下所示:
010031D0 MOV DWORD PTR DS:[100834C],EAX
010031D5 PUSH ESI
010031D6 PUSH EDI
010031D7 PUSH 80 ; /Count = 80 (128.)
010031DC MOV ESI,freecell.01007880 ; |
010031E1 PUSH ESI ; |Buffer => freecell.01007880
010031E2 PUSH 12F ; |RsrcID = STRING "FreeCell Game #%d"
010031E7 PUSH DWORD PTR DS:[1007860] ; |hInst = 01000000
010031ED CALL DWORD PTR DS:[<;&USER32.LoadStringW>>; \LoadStringW
010031F3 PUSH DWORD PTR DS:[100834C]
010031F9 PUSH ESI ; |Format => ""
010031FA MOV ESI,freecell.01007820 ; |UNICODE "Cards Left: %u"
010031FF PUSH ESI ; |s => freecell.01007820
01003200 CALL DWORD PTR DS:[<;&USER32.wsprintfW>] ; \wsprintfW
01003206 ADD ESP,0C
01003209 PUSH ESI ; /Text
0100320A PUSH DWORD PTR SS:[EBP+78] ; |hWnd
0100320D CALL DWORD PTR DS:[<;&USER32.SetWindowTex>; \SetWindowTextW
正如你所见,第一行是将 eax 寄存器的值(这是第一个断点的随机化数字)存储在内存地址 0x0100834C。顺便说一句,从代码本身无法得知 eax 的值是多少,但在运行时使用调试器时,你可以看到这与第一个 rand 函数得到的值是相同的。
回到第二个断点。正如我前面所说,第二个断点被触发了 52 次,这正好是我们拥有的牌的数量。现在我感兴趣的是这段代码在哪里存储随机化的牌。让我们看看相关代码:
010032D3 PUSH EAX ; seed
010032D4 CALL DWORD PTR DS:[<;&msvcrt.srand>] ; [srand]
010032DA POP ECX ; ebx = 52 (from earlier)
010032DB XOR ESI,ESI ; esi = 0
010032DD /CALL DWORD PTR DS:[<;&msvcrt.rand>] ; [rand]: eax = randNumber
010032E3 |XOR EDX,EDX ; edx = 0
010032E5 |DIV EBX ; edx = randNumber % ebx
010032E7 |MOV ECX,ESI ; ecx = iteration #
010032E9 |AND ECX,7 ; ecx = ecx AND 7
010032EC |IMUL ECX,ECX,15 ; ecx = ecx * 21
010032EF |DEC EBX ; decrease iteration #
010032F0 |LEA EAX,DWORD PTR SS:[EBP+EDX*4-60]
010032F4 |MOV EDX,ESI ; edx = esi
010032F6 |SHR EDX,3 ; edx = edx / 8
010032F9 |ADD ECX,EDX ; ecx = ecx + edx
010032FB |MOV EDX,DWORD PTR DS:[EAX] ; load randCardNum
010032FD |MOV DWORD PTR DS:[ECX*4+1007554],EDX ; save in memory
01003304 |MOV ECX,DWORD PTR SS:[EBP+EBX*4-60]
01003308 |INC ESI ; esi = esi +1
01003309 |CMP ESI,34 ; check loop iterations
0100330C |MOV DWORD PTR DS:[EAX],ECX
0100330E \JB SHORT freecell.010032DD ; go to start of the loop
所以,在这段代码中,我们调用了 srand 函数,以之前随机化的游戏编号作为种子。这一点很重要。Freecell 有游戏编号,你可以选择一个特定的游戏,那么为什么游戏是随机化的,但如果你反复选择相同的游戏编号,你会得到相同的牌呢?答案在于代码的前两行,“随机化”的牌是以游戏编号作为种子数来随机化的。如果 rand 函数在 srand 函数中用相同的种子初始化,它生成的随机数将是相同的。
第 5 行是一个循环的开始,这个循环会迭代 52 次,每次迭代都随机化一张牌的编号,并将其放在内存的下一个位置。行 010032FD 是将随机化后的牌号保存在内存中的地方。在用调试器跟踪这些地址几次后,我可以告诉你,有 8 个牌的数组,第一次迭代将牌号作为第一个数组的第一个元素,接下来的迭代将牌号放在第二个数组的第一个元素,依此类推。当到达第 9 张牌时,它会重新开始,将牌号放在第一个数组的第二个元素……下面的图应该能更好地帮助你理解:
那么这 8 个数组有什么用呢?嗯,我们得到 8 个数组的原因是因为 Freecell 有 8 列牌。所以每个数组就是一列牌。再次,在跟踪循环几次后,我发现存储牌的地址的计算公式是:CardAddress = BaseAddress + 0x54*I + 4*J,其中 BaseAddress 是 0x1007554(在行 010032FD 中提到),I 是列号(0..7),J 是行号。
现在,我们得到了地址,就可以继续创建 Freecell Memory Reader 应用程序了。窗体包含 8 个列表框控件,每个列表框对应一列。我们首先要做的是检查我们使用的是哪个操作系统,我检查的 Freecell 在 XP 上,我也搜索了 Win2K 的地址,但无法测试程序,所以如果它在 Win2K 上不起作用,请留下评论,我会修复它。无论如何,我检查操作系统,并根据它设置稍后要使用的地址。然后我使用静态方法 Process.GetProcessesByName() 搜索 Freecell 进程,然后打开我的 ProcessMemoryReader 类的实例。这个类在我之前的文章中已经介绍过,请查看它以获得关于该类使用方法的详细解释。打开进程后,我从游戏编号地址读取游戏编号。然后我遍历牌的数组,对于数组中的每个位置,我读取其内存,将牌号转换为普通牌号,并将其放置在适当的列表视图中(根据其列)。
“转换牌号”?好吧,这里我需要解释牌是如何存储在内存中的。之前随机化的数字是 1 到 52 之间的数字,所以我需要将其转换为牌的编号(1 到 13)和牌的花色(1 到 4)。起初,我以为转换很简单,我认为它是这样组织的:
花色 | 编号 | 原始编号
1 - 1 - 1
1 - 2 - 2
1 - 3 - 3
1 -
4 - 4
...
2 - 1 - 14
2 - 2 - 15
...
4 - 12
- 51
4 - 13 - 52
事实证明并非如此……他们没有按顺序指定一种花色的所有牌,然后是下一张花色,依此类推(1..13,1..13,1..13,1..13),而是指定了牌号的集合。(1..4,1..4,1..4,重复 13 次),像这样:
花色 | 编号 | 原始编号
1 - 1 - 1
2 - 1 - 2
3 - 1 - 3
4 - 1
- 4
1 - 2 - 5
2 - 2 - 6
...
3 - 13 - 51
4 -
13 - 52
希望现在清楚了,这就是为什么需要进行转换。最后要提的是,当我从内存中读取游戏编号时,我得到的是一个 4 字节数组,我需要将其转换为 Int32。有比我的方法更好的方法,但我为了简单起见保留了我的方式。所以代码如下:
IntPtr GameNumAddress;
IntPtr CardsAddress;
// check if version is win2k or winXP
if (Environment.OSVersion.Version.Major == 5)
{
if (Environment.OSVersion.Version.Minor == 0) // win2K
{
GameNumAddress = (IntPtr)0x010071F8;
CardsAddress = (IntPtr)0x01007f74;
}
else // winXP
{
GameNumAddress = (IntPtr)0x0100834C;
CardsAddress = (IntPtr)0x01007554;
}
}
else
{
MessageBox.Show("Sorry, only winXP and win2K are supported!");
return;
}
// Search the Hearts Process
Process[] pArray = Process.GetProcessesByName("freecell");
if (pArray.Length == 0)
{
MessageBox.Show("No Freecell process!");
return;
}
// Create memory reader
ProcessMemoryReaderLib.ProcessMemoryReader pReader =
new ProcessMemoryReaderLib.ProcessMemoryReader();
// Take the first process found
pReader.ReadProcess = pArray[0];
pReader.OpenProcess();
for(int i=0 ; i<8 ; i++)
listArray[i].Items.Clear();
int iGameNum;
int readBytes;
byte[] buffer;
int CardNum, CardKind;
string ItemString = "";
bool bAddCard;
buffer = pReader.ReadProcessMemory(GameNumAddress,4,out readBytes);
iGameNum = buffer[0] + 256*buffer[1] + 256*256*buffer[2] +
256*256*256*buffer[3];
txtGameNum.Text = iGameNum.ToString();
for (int i=0 ; i<8 ; i++)
{
for (int j=0 ; j<21 ; j++)
{
buffer = pReader.ReadProcessMemory((IntPtr)(
(int)CardsAddress + 0x54*i + 4*j),4,out readBytes);
CardNum = (buffer[0] / 4) + 1;
CardKind = (buffer[0] % 4) + 1;
bAddCard = true;
switch (CardNum)
{
case 11:
ItemString = "J";
break;
case 12:
ItemString = "Q";
break;
case 13:
ItemString = "K";
break;
case 64:
bAddCard = false;
break;
default:
ItemString = CardNum.ToString();
break;
}
if (bAddCard)
listArray[i].Items.Add(ItemString ,CardKind -1);
}
}
pReader.CloseHandle();
接下来是……
第二部分:Hearts,它是如何工作的?
这款游戏的调试比其他游戏要困难得多,因为它在内存中存储牌的方式非常复杂。而且示例仅在 XP 上运行,因为我在 Win2K 系统上找不到 Hearts,所以无法搜索对应的地址。由于其复杂性,我将保持解释简洁明了,不会深入细节。
那么,有多少人知道我如何开始?没错,我们在调试器中打开程序(mshearts.exe),然后在文件中搜索著名的 rand() 函数。我们会找到两个地方,然后我们在每个地方设置断点。然后我们运行程序。一旦开始游戏,第一个断点就会触发一次,第二个断点会触发 52 次……我想我们已经找到了有趣的部分。所以,像往常一样,我们会查看代码并搜索存储值的内存地址。这里是第二个断点的代码:
01007FB6 /CALL DWORD PTR DS:[<;&msvcrt.rand>]
01007FBC |CDQ
01007FBD |IDIV DWORD PTR SS:[EBP-18]
01007FC0 |MOV EAX,DWORD PTR SS:[EBP-14]
01007FC3 |PUSH 0D
01007FC5 |POP EDI
01007FC6 |PUSH 4
01007FC8 |POP EBX
01007FC9 |MOV ECX,EDX
01007FCB |CDQ
01007FCC |IDIV EDI
01007FCE |SUB EAX,DWORD PTR DS:[ESI+140]
01007FD4 |MOV EDI,EDX
01007FD6 |ADD EAX,4
01007FD9 |CDQ
01007FDA |IDIV EBX
01007FDC |LEA EAX,DWORD PTR SS:[EBP+ECX*4-110]
01007FE3 |MOV EBX,DWORD PTR DS:[EAX]
01007FE5 |SHL EDI,4
01007FE8 |LEA ECX,DWORD PTR DS:[ESI+EDX*4+130]
01007FEF |MOV EDX,DWORD PTR DS:[ECX]
01007FF1 |MOV DWORD PTR DS:[EDI+EDX+1C],EBX
01007FF5 |MOV ECX,DWORD PTR DS:[ECX]
01007FF7 |XOR EBX,EBX
01007FF9 |DEC DWORD PTR SS:[EBP-18]
01007FFC |INC DWORD PTR SS:[EBP-14]
01007FFF |CMP DWORD PTR SS:[EBP-14],34
01008003 |MOV DWORD PTR DS:[EDI+ECX+28],EBX
01008007 |MOV ECX,DWORD PTR SS:[EBP-18]
0100800A |MOV ECX,DWORD PTR SS:[EBP+ECX*4-110]
01008011 |MOV DWORD PTR DS:[EAX],ECX
01008013 \JL SHORT mshearts.01007FB6
是的,我知道,很多丑陋的代码……但我加粗了我们感兴趣的部分,第一行是我们设置断点的地方,调用了 rand() 函数。第二行加粗的代码(01007FF1)是将随机化值存储在内存中的一行。请注意,这是第一类操作:MOV <内存地址>,<值>,所以我仔细检查了这个命令。在跟踪了几次之后,我发现用于存储值的地址具有一定的逻辑。前 13 次迭代(顺便说一句,这里提供的代码是一个循环)访问内存的一个区域,我们称之为区域 A,接下来的 13 次迭代访问另一个区域,区域 B,然后是接下来的 13 次迭代,以及最后 13 次迭代。所以我们得到什么?52 次迭代,每 13 次我们切换到一个不同的内存区域。我还发现每个集合中的内存地址之间的差值为 16。为了简化,我做了一个小图表,以便更好地理解结构:
所以,你看,我们有 4 个主要的基地址,每个都是一个数组,第一个元素在 ZoneA + 0,第二个在 ZoneA + 16,依此类推。根据图示,这 4 个基地址存储在一个数组中,这是正确的。我发现有一个包含 4 个地址的数组,该数组的每个元素都指向另一个包含 13 个元素的数组。这一切都很棒,但我仍然有一个问题,所有涉及这个结构的地址都是动态的,意味着它们在每次运行时都会改变。我需要找到一个地址,每次程序启动时,它始终是一个指向基地址的静态指针(基地址是 4 个指针数组的地址)。我怎么知道这种静态指针确实存在?嗯,这涉及到解释在堆栈上创建变量时会发生什么,以及在堆上创建变量时会发生什么(我指的是非托管代码)。所以当你创建堆栈上的变量时,它的地址会写入文件,因此在运行不同实例时不会改变;但如果你动态创建变量,它的地址在设计时是未知的,因此无法写入文件。现在假设你有一个指向数组的指针,那么数组的大小可以是任意的,你不能动态创建它,等等,但是指针本身是静态的,并且声明在堆栈上,所以总需要至少一个静态指针来保存结构,并且这个静态指针始终具有相同的地址。所以现在我知道它存在,我该如何找到它?嗯,我刚刚做了一个搜索。我运行了一个程序实例,并查看了我的基地址(指向地址数组的地址),然后我在内存中搜索,找到这个基地址存储在哪里,并且只找到了一个这样的地址。bingo。现在,诀窍在于知道在哪个内存区域查找,因为你怎么知道你正在查看的内存是动态内存还是静态内存?嗯,内存区域在 0x0100D000 和 0x0100E000 之间。这仅适用于此特定文件。每个文件都有自己的 .data 段,这是存储所有静态内存的段。
如果你没有理解所有细节也没关系,你只需要知道我们有一个静态地址 = 0x0100d514,这个静态地址是指向另一个指针,我们称之为 PointerArrayAddress,PointerArrayAddress 是指向一个包含 4 个指针的数组的指针。数组中的每个指针都指向一个包含 13 个元素的数组。每个元素包含一个牌号。每个牌的数组就是一个玩家。现在我只需要读取这个结构并在屏幕上显示它。
那么我们的代码做了什么?首先我们找到 Hearts 进程,然后读取静态指针,然后读取数组的 4 个指针,然后从数组中读取 52 个元素,对于每个元素,我们将它的牌号转换为标准的表示。如果你还记得上一节关于牌的转换,这里的转换也是一样的。最后,我们将它添加到屏幕上相应的列表(我们有 4 个列表)。代码如下:
// Search the Hearts Process
Process[] pArray = Process.GetProcessesByName("mshearts");
if (pArray.Length == 0)
return;
// Create memory reader
ProcessMemoryReaderLib.ProcessMemoryReader pReader =
new ProcessMemoryReaderLib.ProcessMemoryReader();
// Take the first process found
pReader.ReadProcess = pArray[0];
pReader.OpenProcess();
int i;
int readBytes;
byte[] buffer;
IntPtr StaticAddress = (IntPtr)0x0100d514;
buffer = pReader.ReadProcessMemory(StaticAddress,4,out readBytes);
IntPtr PointerArrayAddress = (IntPtr)(
buffer[0] +
256*buffer[1] +
256*256*buffer[2] +
256*256*256*buffer[3] + 0x130);
IntPtr[] aArray = new IntPtr[4];
buffer = pReader.ReadProcessMemory(
(IntPtr)PointerArrayAddress,16,out readBytes);
for (i=0 ; i<4 ; i++)
aArray[i] = (IntPtr)(
buffer[4*i] +
256*buffer[4*i + 1] +
256*256*buffer[4*i + 2] +
256*256*256*buffer[4*i + 3] + 0x1c);
for (i=0 ; i<4 ; i++)
listArray[i].Clear();
int CardNum, CardKind;
string ItemString = "";
bool bAddCard;
for (int j=0; j<13 ; j++)
{
for (i=0 ; i<4 ; i++)
{
buffer = pReader.ReadProcessMemory(
(IntPtr)((int)aArray[i] + (16 * j)),1,out readBytes);
CardNum = (buffer[0] / 4) + 1;
CardKind = (buffer[0] % 4) + 1;
bAddCard = true;
switch (CardNum)
{
case 11:
ItemString = "J";
break;
case 12:
ItemString = "Q";
break;
case 13:
ItemString = "K";
break;
case 64:
bAddCard = false;
break;
default:
ItemString = CardNum.ToString();
break;
}
if (bAddCard)
listArray[i].Items.Add(ItemString ,CardKind -1);
}
}
pReader.CloseHandle();
在下一节,我将给出一个使用 Hearts 游戏向进程内存写入的示例。
第三部分:WriteProcessMemory 奖励
正如我开头所说,我不能写一篇没有新功能的文章,所以我增强了这个类库的功能,现在它也可以写入另一个进程的内存。这是通过 API WriteProcessMemory
实现的。这个函数接受一个字节数组、一个地址和一个进程,并将字节数组写入进程的地址。很简单。它的常规头文件如下:
BOOL WriteProcessMemory(
HANDLE hProcess, // handle to process
LPVOID lpBaseAddress, // base of memory area
LPCVOID lpBuffer, // data buffer
SIZE_T nSize, // count of bytes to write
SIZE_T * lpNumberOfBytesWritten // count of bytes written
);
所以,它的 C# 等效代码是:
[DllImport("kernel32.dll")]
public static extern Int32 WriteProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
[In, Out] byte[] buffer,
UInt32 size,
out IntPtr lpNumberOfBytesWritten);
为了让这个函数正常工作,你首先需要以 PROCESS_VM_WRITE
和 PROCESS_VM_OPERATION
的权限打开进程。这里有一个使用这个函数的示例,该示例再次打开 Hearts 进程,但这次的目标是让我玩一局好牌。代码如下:
// Search the Hearts Process
Process[] pArray = Process.GetProcessesByName("mshearts");
if (pArray.Length == 0)
return;
// Create memory reader
ProcessMemoryReaderLib.ProcessMemoryReader pReader =
new ProcessMemoryReaderLib.ProcessMemoryReader();
// Take the first process found
pReader.ReadProcess = pArray[0];
pReader.OpenProcess();
int i;
int readBytes;
byte[] buffer;
IntPtr StaticAddress = (IntPtr)0x0100d514;
buffer = pReader.ReadProcessMemory(StaticAddress,4,out readBytes);
IntPtr PointerArrayAddress = (IntPtr)(
buffer[0] +
256*buffer[1] +
256*256*buffer[2] +
256*256*256*buffer[3] + 0x130);
IntPtr[] aArray = new IntPtr[4];
buffer = pReader.ReadProcessMemory(
(IntPtr)PointerArrayAddress,16,out readBytes);
for (i=0 ; i<4 ; i++)
aArray[i] = (IntPtr)(
buffer[4*i] +
256*buffer[4*i + 1] +
256*256*buffer[4*i + 2] +
256*256*256*buffer[4*i + 3] + 0x1c);
int writtenBytes;
buffer = new byte[1];
for (i=0 ; i<4 ; i++)
{
for (int j=0; j<13 ; j++)
{
buffer[0] = (byte)(j*4 + i);
pReader.WriteProcessMemory(
(IntPtr)((int)aArray[i] + (16 * j)),buffer,out writtenBytes);
}
}
pReader.CloseHandle();
btnReadMemory_Click(sender,null);
注意:现在游戏可能没那么有趣了。就是这样。希望你喜欢,别忘了投票。