{扫雷程序下载, https://lovexl-oss.oss-cn-beijing.aliyuncs.com/bed/winmine.exe, haofont hao-icon-book}

一、寻找雷区

方法一:IDA找main后逐步分析-细粒度

1.main函数

image-20240528133347725

从EP处开始分析,一开始肯定是一些加载器的初始化过程,我们先粗略看一下,一直往下翻,在下面不远处找到程序的退出。

image-20240528133754581

注意到,exit下面还有一个_cexit

  • exit 函数

    • 定义exit 是 C 标准库中的函数,用于终止程序执行。

    • 功能:

      • 立即终止程序的执行。
      • 调用 exit 后,程序将不会执行任何后续代码。
      • exit 会执行已注册的 atexit 函数,用于清理操作,例如刷新缓冲区、关闭文件等。
      • 操作系统会回收程序占用的资源。
  • _cexit 函数

    • 定义_cexit 是 C 运行时库中的一个函数,用于执行 C 库的终止例程。

    • 功能:

      • atexit 注册的函数和全局对象的析构函数将会被调用。
      • exit 不同,_cexit 不会终止程序的执行,而只是处理 C 运行时的清理工作。
  • 区别

    1. 程序终止
      • exit 函数会终止程序的执行,返回控制权给操作系统。
      • _cexit 函数仅执行 C 库的清理工作,不会终止程序。
    2. 调用时机
      • exit 通常在程序结束时调用,用于确保所有资源被正确释放,并返回退出状态。
      • _cexit 通常用于在执行完所有清理操作后,程序仍然需要继续运行的场景。

如果觉得一直翻容易找不到的话,可以搜索exit进行查找,如下页面:

image-20240528140358148

这时候就可以确定main函数的位置为10021F0,他在位于exit上方的第一个调用的函数。因为绝大部分C/C++ 程序在 main 函数结束时会调用 exit 函数,退出程序。通过追踪 exit 的调用,可以找到调用它的函数,从而确定 main 函数的位置。在进入 main 函数之前,程序通常会进行一些初始化操作,例如设置全局变量、初始化堆栈等。这些操作可能涉及多个函数调用,但最终会调用 main 函数。

image-20240528141808987

进入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()

查看第一个子函数的汇编代码如下:

image-20240528150051755

只需要大致看一下,发现他主要是跟注册表,游戏基础设置,获取屏幕信息等初始化相关的,跟雷区什么的没有关系,所以直接忽略。

不过这样就更可以定位到雷区的位置了,因为根据游戏规则来分析,一定是获取到游戏基础设置(譬如难度、玩家设置的雷区宽高等)以后,才开始生成对应大小的雷区。

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()

image-20240528152103864

call了两个子函数,第一个子函数的结果不为空就执行第二个。

进入sub_1002414查看,如下:

image-20240528154820901

发现有很多资源加载以及画笔创建的函数,所以很明显这个函数是初始化扫雷这个游戏的图形化资源的。

根据汇编,我们可以知道图形化资源加载完成后将执行如下子函数sub_1002ED5

image-20240528161545180
  • ①与②中,将数组 byte_1005340 的前 864 个元素初始化为 15。
  • ③中,通过之前的理解以及这段汇编的整体阅读,我们可以知道dword_1005334记录的雷区网格宽度,dword_1005338记录的雷区网格高度。同时给行数加了2,这个很好理解,因为我们要处理边界,除了雷区还有上下两个边界。
  • ④中将 esi (列值)的值左移 5 位,相当于 esi = esi * 32。这步操作将行索引转换为内存中的偏移量,适用于 32 列的数组。所以③与④就是一个准备边界填充的步骤。
  • ⑤进行边界填充,将边界填充为 16。
  • ⑥-⑧继续循环填充完边界。
  • ⑨结束,恢复③存储的esi的值。

所以结束以后,这864个雷区网格被初始化为了什么样子?雷区初始化为 0x0F(15),边界初始化为 0x10(16),雷区跟边界组合在一起最大不超过864个。

我们去程序中测试一下:

image-20240528170500517

我们设置宽高为40,这样雷区的网格数就会1600个超过了上面分析的864,结果如下:

image-20240528170603018

发现程序最大允许设置的宽高为30x24,然后加上上下左右四个边界,总共允许的最大格子数为32x26=832<864,可以说刚好占满,这下更印证了我们之前的推理,但是为什么正好少了一行(32)呢,这个留到之后的OD动态调试里,直接查看内存应该会很直观。

通过这个函数,我们知道了雷区的存储位置在数组byte_1005340中。

其实到这一步已经知道雷区了,可以停止了。但是这只是初始化的时候,我想看一下使用随机数进行埋雷的步骤,一定就在下面剩下的两个函数中,所以我们接着分析。

4.sub_1003CE5();

image-20240528171149906

注意到一个SetMenu,应该是设置菜单的,其中call了一个函数sub_1001516。

image-20240528171433949

发现每次都是压入一个数,然后call sub_1003CC4。因为她的父函数是处理菜单的,因此,我们怀疑压入的数是控件ID。应该是是处理用户选的是初级,中级,高级之类的。所以,sub_1003CE5也不是。

5.sub_100367A()

埋雷的操作就一定是在这个函数中了

image-20240528172552876

有个双循环,看着比较费劲,我们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_1005330dword_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这一个随机位置生成函数:

image-20240528174631379

非常的简洁,伪代码更是清晰:

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动态调试,在任意位置点击鼠标右键->查找->模块间的调用:

image-20240528180254015

然后找到其中的rand与srand:

image-20240528180414036

srand 用于设置随机数生成器的种子(seed),rand 用于生成伪随机数。

然后我们给这两个函数F2加一个断点,点击运行,程序先停留在srand设置种子处(看看得了,我也不知道看srand这一步有啥用),再次点击运行,程序停留在rand处,当前程序运行的函数位置便是通过随机数来确定埋雷位置的函数。

那么当前函数只是一个返回位置的函数,与我们找的雷区关系还不大。我们梳理一下现在这个函数的功能:

  • 传入参数:行或者列的最大值
  • 输出:随机挑选的一个行或列

所以想要找出正真跟雷区有关的代码,需要去看当前这个rand函数的调用者也就是父函数,有4种办法:

Tips of Ollydbg 查看调用堆栈、交叉引用 - 情三 - 博客园 (cnblogs.com)

  1. 从当前栈堆得到调用关系,如图:

    image-20240528211910956

    因为当前函数位于开头,所以从栈顶出发很容易找到他的父函数,即010036D2

  2. 直接用工具看调用。鼠标置于当前行,鼠标右键->查看调用树(ctrl+k),如图也可以看到他的调用父级函数:

    image-20240528212313531

    这个图第一列表示谁调用了当前这个函数,第二列是当前函数,第三列是当前函数又调用了哪些函数。显然有两个函数调用了当前这个函数,我们跳转过去看一下。

    image-20240528213637889

    可以看到他们都正确找到了父函数,位置也相差无几。

  3. 使用 alt+k 来查看当前的调用堆栈,与 2 不同的是,调用堆栈必须函数运行到此处才可以使用,即查看当前的堆栈调用关系,其实就是方法 1 人工版的工具版。而2的方法是”我不想运行到某个函数,只想查看该函数的调用关系“。

    image-20240528214409599

    如图,第一行也正确找出了调用函数的地址,与 2 不同的是没找出010036DB只找出了第一个,原因是第二个父函数还没有运行入栈,所以当前栈堆里看不到。

  4. 使用交叉引用查找父函数,右键查找参考->选定命令(CTRL+R 交叉引用):

    image-20240528214743200

    也可以找到,原理与 2 差不多。

4.父函数分析

我觉的如果实际到这里已经找到父函数位置了,就可以直接跳到IDA里进行分析了,相当于直接跳过了细粒度里面的前四个函数,直奔主题了。

image-20240528215840761

我们简单通过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,我们打个断点来看一下这个雷区数组在内存中的样子吧。

image-20240528220610234

这是没有经过埋雷操作的初始化后的数组,边界用10,雷区用0F填充。

为什么左右边框不都是10?

通过方法一中对于sub_1003CE5()初始化函数的分析,这个区域为32列,26行,OD里面行是16个数据不是32个,我们把两行看成一行就没问题了,左右边界也就对了,都是10。

同时在方法一中的疑问为什么这个数组最大是 864 但是 32*26=832,少了一行(32),去哪里了?这个问题也得到了解决,我们可以看到最后一行没有用到,被空了出来与下面的数据做区分,如图:

image-20240528221642635

我们给这个父函数的结尾打上断点(记得取消之前的rand断点),看看埋雷操作结束以后数组的变化:

image-20240528222018189

我随便找出了几个雷,可以看到他们已经变成了8F,与正常的0F做区分。

二、点击函数分析

在前面的代码中,我们分析了雷区是怎样生成的,以及在显示游戏界面之前程序做了哪些准备工作。

在这一步中,我们将重点关注游戏界面显示以后,鼠标点击事件的处理函数。主要使用OD进行相关函数位置的寻找,然后再使用IDA进行函数分析。

窗口能一直显示在屏幕上是因为程序一直处于循环之中,循环中包含处理点击事件的消息函数。要确定这个循环的位置,需要一直点击单步步过(F8),直到窗口显示,并且程序一直处于循环之中。

image-20240528231525734

期间这个小循环明显不符合特点,我们直接打断点然后跳过即可。

image-20240528231625003

然后我们就找到了主循环,程序窗口也开始显示,但是不能运行,因为现在处于一步一步调试状态。

image-20240528231723203

我们现在分析这个大的循环:

这段代码实现了一个典型的Windows消息循环。它不断获取、翻译和分派消息,以响应用户输入和其他系统事件。主要步骤包括:

  1. 使用 TranslateAcceleratorW 翻译加速器键消息。
  2. 使用 TranslateMessage 翻译消息,将虚拟键消息翻译为字符消息。
  3. 使用 DispatchMessageW 分派消息,将消息传递给窗口过程处理。
  4. 调用自定义的消息处理函数进行额外的处理。(也就是最后的call esi

整个循环会一直执行,直到自定义消息处理函数返回0(表示退出循环)。

既然这个大循环是用来接收消息的消息循环,那么循环后面的函数就一定是用来处理这个消息的。

我们将循环后面的010023B1打断点,然后运行程序:

image-20240528233002981

可以看到程序窗口正常运行,他此时一直处于大循环之中,时刻准备接收消息。如果我们此时进行一次点击,那么他就会跳出循环由之后call的函数进行处理,但由于循环之后处理函数被我们打上断点,所以程序就会卡死。我们尝试点击,发现果然跟分析的一样:

image-20240528233329709

函数一:鼠标点击计算周围雷数并自动翻开

接着上一步,我们给循环后面的010023B1函数打断点后运行,发现程序可以正常接收消息,但是接收以后程序卡死,点击的块变成空白,无法计算出周围有几个雷。

作为对照,我们在这个函数的后面一行打断点不给他打,看看程序能否正常运行:

image-20240528234032032

发现程序可以正常运行了,并且可以在鼠标点击空白之后自动计算周围雷数,实现自动翻开等功能。

那么说明我们要找的函数的地址就是0100263C,我们打断点后点击窗口,进入这个函数进行分析。


未完待续...