Windows XP扫雷游戏分析以及辅助编写

Windows XP扫雷游戏分析以及辅助编写

工具:IDA、x32dbg、Ollydbg、CFF Explorer、PETools
环境:Win10,Windows XP

0x01 游戏分析

找到程序所在位置,可以发现该游戏似乎并没有编写额外的dll或者lib,就只有一个winmine.exe,而且是n多年前xp自带的东西了,所以也没加壳: image20210704023734461.png

尝试最大化窗口,发现最大格子数为24x30,最多的雷数为667:

image20210704104436984.png

拖进IDA中,OEP:

image20210704203820776.png

简单看一下:

25-50的位置基本上就是PE程序的自校验,这些数字的比较默认是10进制的看不出来,转成16进制就很明显了:

image20210704205004318.png

52-86可以不用管:

image20210704205057216.png

87-100就是窗体创建了:

image20210704205128120.png

sub_10021F0,调用Win32 API进行窗口创建:

image20210704205203683.png

image20210704205508711.png

回调函数DialogFunc:

INT_PTR __stdcall DialogFunc(HWND hDlg, UINT a2, WPARAM a3, LPARAM a4)
{
  int v4; // eax

  switch ( a2 )
  {
    case 0x53u:
      WinHelpW(*(HWND *)(a4 + 12), L"winmine.hlp", 0xCu, (ULONG_PTR)&unk_1005040);
      break;
    case 0x7Bu:
      WinHelpW((HWND)a3, L"winmine.hlp", 0xAu, (ULONG_PTR)&unk_1005040);
      break;
    case 0x110u:
      SetDlgItemInt(hDlg, 141, height, 0);      // 第二参数为宏,将值赋到Item中
      SetDlgItemInt(hDlg, 142, width, 0);
      SetDlgItemInt(hDlg, 143, mine_num, 0);
      return 1;
    case 0x111u:
      if ( (unsigned __int16)a3 != 1 )
      {
        if ( (unsigned __int16)a3 == 2 )
        {
LABEL_13:
          EndDialog(hDlg, 1);
          return 1;
        }
        if ( (unsigned __int16)a3 != 100 )
        {
          if ( (unsigned __int16)a3 != 109 )
            return 0;
          goto LABEL_13;
        }
      }
      height = sub_1003DF6(hDlg, 141, 9, 24);   // 控制高度范围为9-24
      width = sub_1003DF6(hDlg, 142, 9, 30);    // 控制宽度范围为9-30
      v4 = (height - 1) * (width - 1);
      if ( v4 > 999 )
        v4 = 999;
      mine_num = sub_1003DF6(hDlg, 143, 10, v4);// 控制雷数为10,v4,由于宽高限制,最大雷数为23*29=667
      goto LABEL_13;
  }
  return 0;
}

这个宽度和高度肯定会用来初始化雷区的,Xref,发现还有五个函数调用:

image20210707211033299.png

最后发现sub_100367A中,通过do-while循环拿到一个随机的x和y坐标,然后将雷放到这个坐标中,完成雷区初始化,存放雷区的位置为mine_area

image20210707211310051.png

void sub_100367A()
{
  char v0; // bl
  int v1; // esi
  int v2; // eax
  char v3; // [esp-4h] [ebp-10h]

  dword_1005164 = 0;
  if ( width == __width && height == __height )
    v3 = 4;
  else
    v3 = 6;
  v0 = v3;
  __width = width;
  __height = height;
  sub_1002ED5();
  dword_1005160 = 0;
  __mine_num = mine_num;
  do
  {
    do
    {
      v1 = gen_mine_location(__width) + 1;
      v2 = gen_mine_location(__height) + 1;
    }
    while ( mine_area[32 * v2 + v1] < 0 );
    mine_area[32 * v2 + v1] |= 0x80u;
    --__mine_num;
  }
  while ( __mine_num );
  dword_100579C = 0;
  __mine_num = mine_num;
  dword_1005194 = mine_num;
  dword_10057A4 = 0;
  safe_pos = __width * __height - mine_num;
  dword_1005000 = 1;
  sub_100346A(0);
  sub_1001950(v0);
}

由于程序并未开启ASLR,所以IDA中的地址跟x32dbg中的地址是一样的,直接查地址可以发现这里就是雷区的分布,并且通过前面的分析我们知道雷区的数值是|=0x80的,所以红框中0x8E的位置就是雷,0x0F的位置就是可以点击的位置:

image20210707212606304.png

这里需要知道的是,在我们点击前并未对数值做计算,所以此时可以发现出了雷区其他位置都是0x0F,当点击之后,才会开始计算:

image20210707213018298.png

这里的位置通过分析可以发现,上两行,下两行,左一行,右一行可以理解为墙,所以真正的雷区是从1005360开始,并且根据之前随机化区雷区的结果,真正开始的位置是1005361开始,然后隔一行才是下一行雷区,

image20210710232234132.png

真正的雷区,可以发现就是存在10的位置,这里多出的一行0x0F是预留的宽度,就是宽度的最大长度可以理解为就是32:

image20210710233059214.png

可以发现其他位置的值都是0x0F,点击非雷的位置,可以发现对于点击的位置周围9格进行雷数的确认,再根据雷数设置该位置值,可以看到窗口显示2,对应内存的值为0x42,那么就可以理解为 雷数 | 0x40 = 0x42

image20210719001251472.png

对于周围雷数为0的,则显示为0x40:

image20210719001601020.png

0x02 消息机制分析

程序中右键格子时会出现旗子,这里会涉及到Win32 API中的重绘函数以及捕获鼠标按键的函数SetCapture

关于内存中雷区的坐标和矩阵中的数据时通过Rect相关API进行关联

发现BeginPaint只在sub_1001BC9进行调用,那么这里肯定就是重绘的地方

image20210714015839584.png

关于BeginPaint,主要通过PAINTSTRUCT结构体传递消息对格子进行重绘,该函数调用完成后返回一个设备句柄HDC,之后的sub_1002AC3才是真正进行绘图的地方:

int __stdcall sub_1002AC3(HDC hdc)
{
  sub_1002A22(hdc);
  sub_1002785(hdc);
  sub_10028D9(hdc, dword_1005160);
  sub_1002825(hdc);
  return sub_10026A7(hdc);
}

0x03 辅助编写

需要实现一键找到所以的雷区并且标上🚩

这里采用MFC实现,整体内容:

image20210708005131586.png

可以通过该程序运行winmine.exe,Status中显示该程序的实时状态,FindMine实现一键找到雷的坐标

实现程序运行

在MineCrackDlg.h类中添加public class variable:

// Class Variable
CString appName;
STARTUPINFO si;
PROCESS_INFORMATION pi;

MineCrackDlg.cpp中MineCrack的构造方法添加:

appName.Format(L"D:\\winmine.exe");
ZeroMemory(&si, sizeof(si));
ZeroMemory(&pi, sizeof(pi));

MineCrackDlg.cpp的按钮回调函数中添加:

void CMineCrackDlg::OnBnClickedRunmine()
{
    if (CreateProcess(appName, 
        NULL,
        NULL,
        NULL,
        FALSE,
        0,
        NULL,
        NULL,
        &si,
        &pi)) {
        MessageBox(L"Fuck", L"", 0);

    }
}

在按钮事件之前,需要添加一个定时器,用来判断winmine.exe是否已经运行,这里我设定的是只能让辅助程序去运行winmine.exe方便后续的操作,所以这里的判断是判断是否已经通过辅助程序运行,创建一个Check函数,通过CreateToolhelp32Snapshot来创建进程快照,通过遍历的方式去寻找是否有winmine.exe的字符串存在:

UINT CMineCrackDlg::OnCheckProgramIsRunning() {
    UINT dwFlag = 0; // 程序是否运行的标志,0表示未运行,1表示已运行

    HANDLE hSnapshort = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnapshort == INVALID_HANDLE_VALUE)
    {
        MessageBox(L"CreateToolhelp32Snapshot调用失败", L"Error", 0);
    }

    PROCESSENTRY32 stcProcessInfo;
    stcProcessInfo.dwSize = sizeof(stcProcessInfo);

    BOOL  bRet = Process32First(hSnapshort, &stcProcessInfo);

    while (bRet)
    {
        if (!wcscmp(stcProcessInfo.szExeFile, reAppName)) {
            dwFlag = 1;
            break;
        }
        bRet = Process32Next(hSnapshort, &stcProcessInfo);
    }
    return dwFlag;
}

设置定时器:

SetTimer(1, 600, NULL);
SetTimer(2, 150, NULL);

WM_TIMER消息回调函数,定义一个RefreshControl用来刷新IDC_EXESTATUS的状态:

void CMineCrackDlg::OnTimer(UINT_PTR nIDEvent) {
    switch (nIDEvent) {
    case 1:
        if (!OnCheckProgramIsRunning()) {
            //若程序未运行,则修改ExeStatus
            CString cstr;
            GetDlgItem(IDC_EXESTATUS)->GetWindowText(cstr);
            if (cstr.Compare(L"Not Run") != 0) {
                GetDlgItem(IDC_EXESTATUS)->SetWindowText(L"Not Run");
            }
        }
        break;
    case 2:
        RefreshControl(IDC_EXESTATUS);
        break;
    }

    CDialog::OnTimer(nIDEvent);
}

void CMineCrackDlg::RefreshControl(UINT uCtlID) {
    CRect rc;
    GetDlgItem(uCtlID)->GetWindowRect(&rc);
    ScreenToClient(&rc);
    InvalidateRect(&rc);
}

按钮回调函数:

void CMineCrackDlg::OnBnClickedRunmine()
{   
    if (!OnCheckProgramIsRunning()) {
        if (!CreateProcess(abAppName, NULL, NULL, NULL,
            FALSE, 0, NULL, NULL, &si, &pi)) {
            OutputDebugString(L"Open winmine.exe Failed!");
            return;
        }
        GetDlgItem(IDC_EXESTATUS)->SetWindowText(L"Running!");
    }   
}

image20210710120027481.png

image20210710120052657.png

实现一键找出雷区

通过前面的分析了解到,这里的程序没有开启ASLR,并且x32dbg中的地址就是整个PE文件拉伸加载到内存之后的地址,那么就可以直接写死地址,进行取值:

image20210710122600422.png

DWORD dwMineNum = 0;
DWORD dwWidth = 0;
DWORD dwHeight = 0;
BYTE mine_area[] = { 0 };
DWORD dwBaseAddr = 0x1005330;

ReadProcessMemory(hProcess, (LPCVOID)dwBaseAddr, &dwMineNum, 4, NULL); // Read Mine Num
ReadProcessMemory(hProcess, (LPCVOID)(dwBaseAddr+4), &dwWidth, 4, NULL); // Read Width
ReadProcessMemory(hProcess, (LPCVOID)(dwBaseAddr+8), &dwHeight, 4, NULL); // Read Height
DWORD dwSize = 0x20 * (dwHeight + 2); //根据x32dbg中看到的内存分配,可以发现宽度分配是按最大的分配的,所以这里直接按最大的宽度进行分配,高度算上两边的墙即可,这里就可以适用所有模式下的内存分配了

PBYTE lpMineArea = (PBYTE)malloc(dwSize);

if (lpMineArea) {
    ReadProcessMemory(hProcess, (LPCVOID)dwMineAreaAddr, lpMineArea, dwSize, NULL);
} else {
    MessageBox(L"Memory Alloc Failed!", L"Error", 0);
    return;
}

BYTE bClear = 0x8E;

for (DWORD i = 0, n = dwMineNum; i < dwSize && n > 0; i++) {
    if (lpMineArea[i] == 0x8F) {
        WriteProcessMemory(hProcess, (LPVOID)(dwMineAreaAddr + i), &bClear, 1, NULL);
        n--;
    }
}

将雷区的标志更换成旗子之后显示是不会改变的,需要手动刷新:

// Refresh and Rewrite window 
// Need to get windows handle
HWND hMine = ::FindWindow(NULL, L"扫雷"); 
if (!hMine) {
    MessageBox(L"FUCK");
    return;
}

RECT rt;
::GetClientRect(hMine, &rt);
::InvalidateRect(hMine, &rt, TRUE);

完成:

image20210719000747684.png

image20210719000804044.png

未完待续。。。

ps:后面会补上关于Win32程序的完整运行过程、详细的消息机制分析以及一键完成该游戏

avatar