扫雷游戏逆向(细粒度与粗粒度两种分析方法)
一、寻找雷区
方法一:IDA找main后逐步分析-细粒度
1.main函数
从EP处开始分析,一开始肯定是一些加载器的初始化过程,我们先粗略看一下,一直往下翻,在下面不远处找到程序的退出。
注意到,exit下面还有一个_cexit:
-
exit 函数:
-
定义:
exit
是 C 标准库中的函数,用于终止程序执行。 -
功能:
- 立即终止程序的执行。
- 调用
exit
后,程序将不会执行任何后续代码。 exit
会执行已注册的atexit
函数,用于清理操作,例如刷新缓冲区、关闭文件等。- 操作系统会回收程序占用的资源。
-
-
_cexit 函数:
-
定义:
_cexit
是 C 运行时库中的一个函数,用于执行 C 库的终止例程。 -
功能:
atexit
注册的函数和全局对象的析构函数将会被调用。- 与
exit
不同,_cexit
不会终止程序的执行,而只是处理 C 运行时的清理工作。
-
-
区别:
- 程序终止:
exit
函数会终止程序的执行,返回控制权给操作系统。_cexit
函数仅执行 C 库的清理工作,不会终止程序。
- 调用时机:
exit
通常在程序结束时调用,用于确保所有资源被正确释放,并返回退出状态。_cexit
通常用于在执行完所有清理操作后,程序仍然需要继续运行的场景。
- 程序终止:
如果觉得一直翻容易找不到的话,可以搜索exit进行查找,如下页面:
这时候就可以确定main函数的位置为10021F0,他在位于exit上方的第一个调用的函数。因为绝大部分C/C++ 程序在 main
函数结束时会调用 exit
函数,退出程序。通过追踪 exit
的调用,可以找到调用它的函数,从而确定 main
函数的位置。在进入 main
函数之前,程序通常会进行一些初始化操作,例如设置全局变量、初始化堆栈等。这些操作可能涉及多个函数调用,但最终会调用 main
函数。
进入main函数,开头进行了一些参数的定义,其中前三个为局部(临时)变量,后两个为传递的参数。WndClass= WNDCLASSW ptr -4Ch
是一个Windows API 结构体,用于注册窗口类。更加说明这个就是main函数。
因为里面代码太多了,我们F5梳理一下他的结构:
WPARAM __stdcall main(HINSTANCE a1, int a2, int a3, HACCEL hAccTable)
{
WNDCLASSW WndClass; // [esp+Ch] [ebp-4Ch] BYREF
struct tagMSG Msg; // [esp+34h] [ebp-24h] BYREF
INITCOMMONCONTROLSEX picce; // [esp+50h] [ebp-8h] BYREF
HACCEL hAccTablea; // [esp+6Ch] [ebp+14h]
hModule = a1;
sub_1003AB0();
if ( hAccTable == (HACCEL)7 || (dword_1005B38 = 0, hAccTable == (HACCEL)2) )
dword_1005B38 = 1;
picce.dwSize = 8;
picce.dwICC = 5885;
InitCommonControlsEx(&picce);
dword_1005B28 = (int)LoadIconW(hModule, (LPCWSTR)0x64);
WndClass.style = 0;
WndClass.lpfnWndProc = sub_1001BC9;
WndClass.cbClsExtra = 0;
WndClass.cbWndExtra = 0;
WndClass.hInstance = hModule;
WndClass.hIcon = (HICON)dword_1005B28;
WndClass.hCursor = LoadCursorW(0, (LPCWSTR)0x7F00);
WndClass.hbrBackground = (HBRUSH)GetStockObject(1);
WndClass.lpszMenuName = 0;
WndClass.lpszClassName = &Buffer;
if ( !RegisterClassW(&WndClass) )
return 0;
hMenu = LoadMenuW(hModule, (LPCWSTR)0x1F4);
hAccTablea = LoadAcceleratorsW(hModule, (LPCWSTR)0x1F5);
sub_1002BC2();
hWnd = CreateWindowExW(
0,
&Buffer,
&Buffer,
0xCA0000u,
*(_DWORD *)&X - dword_1005A90,
*(_DWORD *)&Y - dword_1005B88,
dword_1005A90 + xRight,
dword_1005B88 + yBottom,
0,
0,
hModule,
0);
if ( !hWnd )
{
sub_1003950(1000);
return 0;
}
sub_1001950(1);
if ( !sub_1002B14() )
{
sub_1003950(5);
return 0;
}
sub_1003CE5(dword_10056C4);
sub_100367A();
ShowWindow(hWnd, 1);
UpdateWindow(hWnd);
dword_1005B38 = 0;
while ( GetMessageW(&Msg, 0, 0, 0) )
{
if ( !TranslateAcceleratorW(hWnd, hAccTablea, &Msg) )
{
TranslateMessage(&Msg);
DispatchMessageW(&Msg);
}
}
sub_100263C();
if ( dword_100515C )
sub_1002DAB();
return Msg.wParam;
}
其中25行之前,都是进行一些初始化操作,包括设置窗口的各个属性等,粗略看一下就行。其中包含了初始化函数sub_1003AB0()
。
在26行出现了RegisterClassW
这是一个注册窗口类,如果注册失败程序就返回0结束了,之后的LoadMenuW
见名知意就是加载扫雷程序上面的菜单了。
在30行调用了一个函数sub_1002BC2();
可以重点留意一下,因为他之后就执行了CreateWindowExW
来创建窗口了。如果窗口创建失败调用sub_1003950(1000);
后返回0。
if ( !hWnd )
{
sub_1003950(1000);
return 0;
}
sub_1001950(1);
if ( !sub_1002B14() )
{
sub_1003950(5);
return 0;
}
我们可以看到出现错误后多次调用了sub_1003950
这个函数并且传入了不同给的参数,所以它应该是一个可以根据传入不同参数来处理不同错误的函数。
所以到57行ShowWindow(hWnd, 1);
显示窗口之前,我们就可以列出一些关键的子函数调用,他们之间一定包含了雷区生成。
之前的函数有:
- 30行:sub_1002BC2();
- 49行:sub_1001950(1);
- 50行:sub_1002B14();
- 55行:sub_1003CE5(dword_10056C4);
- 56行:sub_100367A();
2.sub_1002BC2()
查看第一个子函数的汇编代码如下:
只需要大致看一下,发现他主要是跟注册表,游戏基础设置,获取屏幕信息等初始化相关的,跟雷区什么的没有关系,所以直接忽略。
不过这样就更可以定位到雷区的位置了,因为根据游戏规则来分析,一定是获取到游戏基础设置(譬如难度、玩家设置的雷区宽高等)以后,才开始生成对应大小的雷区。
3.sub_1001950(1)
汇编太长了,直接放伪代码吧:
void __stdcall sub_1001950(char a1)
{
int v1; // ecx
int v2; // eax
int v3; // edx
int v4; // ecx
int v5; // [esp-14h] [ebp-4Ch]
int v6; // [esp+4h] [ebp-34h]
struct tagRECT rcItem; // [esp+8h] [ebp-30h] BYREF
struct tagRECT v8; // [esp+18h] [ebp-20h] BYREF
struct tagRECT rc; // [esp+28h] [ebp-10h] BYREF
v6 = 0;
if ( hWnd )
{
dword_1005B88 = dword_1005B80;
if ( (dword_10056C4 & 1) == 0 )
{
dword_1005B88 = dword_1005B80 + dword_1005B34;
if ( hMenu )
{
if ( GetMenuItemRect(hWnd, hMenu, 0, &rcItem) && GetMenuItemRect(hWnd, hMenu, 1u, &v8) && rcItem.top != v8.top )
{
dword_1005B88 += dword_1005B34;
v6 = 1;
}
}
}
xRight = 16 * dword_1005334 + 24;
yBottom = 16 * dword_1005338 + 67;
v1 = *(_DWORD *)&X + xRight - sub_1001915(0);
if ( v1 > 0 )
{
a1 |= 2u;
*(_DWORD *)&X -= v1;
}
v2 = sub_1001915(1);
v3 = *(_DWORD *)&Y;
v4 = *(_DWORD *)&Y + yBottom - v2;
if ( v4 > 0 )
{
a1 |= 2u;
v3 = *(_DWORD *)&Y - v4;
*(_DWORD *)&Y -= v4;
}
if ( !dword_1005B38 )
{
if ( (a1 & 2) != 0 )
MoveWindow(hWnd, *(int *)&X, v3, xRight + dword_1005A90, dword_1005B88 + yBottom, 1);
if ( v6
&& hMenu
&& GetMenuItemRect(hWnd, hMenu, 0, &rcItem)
&& GetMenuItemRect(hWnd, hMenu, 1u, &v8)
&& rcItem.top == v8.top )
{
v5 = dword_1005B88 - dword_1005B34 + yBottom;
dword_1005B88 -= dword_1005B34;
MoveWindow(hWnd, *(int *)&X, *(int *)&Y, xRight + dword_1005A90, v5, 1);
}
if ( (a1 & 4) != 0 )
{
SetRect(&rc, 0, 0, xRight, yBottom);
InvalidateRect(hWnd, &rc, 1);
}
}
}
}
从获取菜单设置的数GetMenuItemRect
到计算窗口大小xRight,yBottom
防止超出屏幕,超出之后使用 MoveWindow
来移动窗口等等,都表明这个子函数是调整扫雷游戏窗口的大小和位置,并根据菜单项的位置和尺寸进行调整。所以设置雷区的函数还在下面。
3.sub_1002B14()
call了两个子函数,第一个子函数的结果不为空就执行第二个。
进入sub_1002414
查看,如下:
发现有很多资源加载以及画笔创建的函数,所以很明显这个函数是初始化扫雷这个游戏的图形化资源的。
根据汇编,我们可以知道图形化资源加载完成后将执行如下子函数sub_1002ED5
:
- ①与②中,将数组
byte_1005340
的前 864 个元素初始化为 15。 - ③中,通过之前的理解以及这段汇编的整体阅读,我们可以知道
dword_1005334
记录的雷区网格宽度,dword_1005338
记录的雷区网格高度。同时给行数加了2,这个很好理解,因为我们要处理边界,除了雷区还有上下两个边界。 - ④中将
esi
(列值)的值左移 5 位,相当于esi = esi * 32
。这步操作将行索引转换为内存中的偏移量,适用于 32 列的数组。所以③与④就是一个准备边界填充的步骤。 - ⑤进行边界填充,将边界填充为 16。
- ⑥-⑧继续循环填充完边界。
- ⑨结束,恢复③存储的esi的值。
所以结束以后,这864个雷区网格被初始化为了什么样子?雷区初始化为 0x0F
(15),边界初始化为 0x10
(16),雷区跟边界组合在一起最大不超过864个。
我们去程序中测试一下:
我们设置宽高为40,这样雷区的网格数就会1600个超过了上面分析的864,结果如下:
发现程序最大允许设置的宽高为30x24,然后加上上下左右四个边界,总共允许的最大格子数为32x26=832<864,可以说刚好占满,这下更印证了我们之前的推理,但是为什么正好少了一行(32)呢,这个留到之后的OD动态调试里,直接查看内存应该会很直观。
通过这个函数,我们知道了雷区的存储位置在数组byte_1005340中。
其实到这一步已经知道雷区了,可以停止了。但是这只是初始化的时候,我想看一下使用随机数进行埋雷的步骤,一定就在下面剩下的两个函数中,所以我们接着分析。
4.sub_1003CE5();
注意到一个SetMenu,应该是设置菜单的,其中call了一个函数sub_1001516。
发现每次都是压入一个数,然后call sub_1003CC4。因为她的父函数是处理菜单的,因此,我们怀疑压入的数是控件ID。应该是是处理用户选的是初级,中级,高级之类的。所以,sub_1003CE5也不是。
5.sub_100367A()
埋雷的操作就一定是在这个函数中了
有个双循环,看着比较费劲,我们F5分析伪代码吧:
void sub_100367A()
{
char v0; // bl
int v1; // esi
int v2; // eax
char v3; // [esp-4h] [ebp-10h]
dword_1005164 = 0;
if ( dword_10056AC == dword_1005334 && uValue == dword_1005338 )
v3 = 4;
else
v3 = 6;
v0 = v3;
dword_1005334 = dword_10056AC;
dword_1005338 = uValue;
sub_1002ED5();
dword_1005160 = 0;
dword_1005330 = dword_10056A4;
do
{
do
{
v1 = sub_1003940(dword_1005334) + 1;
v2 = sub_1003940(dword_1005338) + 1;
}
while ( byte_1005340[32 * v2 + v1] < 0 );
byte_1005340[32 * v2 + v1] |= 0x80u;
--dword_1005330;
}
while ( dword_1005330 );
dword_100579C = 0;
dword_1005330 = dword_10056A4;
dword_1005194 = dword_10056A4;
dword_10057A4 = 0;
dword_10057A0 = dword_1005334 * dword_1005338 - dword_10056A4;
dword_1005000 = 1;
sub_100346A(0);
sub_1001950(v0);
}
很明显的,我们可以从这一段代码中提取出布雷操作如下:
dword_1005160 = 0;
dword_1005330 = dword_10056A4;
do {
do {
v1 = sub_1003940(dword_1005334) + 1;
v2 = sub_1003940(dword_1005338) + 1;
} while (byte_1005340[32 * v2 + v1] < 0);
byte_1005340[32 * v2 + v1] |= 0x80u;
--dword_1005330;
} while (dword_1005330);
通过每次双循环之后--dword_1005330;
的操作以及开始时dword_1005330 = dword_10056A4;
的操作,我们可以知道:
dword_1005330 = dword_10056A4
:设置dword_1005330
为dword_10056A4
,表示需要布置的雷的数量。--dword_1005330
:减少剩余需要布置的雷的数量,直到所有雷都布置完毕。
然后我们分析出这两个循环各自的作用:
-
外层循环表示每一次的布雷操作。
-
内层循环使用随机函数
sub_1003940
来一直取随机位置出来,直到找到一个未被布雷的位置(byte_1005340[32 * v2 + v1] < 0
),然后给他设为雷。byte_1005340[32 * v2 + v1] |= 0x80u
:在指定位置布雷,使用按位或操作设置该位置的最高位(表示布雷)。假设
byte_1005340[32 * v2 + v1]
的原始值为0x35
(即二进制00110101
),执行|= 0x80
后:00110101 (原始值) 10000000 (0x80) --------- 10110101 (结果)
结果
10110101
对应十六进制的0xB5
,最高位被设置为 1。既然这句话是埋雷的操作的话,那为什么可以用byte_1005340[32 * v2 + v1] < 0来判断这个地方是不是雷,为什么又是<0了?
这是因为
byte_1005340
数组中的每个元素是一个byte
(即 8 位),在初始化时,这些byte
被设置为0x0F
(十六进制,15)。当一个位置被标记为雷时,最高位被设置为 1(即0x80
,十六进制,128)。这样,标记后的byte
值变成0x8F
(十六进制,143),它在有符号字节表示中是一个负数(因为最高位为 1 表示负数)。
到这里,初始化以及埋雷操作已经弄清楚了。我们继续看一下sub_1003940
这一个随机位置生成函数:
非常的简洁,伪代码更是清晰:
int __stdcall sub_1003940(int a1)
{
return rand() % a1;
// 返回对应的随机行或列,取决于传入了a1
}
其中idiv [esp+arg_0]
这一句:
idiv
指令执行有符号除法。- 被除数是
edx:eax
(64 位数,其中edx
是高 32 位,eax
是低 32 位)。 - 除数是
[esp+arg_0]
,即函数的第一个参数。 - 执行
idiv
后,eax
存储商,edx
存储余数。
方法二:OD直接下断Rand()-粗粒度
1.找rand函数下断点
因为我们知道雷的生成是随机的,所以游戏开始时,就一定会调用rand函数来随机生成雷,而这又与对于雷区的操作息息相关,所以我们可以通过找rand函数,进而确定存储雷区的数组的位置。
将程序拖入OD动态调试,在任意位置点击鼠标右键->查找->模块间的调用:
然后找到其中的rand与srand:
srand 用于设置随机数生成器的种子(seed),
rand
用于生成伪随机数。
然后我们给这两个函数F2加一个断点,点击运行,程序先停留在srand设置种子处(看看得了,我也不知道看srand这一步有啥用),再次点击运行,程序停留在rand处,当前程序运行的函数位置便是通过随机数来确定埋雷位置的函数。
那么当前函数只是一个返回位置的函数,与我们找的雷区关系还不大。我们梳理一下现在这个函数的功能:
- 传入参数:行或者列的最大值
- 输出:随机挑选的一个行或列
所以想要找出正真跟雷区有关的代码,需要去看当前这个rand函数的调用者也就是父函数,有4种办法:
-
从当前栈堆得到调用关系,如图:
因为当前函数位于开头,所以从栈顶出发很容易找到他的父函数,即
010036D2
。 -
直接用工具看调用。鼠标置于当前行,鼠标右键->查看调用树(ctrl+k),如图也可以看到他的调用父级函数:
这个图第一列表示谁调用了当前这个函数,第二列是当前函数,第三列是当前函数又调用了哪些函数。显然有两个函数调用了当前这个函数,我们跳转过去看一下。
可以看到他们都正确找到了父函数,位置也相差无几。
-
使用 alt+k 来查看当前的调用堆栈,与 2 不同的是,调用堆栈必须函数运行到此处才可以使用,即查看当前的堆栈调用关系,其实就是方法 1 人工版的工具版。而2的方法是”我不想运行到某个函数,只想查看该函数的调用关系“。
如图,第一行也正确找出了调用函数的地址,与 2 不同的是没找出
010036DB
只找出了第一个,原因是第二个父函数还没有运行入栈,所以当前栈堆里看不到。 -
使用交叉引用查找父函数,右键查找参考->选定命令(CTRL+R 交叉引用):
也可以找到,原理与 2 差不多。
4.父函数分析
我觉的如果实际到这里已经找到父函数位置了,就可以直接跳到IDA里进行分析了,相当于直接跳过了细粒度里面的前四个函数,直奔主题了。
我们简单通过OD里的这个函数的代码确定一下雷区数组:
0100367A /$ A1 AC560001 mov eax,dword ptr ds:[0x10056AC]
0100367F |. 8B0D A8560001 mov ecx,dword ptr ds:[0x10056A8]
01003685 |. 53 push ebx
01003686 |. 56 push esi ; winmine.01005AA0
01003687 |. 57 push edi
01003688 |. 33FF xor edi,edi
0100368A |. 3B05 34530001 cmp eax,dword ptr ds:[0x1005334]
01003690 |. 893D 64510001 mov dword ptr ds:[0x1005164],edi
01003696 |. 75 0C jnz short winmine.010036A4
01003698 |. 3B0D 38530001 cmp ecx,dword ptr ds:[0x1005338]
0100369E |. 75 04 jnz short winmine.010036A4
010036A0 |. 6A 04 push 0x4
010036A2 |. EB 02 jmp short winmine.010036A6
010036A4 |> 6A 06 push 0x6
010036A6 |> 5B pop ebx ; winmine.010036D2
010036A7 |. A3 34530001 mov dword ptr ds:[0x1005334],eax
010036AC |. 890D 38530001 mov dword ptr ds:[0x1005338],ecx
010036B2 |. E8 1EF8FFFF call winmine.01002ED5
010036B7 |. A1 A4560001 mov eax,dword ptr ds:[0x10056A4]
010036BC |. 893D 60510001 mov dword ptr ds:[0x1005160],edi
010036C2 |. A3 30530001 mov dword ptr ds:[0x1005330],eax
010036C7 |> FF35 34530001 push dword ptr ds:[0x1005334]
010036CD |. E8 6E020000 call winmine.01003940
010036D2 |. FF35 38530001 push dword ptr ds:[0x1005338]
010036D8 |. 8BF0 mov esi,eax
010036DA |. 46 inc esi ; winmine.01005AA0
010036DB |. E8 60020000 call winmine.01003940
010036E0 |. 40 inc eax
010036E1 |. 8BC8 mov ecx,eax
010036E3 |. C1E1 05 shl ecx,0x5
010036E6 |. F68431 405300>test byte ptr ds:[ecx+esi+0x1005340],0x80
很明显010036E6 |. F68431 405300>test byte ptr ds:[ecx+esi+0x1005340],0x80
这句话就说明雷区数组是1005340
,我们打个断点来看一下这个雷区数组在内存中的样子吧。
这是没有经过埋雷操作的初始化后的数组,边界用10,雷区用0F填充。
为什么左右边框不都是10?
通过方法一中对于sub_1003CE5()
初始化函数的分析,这个区域为32列,26行,OD里面行是16个数据不是32个,我们把两行看成一行就没问题了,左右边界也就对了,都是10。
同时在方法一中的疑问为什么这个数组最大是 864 但是 32*26=832,少了一行(32),去哪里了?这个问题也得到了解决,我们可以看到最后一行没有用到,被空了出来与下面的数据做区分,如图:
我们给这个父函数的结尾打上断点(记得取消之前的rand断点),看看埋雷操作结束以后数组的变化:
我随便找出了几个雷,可以看到他们已经变成了8F
,与正常的0F
做区分。
二、点击函数分析
在前面的代码中,我们分析了雷区是怎样生成的,以及在显示游戏界面之前程序做了哪些准备工作。
在这一步中,我们将重点关注游戏界面显示以后,鼠标点击事件的处理函数。主要使用OD进行相关函数位置的寻找,然后再使用IDA进行函数分析。
窗口能一直显示在屏幕上是因为程序一直处于循环之中,循环中包含处理点击事件的消息函数。要确定这个循环的位置,需要一直点击单步步过(F8),直到窗口显示,并且程序一直处于循环之中。
期间这个小循环明显不符合特点,我们直接打断点然后跳过即可。
然后我们就找到了主循环,程序窗口也开始显示,但是不能运行,因为现在处于一步一步调试状态。
我们现在分析这个大的循环:
这段代码实现了一个典型的Windows消息循环。它不断获取、翻译和分派消息,以响应用户输入和其他系统事件。主要步骤包括:
- 使用
TranslateAcceleratorW
翻译加速器键消息。 - 使用
TranslateMessage
翻译消息,将虚拟键消息翻译为字符消息。 - 使用
DispatchMessageW
分派消息,将消息传递给窗口过程处理。 - 调用自定义的消息处理函数进行额外的处理。(也就是最后的
call esi
)
整个循环会一直执行,直到自定义消息处理函数返回0(表示退出循环)。
既然这个大循环是用来接收消息的消息循环,那么循环后面的函数就一定是用来处理这个消息的。
我们将循环后面的010023B1
打断点,然后运行程序:
可以看到程序窗口正常运行,他此时一直处于大循环之中,时刻准备接收消息。如果我们此时进行一次点击,那么他就会跳出循环由之后call的函数进行处理,但由于循环之后处理函数被我们打上断点,所以程序就会卡死。我们尝试点击,发现果然跟分析的一样:
函数一:鼠标点击计算周围雷数并自动翻开
接着上一步,我们给循环后面的010023B1
函数打断点后运行,发现程序可以正常接收消息,但是接收以后程序卡死,点击的块变成空白,无法计算出周围有几个雷。
作为对照,我们在这个函数的后面一行打断点不给他打,看看程序能否正常运行:
发现程序可以正常运行了,并且可以在鼠标点击空白之后自动计算周围雷数,实现自动翻开等功能。
那么说明我们要找的函数的地址就是0100263C
,我们打断点后点击窗口,进入这个函数进行分析。
未完待续...
- 感谢你赐予我前进的力量