This page looks best with JavaScript enabled

“花式扫雷” 辅助制作

 ·  ☕ 18 min read · 👀... views

队里来了好几个挂佬,这个暑假跟着他们学习了一些辅助制作的技巧。我把其中部分常用的技巧整理出来用扫雷这款游戏实现了一遍,然后写了这篇文章。至于为什么选扫雷这款游戏呢,首先这款游戏是我见过的第一款电脑游戏,又是我学习外挂制作过程中用于学习的第一款游戏,非常有纪念意义。而且,这款游戏非常简单,但是其组成部分又非常完整,“麻雀虽小,五脏俱全”,可以用各种各样的方式对其实现外挂,特别适合用作教学。

地图寻找

因为扫雷每次地雷位置都是随机出现的,在IDA的导入表中找到rand函数,查一下交叉引用就可以发现只有sub_1003940这个函数中调用了它。再查sub_1003940的交叉引用可以发现只有sub_100367A函数调用了它。对sub_1003940下断动态跟一下,就很容易可以发现,sub_1003940函数调用了rand函数生成了两个随机值,通过这两个随机值来决定在雷区地图的哪个位置有地雷,很明显,sub_100367A就是生成雷区地图的函数。 然后我们就很容易能注意到两个全局变量:dword_1005330(cnt_remainMine)存储还剩下多少个地雷没有初始化;byte_1005340(mine_map)存储着雷区地图

然后得到了扫雷地图下一步就要找一找他的寻址逻辑了。这一步用CE也能观察出来,但这里还是用IDA做一个分析,这样能知其所以然。

首先我选了初级模式,地图大小为9x9,我就在byte_1005340(mine_map)处下了一个大小为81字节的内存读写断点,然后随便选了一个坐标点进行动态,调了好一会最后在这里引起了我的注意

可以看到,这里push了edi和ebx,分别为3和6,这正是我选择的坐标点!扫雷这种有明显方格图的程序,以传入坐标点的方式进行判断是非常合理的。然后,内存断点所断下的这个mov同样可疑,它改变了雷区地图的值,瞒猜这里al的0x41代表的就是[esi]这个位置已被翻开,那猜测这时[esi]就是雷区二维坐标 (6,3) 的线性表示。为了验证这个猜想我们再从函数头部找到esi的计算方式。

很明显了,将纵坐标[edi]的值逻辑左移5位再加上横坐标[edx]的值。前面雷区地图初始化的时候rand了两个值保存在eax和esi中,最后将mine_map[eax+esi]设置为地雷,而eax也做过 “shl ecx, 5” 这个操作。这证明了我们这个猜测是非常合理的,我们获得了雷区地图中地雷的保存算法。对比地图可以发现:

0xF:  没有雷
0x8F: 雷
0x8a: 是地雷且格子被翻开
0x40: 格子被翻开且周围都没有雷
0x4X: 格子被翻开且周围有X个地雷

但是我们跟入sub_1002646这个函数可以发现它只是调用了API对雷区的图形界面进行一些改变,这就说明在这之前已经完成了对当前格是否是地雷的判断,而且还没有触发我的内存断点。仔细思考发现,坐标点(6,3)的线性表示是 0x66 = 102,而我的内存断点的size设置的是81。那只需要重启程序修改内存断点size就可以了。果然,程序断在了这里

通过对比mine_map计算出了此次游戏 (9,3) 坐标处是雷,选择这个坐标调试可以看到在sub_100316B中,将 (9,3)这个位置的值由0x8F改成了0x80,但是继续动调却发现,这个位置虽然确实会做一个检测,但是触发断点的并不是我们点击鼠标后的操作,而是我们鼠标悬停时,程序访问了我们鼠标悬停位置的雷区地图,导致触发了内存断点。

这就麻烦了。我们知道,在扫雷游戏中,一旦点到了地雷就会使游戏终止,这说明当我们点到地雷的时候,必然有个地方检测到了然后做了一个跳转,跳过去结束了游戏,我希望能找到这个跳转的地方,这样就能劫持流程,实现 “即使我们点到地雷游戏也仍然会继续” 的这样的功能。但是因为鼠标的悬停也会访问雷区地图,使得我们不能通过内存断点的方式找到跳转前的那次判断。 那这样的情况怎么解决呢?这里我埋个伏笔,为什么会出现这样的情况和如何解决我会在“call”这一节具体介绍

其实有了mine_map的地址,我们就已经可以实现外挂了。扫雷程序我们dump出他的雷区地图不就已经无敌了嘛(手动滑稽)。用这个简陋的idc脚本就可在IDA调试器附加的情况下输出获得当前这局游戏的雷区地图。(注意要先将程序暂停再运行脚本)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from idc import *
base = 0x1005340
for i in range(1,10):
    raw = []
    for j in range(1, 10):
        tmp = Byte(base+ (i<<5) + j)
        if tmp & 0x80 != 0:
            tmp = 1
        else:
            tmp = 0
        raw.append(tmp)
    print raw

当时每次玩游戏都用调试器附加是不可能的,既然是外挂嘛,就得独立。这里我用C++和python分别实现了读取输出地图,原理上是一样的,都是调用了kernel32.dll的OpenProcessReadProcessMemory API

 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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <cstdio>
#include <iostream>
#include <windows.h>
#include <winbase.h>
#include <Tlhelp32.h> 
#include <stdint.h>
using namespace std;
#define MINEMAP_BASE 0x1005340    // 地图基址
#define X_BASE 0x1005334                        // 游戏棋盘宽 
#define Y_BASE 0x1005338                        // 游戏棋盘高  
int X, Y;

DWORD GetProcessIDByName(const char* pName)      // 按进程名获取PID
{
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (INVALID_HANDLE_VALUE == hSnapshot) {
        return NULL;
    }
    PROCESSENTRY32 pe = { sizeof(pe) };
    for (BOOL ret = Process32First(hSnapshot, &pe); ret; ret = Process32Next(hSnapshot, &pe)) {
        // char szExeFile[256];
        // sprintf(szExeFile, "%ws", pe.szExeFile);  //如果是vs编译器需要转一下宽字节
        if (strcmp(pe.szExeFile, pName) == 0) {
            CloseHandle(hSnapshot);
            return pe.th32ProcessID;
        }
        //printf("%-6d %s\n", pe.th32ProcessID, pe.szExeFile);
    }
    CloseHandle(hSnapshot);
    return 0;
}

void print_map(BYTE * map) {
    BYTE tmp;
    for(int i=1;i<=Y;++i){
        for(int j=1;j<=X;++j){
            tmp = map[(i<<5)+j];
            if( (tmp & 0x80) != 0)    //这里按位运算的优先级是低于不等于的
                tmp = 1;
            else
                tmp = 0;
            printf("%d ",tmp);
        }
        printf("\n");
    }
}

int main(){
    DWORD PidGame = GetProcessIDByName("winmine.exe");   //获取进程ID
    cout<<"ID:"<<PidGame<<endl;
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, PidGame);  //获取进程句柄
    BYTE mine_map[865];
    if(hProcess != NULL) {
        if (ReadProcessMemory(hProcess, (LPVOID)MINEMAP_BASE, mine_map, 0x360, NULL) == 1)
        {
            cout <<"Read Success!"<< endl;
            ReadProcessMemory(hProcess, (LPVOID)X_BASE, &X, sizeof(X), NULL);
            ReadProcessMemory(hProcess, (LPVOID)Y_BASE, &Y, sizeof(Y), NULL);
            print_map(mine_map);
        }
        else
            cout << "Read Fail!" << endl;
    }
    CloseHandle(hProcess);
    return 0;
}

也写了个Py脚本来实现(感觉py调用这些API坑好多 눈_눈

 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
51
52
53
54
55
56
57
58
import os, psutil
from subprocess import check_output
from ctypes import *
from ctypes.wintypes import *
OpenProcess = windll.kernel32.OpenProcess
ReadProcessMemory = windll.kernel32.ReadProcessMemory
CloseHandle = windll.kernel32.CloseHandle
PROCESS_ALL_ACCESS = 0x1F0FFF
MINEMAP_BASE = 0x1005340
X = 30     # 棋盘X轴长度
Y = 24     # 棋盘Y轴长度

def get_pid(name):
    # return map(int,check_output(["pidof",name]).split())   #Linux
    return int( os.popen("tasklist /FI \"IMAGENAME eq %s\"" % name).read().split()[11] )   # 这种方法限制只能有一个同名进程


try:
    pid = get_pid("winmine.exe")
    print("winmine.exe pid is:", pid)
except IndexError as e:
    print("未找到进程!")
    exit(1)
except ValueError as e:
    print("终端错误!")
    exit(1)

address = MINEMAP_BASE  # Likewise; for illustration I'll get the .exe header.
buffer_ = c_char_p(("a"*0x360).encode())     # 设置缓冲区大小
bufferSize = len(buffer_.value)
bytesRead = c_ulong(0)
processHandle = OpenProcess(PROCESS_ALL_ACCESS, False, pid)

if ReadProcessMemory(processHandle, address, buffer_, bufferSize, byref(bytesRead)):
    print("Success:", buffer_)
else:
    print("Failed.")

raw_minemap = buffer_.value
CloseHandle(processHandle)

# print(buffer_.value)
minemap = []
for i in range(1, Y+1):
    minemap.append(['{:<2}'.format(i)])
    for j in range(1, X+1):
        # print((i<<5) + j)
        tmp = raw_minemap[(i<<5) + j]
        if tmp & 0x80 != 0:
            tmp = '{:<2}'.format(1)
        else:
            tmp = '{:<2}'.format(0)
        minemap[i-1].append( tmp )
minemap.insert(0, ['{:<2}'.format(i) for i in range(X+1)])
for i in range(Y+1):
    print(minemap[i])

# print(minemap[7][1])    # 快速查(7,1)处的值

模拟点击实现自动扫雷

拿到雷区地图其实我们已经无敌了,但是呢,小编太懒了,高级模式甚至更大的自定义模式我对着地图都懒得点_(:з」∠)_,那有没有办法可以自动的帮我点掉呢?当然可以,最容易想到的办法就是计算出坐标实现一下鼠标的模拟点击。模拟点击的关键就是要测算一下坐标值,这里我用了Microsoft Spy++工具,因为消息数量太多,需要设置一下做个过滤:监视 -> 日志消息 -> “查找程序工具”选择窗口 -> 消息 全部反选,勾选鼠标左键点击的两个消息事件WM_LBUTTONDOWN和WM_LBUTTONUP

然后我们对窗体做点击操作就可以监听到事件,看到相关的坐标值,计算一下每个格子的边长就可以了

然后这里我发现一个问题,就是我用API发送一个坐标过去,但是发现点击的位置不对,用Spy捕捉消息发现坐标和我参数填的坐标不一样!这就很疑惑了,测试了发现应该是一种等比例缩放的关系,然后不同电脑测试还不一样。具体原因我也不太确定,所以我代码中的坐标是自己测试出来的,这个过程比较麻烦,就瞎猜。别的都是挺简单的,就是调用PostMessage API,传入坐标模拟对地图上没有雷的位置点击即可,这里附上效果图和部分代码

 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
51
52
53
#define MINEMAP_BASE 0x1005340    // 地图基址
#define X_BASE 0x1005334                        // 游戏棋盘宽 
#define Y_BASE 0x1005338                        // 游戏棋盘高  
int X, Y;
CString str_tmp = TEXT("");
void cheat(HWND hwnd, CString& m_strshowdata, BYTE* map ) {
	unsigned short xypos[2] = { 0 };
	int gamex = 24, gamey = 74;
	m_strshowdata.Empty();
	for (int i = 1; i <= Y; ++i) {
		for (int j = 1; j <= X; ++j) {
			xypos[0] = gamex + 20 * (j-1);   // 低两字节保存X
			xypos[1] = gamey + 20 * (i-1);   // 高两字节保存Y
			BYTE tmp = map[(i << 5) + j];
			if ((tmp & 0x80) != 0) {    //这里按位运算的优先级是低于不等于的
				tmp = 1;
			}
			else {
				tmp = 0;
				::PostMessage(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, *(int*)xypos);  // PostMessage lParam低两字节保存X轴坐标,高两字节保存Y轴坐标
				::PostMessage(hwnd, WM_LBUTTONUP, 0, *(int*)xypos);
			}
			//printf("%d ", tmp);
			str_tmp.Format(TEXT("%01X "), tmp);     // 在Edit Control控件上输出雷区地图
			m_strshowdata += str_tmp;
			//Sleep(2000);
		}
		m_strshowdata += _T("\r\n");
	}
}
void CwinminecheatDlg::OnBnClickedButton6()
{
	HWND hwnd = ::FindWindow(NULL, TEXT("扫雷"));   //获取游戏窗口句柄
	if (hwnd == NULL) {
		::MessageBox(NULL, TEXT("扫雷游戏未打开"), TEXT("错误"), MB_OK);
		return;
	}

	DWORD pid;
	GetWindowThreadProcessId(hwnd, &pid);   // 通过窗口句柄得到进程ID
	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);  // 通过进程ID得到进程句柄
	BYTE mine_map[865];

	if (ReadProcessMemory(hProcess, (LPVOID)MINEMAP_BASE, mine_map, 0x360, NULL) == 1)
	{
		ReadProcessMemory(hProcess, (LPVOID)X_BASE, &X, sizeof(X), NULL);
		ReadProcessMemory(hProcess, (LPVOID)Y_BASE, &Y, sizeof(Y), NULL);
		cheat(hwnd, this->m_strshowdata, mine_map);
		
	}
	UpdateData(FALSE);
	return;
}

call的寻找

我们还可以通过找call来快速的实现扫雷外挂。因为每次点击,内部必然是调用了一个函数,往往会将我们点击的坐标当作参数传入点击函数,然后用call指令调用这个函数,程序会在函数内做是否为雷等等的逻辑判断和图案绘制。那我们只要找到这个call的位置,手动注入shellcode,让其调用这个call就可以实现自动的扫雷了。这种方法相较于模拟点击更具有普适性,因为模拟点击其实是受很多因素影响的,比如我这个坐标不一致的问题,个人感觉应该是窗口缩放的误差。但是注入后进行call就不存在这样的问题,因为程序固定,基址一定不变,基址不变,shellcode不变,调用的一定还是那个函数。 那接下来就要看一下怎么找这个call了。

通过前面的分析,应该可以知道 dword_1005118 和 dword_100511C 分别保存着这次点击的X轴坐标和Y轴坐标,而byte_1005340则保存着雷区地图。我们同样先给雷区地图下一个读写断点,然后随便点一个位置。正常情况下应该会在 0x010033EA 处断下来(因为如果你第一次点到的位置就是地雷的话,程序会重置扫雷地图,这样你断下来的位置可能不一样,如果不一样可以重试一下),而这个位置是隶属于sub_10031D4的,可以在里面看到很多的判断和调用分发,看一下这个函数的交叉引用可以看到确实在消息循环函数(sub_1001BC9)里,先做了两次push,即传了两个参数后对它做了call调用,并且通过动态调试可以发现push的两个参数分别就是我们点击位置的X坐标和Y坐标!

好家伙,那是不是意味着已经找到这个call了呢?我直接打开代码注入工具测试一下

有点问题,可以看到,我传的参数是(1,1),注入后(1,1)处的格子确实是被翻开了,或者说它处于被按压下去的状态,但是没有任何的反应,按道理格子被点击后应该显示四周有多少雷或者将四周都没有雷的格子一并翻开,说明我们这样做应该漏了什么。如果认真看了上一节的同学应该马上就能猜到是什么原因了。鼠标点击其实是两种操作,分别是 WM_LBUTTONDOWNWM_LBUTTONUP,在消息循环函数中接收到 WM_LBUTTONDOWN 消息,分发调用了 sub_10031D4 来实现对dword_1005118 和 dword_100511C这两个全局变量赋值,并在窗口上绘制处按压的图案。那必然的,程序真正的逻辑在
WM_LBUTTONUP 消息所分发调用的函数里。 那怎么找那个函数呢,给地图下内存断点已经没有用了,因为WM_LBUTTONDOWN就会将断点触发让程序断下来,WM_LBUTTONUP无法传入。 我们可以通过在 0x1001BD2 处下条件断点来实现

  • GetRegValue( “edx” ) == 0x202

当我们对窗口做任何点击拖动最大最小化等操作时,其实本质都是将消息发送给窗口,这个消息是由Windows封装好的一个结构化数据发送给程序的消息循环函数的,它的第二个参数Msg标志着消息的类型,比如我们点击的是窗体的菜单时 Msg = WM_COMMAND;当我们左键按下时,Msg = WM_LBUTTONDOWN;当我们左键弹起时,Msg = WM_LBUTTONUP。 而 WM_COMMAND,WM_LBUTTONDOWN,WM_LBUTTONUP 这些其实都是Windows的宏,比如WM_LBUTTONUP就等于0x202,所以我这里下条件断点的意思是当Msg为WM_LBUTTONUP时中断程序。

我们这样设置断点再动调就可以看到程序调用了sub_10037E1,在这个函数里做的就是真正的判断逻辑了。可能有的同学发现这个函数的调用并没有传任何的参数,因为它是通过dword_1005118 和 dword_100511C两个全局变量来确定你点击的位置的,而这两个全局变量则是在sub_10031D4(WM_LBUTTONDOWN)时初始化好的。所以我们修改注入代码,就可以实现扫雷了。

下面同样先附上一张效果图再加部分代码,其中因为权限令牌和注入函数是模块化封装好的,我懒得改了,所以有不少代码重复,比如句柄重开,在开发上这样写当然是不合适的,但是这两个模块化函数可以直接拿来用

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
typedef struct _SweepMineParame {
    UINT u_x;
    UINT u_y;
}SweepMineParame, * PSweepMineParame;

#include <winbase.h>
#include <Tlhelp32.h> 
bool enableDebugPriv() {   // 获取权限令牌
    HANDLE hToken;
    LUID sedebugnameValue;
    TOKEN_PRIVILEGES tkp;

    if (!OpenProcessToken(GetCurrentProcess(),
        TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) {
        return false;
    }

    if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &sedebugnameValue)) {
        CloseHandle(hToken);
        return false;
    }

    tkp.PrivilegeCount = 1;
    tkp.Privileges[0].Luid = sedebugnameValue;
    tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

    if (!AdjustTokenPrivileges(hToken, FALSE, &tkp, sizeof(tkp), NULL, NULL)) {
        CloseHandle(hToken);
    }

    return TRUE;
}




DWORD __stdcall SweepMine(LPVOID lpThreadParame) {   // 这样的写法编译器代码优化一定要设置为最大优化
    PSweepMineParame pParame = (PSweepMineParame)lpThreadParame;   // 在开发上不推崇这样直接注shellcode的写法
    UINT u_x = pParame->u_x;
    UINT u_y = pParame->u_y;
    __asm {
        push u_y
        push u_x
        mov eax, 0x10031D4
        call eax
        mov eax, 0x10037E1
        call eax
    }
    return 0;
}


bool InjectRemoteFunc(DWORD dwPid, LPVOID mFunc, LPVOID pRemoteParam, DWORD dwParameSize, DWORD dwWaitTime = INFINITE) {

    HANDLE hProcess = NULL;
    PVOID ThreadFunAddr = NULL;
    DWORD lpNumberofBytes = 0;
    BOOL bret = FALSE, bsucc = FALSE;
    LPVOID ParamAddr = NULL;
    HANDLE hTread = NULL;
    //DWORD dwWaitTime;
    //do {
        // 第一步 打开目标进程
        hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPid);
        //if (hProcess == NULL) break;

        // 第二步 在目标进程分配内存空间
        ThreadFunAddr = VirtualAllocEx(hProcess, NULL, 4096, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
        //if (ThreadFunAddr == NULL) break;

        // 第三步  向分配的内存空间中写入代码
        bret = WriteProcessMemory(hProcess, ThreadFunAddr, mFunc, 4096, &lpNumberofBytes);
        //if (bret == FALSE) break;

        // 第四步 装载函数所需要的参数的空间
        if (dwParameSize != 0) {
            ParamAddr = VirtualAllocEx(hProcess, NULL, dwParameSize, MEM_COMMIT, PAGE_READWRITE);
            //if (ParamAddr == NULL) break;

            //  写入参数的地址
            bret = WriteProcessMemory(hProcess, ParamAddr, pRemoteParam, dwParameSize, &lpNumberofBytes);
            //if (bret == FALSE) break;
        }

        // 第四步 执行目标进程地址代码
        hTread = CreateRemoteThread(hProcess, NULL, NULL, (LPTHREAD_START_ROUTINE)ThreadFunAddr, ParamAddr, NULL, NULL);
        //if (hTread == NULL) break;

        bsucc = TRUE;
    //} while (false);

    if (bsucc) {
        WaitForSingleObject(hTread, dwWaitTime);
        if (WAIT_TIMEOUT == dwWaitTime)   // 如果超时  放弃释放资源  防止目标进程在使用
            goto end;
    }
    if (bsucc && ThreadFunAddr && hProcess)
        VirtualFreeEx(hProcess, ThreadFunAddr, 0, MEM_RELEASE);
    if (bsucc && (0 != dwParameSize) && ParamAddr && hProcess)
        VirtualFreeEx(hProcess, ParamAddr, 0, MEM_RELEASE);
    //printf("资源成功释放\n");

end:
    if (hTread != NULL) CloseHandle(hTread);
    if (hProcess != NULL)  CloseHandle(hProcess);

    return bsucc;
}

void cheat(BYTE* map) {
    BYTE tmp;
    DWORD dwPid = GetProcessIDByName("winmine.exe");   //获取进程ID

    for (int i = 1; i <= Y; ++i) {
        for (int j = 1; j <= X; ++j) {
            tmp = map[(i << 5) + j];
            if ((tmp & 0x80) != 0)    //这里按位运算的优先级是低于不等于的
                tmp = 1;
            else {
                tmp = 0;
                SweepMineParame parame;    // 第零步  获取参数
                parame.u_x = j;
                parame.u_y = i;
                if (!InjectRemoteFunc(dwPid, SweepMine, &parame, sizeof(parame)))
                    printf("远程调用失败\n");
                Sleep(1);
            }
            printf("%d ", tmp);
        }
        printf("\n");
    }
}

int main() {
    enableDebugPriv();  // 获取权限令牌
    DWORD dwPid = GetProcessIDByName("winmine.exe");   //获取进程ID
    cout << "PID:" << dwPid << endl;
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, dwPid);  //获取进程句柄
    BYTE mine_map[865];
    if (hProcess != NULL) {
        if (ReadProcessMemory(hProcess, (LPVOID)MINEMAP_BASE, mine_map, 0x360, NULL) == 1)
        {
            cout << "Read Success!" << endl;
            ReadProcessMemory(hProcess, (LPVOID)X_BASE, &X, sizeof(X), NULL);
            ReadProcessMemory(hProcess, (LPVOID)Y_BASE, &Y, sizeof(Y), NULL);
            CloseHandle(hProcess);

            cheat(mine_map);
        }
        else
            cout << "Read Fail!" << endl;
    }
    return 0;
}

DLL注入

前面讲的方法是直接注入shellcode,这种方法在开发中往往不可取。因为每注入一段shellcode只能实现一个或者一次功能,要多次实现必然就要多次注入,也就是意味着要多次的申请和释放空间。这个过程是比较危险的,容易出错,所以在开发中往往采用的是直接注入DLL的方式,将整个辅助模块注入进去。

要实现一个比较规范标准的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
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
# define RETURN_FAIL(info) \
{ \
	AfxMessageBox(info); \
	goto error; \
}

CString GetAppPath() //返回应用程序的路径
{
	char szPath[MAX_PATH];
	GetModuleFileName(NULL, szPath, MAX_PATH);
	CString strPath = szPath;
	strPath = strPath.Left(strPath.ReverseFind('\\')+1);
	return strPath;
}

bool InjectModder(HANDLE hProcess, CString strDll, bool bInject) {
	// 写参数
	LPVOID pRemoteParam = ::VirtualAllocEx(hProcess, NULL, strDll.GetLength() + 1, MEM_COMMIT, PAGE_READWRITE);
	if (NULL == pRemoteParam) {
		::MessageBox(NULL, "远程进程参数地址分配失败", "失败", MB_OK);
		return false;
	}
	DWORD dwWritten = 0;
	if (!::WriteProcessMemory(hProcess, pRemoteParam, strDll.GetBuffer(0), strDll.GetLength(), &dwWritten)) 
		RETURN_FAIL(_T("向远程进程空间写入参数失败"));
	
	// 检查远程辅助模块是否已被注入
	PTHREAD_START_ROUTINE pRemoteFunc = (PTHREAD_START_ROUTINE)GetFuncAddr("kernel32.dll", "GetModuleHandleA");
	if (NULL == pRemoteFunc) RETURN_FAIL(_T("获取GetModuleHandleA地址失败"));
	HANDLE hThread = CreateRemoteThread(hProcess, NULL, NULL, pRemoteFunc, pRemoteParam, NULL, NULL);
	if (NULL == hThread) RETURN_FAIL(_T("远程线程启动失败"));
	WaitForSingleObject(hThread, INFINITE);
	DWORD dwModuleHandle;
	GetExitCodeThread(hThread, &dwModuleHandle);    // dwValue为远程进程辅助模块句柄

	if (bInject) {
		if (dwModuleHandle) RETURN_FAIL(_T("辅助模块已经注入,请勿重复注入"));
		PTHREAD_START_ROUTINE pRemoteFunc = (PTHREAD_START_ROUTINE)GetFuncAddr("kernel32.dll", "LoadLibraryA");
		if (NULL == pRemoteFunc) RETURN_FAIL(_T("获取LoadLibrary地址失败"));
		HANDLE hThread = CreateRemoteThread(hProcess, NULL, NULL, pRemoteFunc, pRemoteParam, NULL, NULL);
		if (NULL == hThread) RETURN_FAIL(_T("远程线程启动失败"));
		

		WaitForSingleObject(hThread, INFINITE);
		DWORD dwValue;
		GetExitCodeThread(hThread, &dwValue);


	}
	else {

		if (NULL == dwModuleHandle) RETURN_FAIL(_T("远程辅助模块未加载"));

		pRemoteFunc = (PTHREAD_START_ROUTINE)GetFuncAddr("kernel32.dll", "FreeLibrary");
		if (NULL == pRemoteFunc) RETURN_FAIL(_T("获取FreeLibrary地址失败"));
		hThread = CreateRemoteThread(hProcess, NULL, NULL, pRemoteFunc, (LPVOID)dwModuleHandle, NULL, NULL);
		if (NULL == hThread) RETURN_FAIL(_T("远程线程启动失败"));

		WaitForSingleObject(hThread, INFINITE);
		DWORD dwValue;
		GetExitCodeThread(hThread, &dwValue);  

	}

	::VirtualFreeEx(hProcess, pRemoteParam, strDll.GetLength() + 1, MEM_DECOMMIT);
	return true;

error:
	::VirtualFreeEx(hProcess, pRemoteParam, strDll.GetLength() + 1, MEM_DECOMMIT);
	return false;

}


void CwinminecheatDlg::OnBnClickedStartmodder()     // 注入辅助
{
	HWND hwnd = ::FindWindow(NULL, TEXT("扫雷"));   //获取游戏窗口句柄
	if (hwnd == NULL) {
		::MessageBox(NULL, TEXT("扫雷游戏未打开"), TEXT("错误"), MB_OK);
		return;
	}

	DWORD pid;
	GetWindowThreadProcessId(hwnd, &pid);   // 通过窗口句柄得到进程ID
	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);  // 通过进程ID得到进程句柄
	InjectModder(hProcess, GetAppPath() + MODDER_FILE_NAME, true);	
}


void CwinminecheatDlg::OnBnClickedRemovemodder()   // 卸载辅助
{
	HWND hwnd = ::FindWindow(NULL, TEXT("扫雷"));   //获取游戏窗口句柄
	if (hwnd == NULL) {
		::MessageBox(NULL, TEXT("扫雷游戏未打开"), TEXT("错误"), MB_OK);
		return;
	}

	DWORD pid;
	GetWindowThreadProcessId(hwnd, &pid);   // 通过窗口句柄得到进程ID
	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);  // 通过进程ID得到进程句柄

	InjectModder(hProcess, GetAppPath() + MODDER_FILE_NAME, false);
}

然后就是要实现一下辅助模块了。辅助模块内同样也是采用调用call的方式来实现自动扫雷,这里与注入shellcode有些区别。因为注入shellcode时,我们的外挂程序与扫雷程序是两个不同的程序,所以所需变量的读写都需要通过ReadProcessMemory WriteProcessMemory等API来实现。而现在,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
// winmine_modder.cpp
DWORD WINAPI ShowMainDlg(LPVOID pParam) {   // 显示辅助页面
	// ::MessageBox(NULL, TEXT("辅助成功加载"), TEXT(""), NULL);
	ModderMainDlg dlg;
	dlg.DoModal();

	return 0;
}

BOOL CwinminemodderApp::InitInstance()   // DLL在初始化时被调用
{
	CWinApp::InitInstance();

	m_hUIThread = ::CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)ShowMainDlg, NULL, NULL, NULL);
	return TRUE;
}
BOOL CwinminemodderApp::ExitInstance()   // DLL在释放时被调用 
{
	::TerminateThread(m_hUIThread, 0);
	::WaitForSingleObject(m_hUIThread, INFINITE);
	::CloseHandle(m_hUIThread);

	::MessageBox(NULL, TEXT("辅助卸载成功"), TEXT(""), NULL);
	return CWinApp::ExitInstance();
}

记得要在MFC里创建一个页面,再添加控件

 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
51
52
53
54
55
// ModderMainDlg.cpp
typedef struct _SweepMineParame {
    UINT u_x;
    UINT u_y;
}SweepMineParame, * PSweepMineParame;


DWORD __stdcall SweepMine(LPVOID lpParame) {   // 这样的写法编译器代码优化一定要设置为最大优化
    PSweepMineParame pParame = (PSweepMineParame)lpParame;
    UINT u_x = pParame->u_x;
    UINT u_y = pParame->u_y;
    __asm {
        push u_y
        push u_x
        mov eax, 0x10031D4
        call eax
        mov eax, 0x10037E1
        call eax
    }
    return 0;
}

#define MINEMAP_BASE 0x1005340    // 地图基址
#define X_BASE 0x1005334                        // 游戏棋盘宽 
#define Y_BASE 0x1005338                        // 游戏棋盘高  
#define GAME_STATUS 0x1005160                   // 游戏状态 0为正在游戏  2为扫雷失败  3为扫雷成功
int X, Y;

void ModderMainDlg::OnBnClickedSweepmine()
{

    BYTE *mine_map = (BYTE*)MINEMAP_BASE;

    X = *(int*)X_BASE;
    // ReadProcessMemory(hProcess, (LPVOID)X_BASE, &X, sizeof(X), NULL);    等效于
    Y = *(int*)Y_BASE;
    // ReadProcessMemory(hProcess, (LPVOID)Y_BASE, &Y, sizeof(Y), NULL);    等效于

    for (int i = 1; i <= Y; ++i) {
        for (int j = 1; j <= X; ++j) {
            if (mine_map[(i << 5) + j] == 0) {
                SweepMineParame parame;    // 获取参数
                parame.u_x = j;
                parame.u_y = i;
                SweepMine(&parame);
                // Sleep(2);
                if (3 == *(int*)GAME_STATUS) {    // 要判断下当前游戏状态  若该局游戏已结束仍继续注入call会使游戏崩溃
                    ::MessageBox(NULL, _T("扫雷完毕"), _T("OK"), MB_OK);
                    return;
                }
            }
        } 
    }

}

修改跳转

除了模拟点击、注入代码,其实还有很多奇技淫巧,比如我们前面提到的,当点击到地雷时,必然有一个判断跳转会去结束掉游戏,那如果我们找到了那个跳转,将其跳转流程改掉,那就算我们点到了地雷,也不会结束,我们就可以随便点格子直到翻开全部格子。 要找这个位置需要一些耐心,多动调几遍观察函数作用。大体的方法就是下“WM_LBUTTONUP”断点,输出地图对比非雷与雷点击后代码的运行路径。这里我直接附上我的实现代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#define JMP_BASE 0x1003591
#define PATCH true          // 开启功能

void patch_jmp() {
    DWORD dwPid = GetProcessIDByName("winmine.exe");
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, dwPid);
    BYTE jmp[2] = {0};
    if(PATCH){
        jmp[0] = 0xEB; jmp[1] = 0x1D;
    }
    else{
        jmp[0] = 0x6A; jmp[1] = 0x00;
    }
    DWORD lpNumberofBytesWritten;
    WriteProcessMemory(hProcess, (LPVOID)JMP_BASE, jmp, sizeof(jmp), &lpNumberofBytesWritten);
    CloseHandle(hProcess);
    return 0;
}

驱动读写

在最初写挂的时候 BlackBinary神就建议我最好直接学习驱动,直接上驱动挂可以省去很多时间研究3环的对抗技术。扫雷虽然是不带任何保护的一个小小小游戏,但也不影响我们用这款游戏展示驱动读写。 驱动的开发环境我用的是Windows 10 1809 + vs2019 + WDK 1809,这个版本对驱动的影响是比较大的,因为驱动会用到r0的API,低版本环境下的驱动能在高版本环境中运行,但高版本环境的驱动不一定能在低版本环境中运行。 还有一个注意点就是驱动编译的架构一定要和调用驱动的程序编译的架构相同。比如我驱动是用x64编译的,那调用这个驱动的程序也一定要是x64架构的,不然指针上会有很严重的问题,基本就是蓝屏(别问我为什么知道的这么清楚QwQ)

PS:这个Mdl读写驱动用的是这位大佬的项目,模块化封装的非常好,不会写驱动的师傅看到也能用

Mdl读取内存

驱动给出仓库地址了,这里就不贴了,贴一下驱动的注册和启动代码

1
2
3
4
5
sc create MyDriver binPath= "D:\DriverReadWriteProcess.sys" type= kernel start= demand
sc start MyDriver
pause
sc stop MyDriver
sc delete MyDriver

如果提示驱动数字签名无效不能启动,可以进安全模式暂时关闭签名保护或者用 64Signer 上个签名

 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
51
52
53
54
55
56
57
58
59
60
61
/* 读取内存 */
#define Mdl_Read CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ALL_ACCESS)

/* 写入内存 */
#define Mdl_Write CTL_CODE(FILE_DEVICE_UNKNOWN,0x801,METHOD_BUFFERED,FILE_ALL_ACCESS)

/* 连接符 */
const  char* g_link = "\\??\\{F90B1129-715C-4F84-A069-FEE12E2AFB48}";

/* 传递信息的结构 */
typedef struct _UserData
{
    DWORD Pid;							//要读写的进程ID
    DWORD64 Address;				//要读写的地址
    DWORD Size;							//读写长度
    PBYTE Data;								//要读写的数据
}UserData, * PUserData;

HANDLE hDriver = NULL;


void read(DWORD pid, DWORD64 addr, LPVOID result, DWORD size)
{

    UserData buf{ 0 };
    buf.Pid = pid;
    buf.Address = addr;
    buf.Data = (PBYTE)result;
    buf.Size = size;

    DWORD dwSize = 0;
    DeviceIoControl(hDriver, Mdl_Read, &buf, sizeof(buf), &buf, sizeof(buf), &dwSize, NULL);

}

int main() {
    hDriver = CreateFileA(g_link,
        GENERIC_READ | GENERIC_WRITE,
        FILE_SHARE_READ | FILE_SHARE_WRITE,
        NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL,
        NULL);
    if (hDriver == INVALID_HANDLE_VALUE)
    {
        printf("[-] 驱动打开失败 %d \n", GetLastError());
        return 0;
    }

    PidGame = GetProcessIDByName("winmine.exe");   //获取进程ID
    cout << "PID:" << PidGame << endl;
    BYTE mine_map[865] = {0};
    read(PidGame, MINEMAP_BASE, mine_map, sizeof(mine_map));
    read(PidGame, X_BASE, &X, sizeof(X));
    read(PidGame, Y_BASE, &Y, sizeof(Y));
    
    print_map(mine_map);

    CloseHandle(hDriver);
    return 0;
}
Share on

Qfrost
WRITTEN BY
Qfrost
CTFer, Anti-Cheater, LLVM Committer