游戏安全的学习(2)—— 利用Windows API 操作游戏窗口,和扫雷游戏分析。

先定个小目标,实现以下功能:

  • 透视,炸弹不炸,时间暂停,一键扫雷等等

句柄

句柄(handle)是Windows操作系统用来标识被应用程序所建立或使用的对象的整数,可以理解为内核有一张句柄表,我们打开获得的句柄得到的是句柄表的偏移值。

特定的,窗口句柄叫 HWND。我们可以通过 VS 的工具选项中的 SPY++

去找到窗口的标题,句柄和类等等信息。

获取窗口句柄

使用 API FindWindow 即可,我们需要给定两个参数,第一个参数为 Class Name,第二个参数为 Window name,应该是标题。我们可以给第一个参数传 NULL 一样能成功获取窗口句柄。

设置窗口标题

使用 SetWindowText 就可以了,第一个参数为窗口句柄,第二个参数为要修改的标题。

然后我们来搞搞扫雷。

1
2
3
4
5
6
7
8
#include<windows.h>
#include<iostream>

int main() {
HWND hwnd=FindWindow(NULL, L"扫雷");
printf("%x\n", hwnd);
SetWindowTextA(hwnd, "扫雷 by xia0ji233");
}

获取窗口标题

1
2
3
4
5
6
7
8
9
10
11
#include<windows.h>
#include<iostream>

int main() {
HWND hwnd=FindWindow(NULL, L"扫雷");
printf("%x\n", hwnd);
//SetWindowTextA(hwnd, "扫雷 by xia0ji233");
char *s =(char*) malloc(0x20);
GetWindowTextA(hwnd, s, 0x100);
printf("%s", s);
}

不过这玩意有点鸡肋啊,因为我就是通过标题找的窗口句柄,然后我又去 GetWindowText 了,大概是因为有别的方法去获得窗口句柄吧,咱也不知道。

扫雷游戏分析

对数据的分析(时间暂停)

首先我觉得最容易能分析的应该就是时间暂停了吧,用 CE 去找到数据相对于基址的偏移。抓准时机,搜到时间,看来就是四字节存储的。

然后对这个数据去找出 是什么改写了这个地址,得到一个指令和指针:

这个数据都是直接通过基址 + 固定偏移能直接得到的。

分析完成之后我们去写一下这个注入器和 DLL,这些都是之前学过的知识点。

注入器编写

就直接照着下面这么写:寻找进程,匹配,拿到进程 pid,然后用远程线程注入把指定的 DLL 注入到目标进程。

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
#include<windows.h>
#include<iostream>
#include<time.h>
#include<stdlib.h>
#include<TlHelp32.h>
DWORD FindProcess() {
HANDLE hSnap=CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS,0);
PROCESSENTRY32 pe32;
pe32 = { sizeof(pe32) };
BOOL ret=Process32First(hSnap, &pe32);
while (ret)
{
if (!wcsncmp(pe32.szExeFile,L"winmine.exe",11)) {
printf("Find winmine.exe Process %d\n", pe32.th32ProcessID);
return pe32.th32ProcessID;
}
ret=Process32Next(hSnap,&pe32);
}
return 0;
}
void InjectModule(DWORD ProcessId,const char* szPath)
{
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessId);
printf("进程句柄:%p\n", hProcess);
LPVOID lpAddress = VirtualAllocEx(hProcess, NULL, 0x100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
SIZE_T dwWriteLength = 0;
WriteProcessMemory(hProcess, lpAddress, szPath, strlen(szPath), &dwWriteLength);
HANDLE hThread = CreateRemoteThread(hProcess, NULL, NULL, (LPTHREAD_START_ROUTINE)LoadLibraryA, lpAddress, NULL, NULL);
WaitForSingleObject(hThread, -1);
VirtualFreeEx(hProcess, lpAddress, 0, MEM_RELEASE);
CloseHandle(hProcess);
CloseHandle(hThread);
}

int main() {
DWORD ProcessId = FindProcess();
while (!ProcessId) {
printf("未找到扫雷程序,等待两秒中再试\n");
Sleep(2000);
ProcessId = FindProcess();
}
printf("开始注入进程...\n");
InjectModule(ProcessId,"C:\\Users\\xia0ji233\\source\\repos\\Game2\\Debug\\Mine.dll");
printf("注入完毕\n");
}

DLL编写

这里直接用 MFC 的动态链接库,静态编译的模式。

需要注意窗口加载的模式,我们创建窗体之后,需要自主生成一个类对象,在资源窗体,新建一个 Dialog 然后右键窗体,添加类,取个名。

这里需要注意了,我们在加载窗体的时候需要创建一个窗体类对象用它的 DoModal 方法去显示,但是直接写在 InitInstance 中初始化会卡住原进程,所以可以把它用线程回调的方式加载,像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DWORD WINAPI DlgThreadCallBack(LPVOID lp) {
MineDlg* Dlg;
Dlg = new MineDlg();
Dlg->DoModal();
delete Dlg;
FreeLibraryAndExitThread(theApp.m_hInstance, 1);
return 0;
}
// CMineApp 初始化
BOOL CMineApp::InitInstance()
{
CWinApp::InitInstance();
::CreateThread(NULL, NULL, DlgThreadCallBack, NULL, NULL, NULL);
return TRUE;
}

这里我们找到了它控制时间增加的指令之后就可以把它 NOP 掉,写两个按钮,创建下面的事件实现时间暂停开关。

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
DWORD GetBaseAddr() {
HMODULE hMode = GetModuleHandle(nullptr);
//LPWSTR s = (LPWSTR)malloc(0x100);
//wsprintf(s, L"基址:%p", hMode);
//AfxMessageBox(s);
return (DWORD)hMode;
}

void MineDlg::OnBnClickedButton1()
{
// TODO: 在此添加控件通知处理程序代码
auto BaseAddr=GetBaseAddr();
DWORD TimeOffset = 0x579C;
DWORD TimeInsOffset = 0x2FF5;
DWORD InsLen = 6;
DWORD old;
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, PAGE_EXECUTE_READWRITE, &old);
BYTE INS[] = { 0x90,0x90,0x90,0x90,0x90,0x90 };
memcpy((void *)(BaseAddr + TimeInsOffset), INS, InsLen);
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, old, &old);
}


void MineDlg::OnBnClickedButton2()
{
// TODO: 在此添加控件通知处理程序代码
auto BaseAddr = GetBaseAddr();
DWORD TimeOffset = 0x579C;
DWORD TimeInsOffset = 0x2FF5;
DWORD InsLen = 6;
DWORD old;
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, PAGE_EXECUTE_READWRITE, &old);
BYTE INS[] = { 0xFF,0x05,0x9C,0x57,0x00,0x01 };
memcpy((void*)(BaseAddr + TimeInsOffset), INS, 6);
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, old, &old);
}

测试一下,成功执行:

对数据的分析(点雷不爆炸,修改表情)

这个呢一开始是没有什么头绪的,但是突然想到,里面应该有一个状态标识了现在的游戏状态:未开始,开始了,结束了,结束可能会分赢或输。反正就游戏开始的时候扫一下未知的值,然后故意点雷扫变化的值,再开始再扫变化的值,如此反复得到了两个跟游戏状态有关的数据:

游戏开始的时候,数值为这样,其它时候不变,输了的话变成 16 2。赢的时候下面的值变成了 3

所以下面那个值应该标记了你当前的游戏状态。

直接改成 3 发现并没有赢得游戏,只是上面那个小人变化了,是赢的戴墨镜的样子。

所以这个数值应该也就是给自己看看的。正真决定的还是第一个值,不过修改小人的这个功能可以做做。

这里直接用一个下拉框把四种状态表示齐全就好了。

1
2
3
4
5
6
7
8
void MineDlg::OnBnClickedButton3()
{
// TODO: 在此添加控件通知处理程序代码
DWORD FaceStatusOffset = 0x5160;
DWORD GameStatusOffset = 0x5000;
int index=FaceSel.GetCurSel();
*(int*)(FaceStatusOffset + GetBaseAddr()) = index;
}

但是还有另一个值需要分析,这个值应该决定了我们

是否能操作当前界面,如果我们点了一个雷之后,那个值变为 16,我们无法操作界面,但是我们可以改回 1 让它变得可操作,然后就把空白的地方点完就可以直接赢了。

然后我们创造一个复活键

1
2
3
4
5
6
void MineDlg::OnBnClickedButton4()
{
// TODO: 在此添加控件通知处理程序代码
DWORD GameStatusOffset = 0x5000;
*(int*)(GameStatusOffset + GetBaseAddr()) = 1;
}

而且目测好像改成偶数就无法操作,奇数则可以操作。

如果想要真正实现点雷不爆炸的话,可以把赋值 16 的语句给 NOP 掉。

跟之前差不多的套路。

点雷不爆炸:

1
2
3
4
5
6
7
8
9
10
11
12
void MineDlg::OnBnClickedButton5()
{
// TODO: 在此添加控件通知处理程序代码
auto BaseAddr = GetBaseAddr();
DWORD BoombOffset = 0x34D6;
DWORD InsLen = 10;
DWORD old;
VirtualProtect((void*)(BaseAddr + BoombOffset), InsLen, PAGE_EXECUTE_READWRITE, &old);
BYTE INS[] = { 0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90 };
memcpy((void*)(BaseAddr + BoombOffset), INS, InsLen);
VirtualProtect((void*)(BaseAddr + BoombOffset), InsLen, old, &old);
}

取消不爆炸:

1
2
3
4
5
6
7
8
9
10
11
12
void MineDlg::OnBnClickedButton6()
{
// TODO: 在此添加控件通知处理程序代码
auto BaseAddr = GetBaseAddr();
DWORD BoombOffset = 0x34D6;
DWORD InsLen = 10;
DWORD old;
VirtualProtect((void*)(BaseAddr + BoombOffset), InsLen, PAGE_EXECUTE_READWRITE, &old);
BYTE INS[] = { 0xC7,0x05,0x00,0x50,0x00,0x01,0x10,0x00,0x00,0x00 };
memcpy((void*)(BaseAddr + BoombOffset), INS, InsLen);
VirtualProtect((void*)(BaseAddr + BoombOffset), InsLen, old, &old);
}

透视+秒过关

这一步应该需要动态调试了,思考游戏结束的时候会自动显示所有的雷,因此我们在刚刚的基础上,往上面去找,看看哪个函数调用之后会显示所有的雷,经过几次的动态调试之后发现:0x2F80函数是我们要找的结果。

这里 Qfrost 师傅点醒我了,如果我想直接调用这个函数,那么我直接创建一个线程,让线程回调去调用这个函数即可,而且这玩意还有意外惊喜,一点直接就过关了哈哈哈,目标直接就结束了。

再经过一些调试分析的手段发现了实际调用的函数是 0x347c

并且根据函数的参数决定是输还是赢,这里可以直接秒杀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void MineDlg::OnBnClickedButton7()
{
// TODO: 在此添加控件通知处理程序代码
//DWORD ESPOffset = 0x3497;
//DWORD ESPOffset = 0x2913;
//DWORD ESPOffset = 0x26A7;
//DWORD ESPOffset = 0x272E;
//DWORD ESPOffset = 0x34AB;
//DWORD ESPOffset = 0x2f80;
DWORD ESPOffset = 0x347C;

DWORD FuncAddr = GetBaseAddr() + ESPOffset;
struct { int a; } s = { 0 };
CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)FuncAddr, &s, NULL, NULL);
}

点击直接秒杀,跟透视一块做了感觉哈哈哈