郁金香2021年游戏辅助技术初级班(中)
- MFC动态链接库与注入DLL
- 在目标进程分配内存写入代码
- 向目标进程注入代码加载DLL
- 029-分析角色对象的属性
- 外平栈的call计算参数数量
- C,C++编写代码读取对象属性值
- C,C++输入输出重定向
- C,C++定时器与主线程
- 定时器(微软文档)
- 代码实例
- 基址偏移分析、角色信息复习
MFC动态链接库与注入DLL
之前我们都是用远程线程的方式调用CALL,今天我们通过动态链接库以本地的方式调用CALL。
任何的动态链接库都是无法启动的程序,不能够单独运行,它只能依附于其他exe程序,动态链接库都是为其他的exe的独立的进程服务的,它相当于代码库的一个资源。
我们就是要通过这个动态链接库里面带一个窗口,然后给它注入到另外一个进程里面去,显示出窗口,并测试里面的CALL。
双击资源文件目录中的*.rc文件,就会切换到资源视图,并在资源视图中的名称为*.rc目录上鼠标右击弹出菜单,点击添加资源、选择Dialog选项,即可为我们动态链接库添加一个对话框资源:
为了能够显示出该窗口,我们首先要为该对话框窗口资源添加类:
显示窗口:
注意,编译程序之前记得把平台换成x86,因为那个测试用的代码注入器只能在x86平台下使用。
我们现在就要通过代码注入器把生成的.dll文件注入到正在运行的.exe进程里面去:
我们这个窗口注入进去后,就可以去调用call00、call01、call02,这个时候调用就非常方便了。
我们给窗口添加一个按钮,并在按钮的单击事件里添加调用call02这个CALL的代码:
在目标进程分配内存写入代码
写代码(控制台应用程序)把call02函数代码写入到目标进程里面去。
- 参数flAllocationType
其中的MEM_OMMIT是说用这种方式分配的内存可以被置换到我们硬盘上,也就是我们所说的页面内存。
- 参数flProtect
这个是我们内存的页面属性,其中的PAGE_EXECUTE_READWRITE就是说,分配的这块内存可读、可写、可执行。
我们可以看到在目标进程中分配的内存是从0x00250000开始的,我们用x64dbg来验证看看有没有这块内存:
这个时候能够看到,的确分配了一大块为0的内存空间。
接下来就要把我们的数据(代码)写到在目标进程里面分配的这块内存空间,怎么写呢?
先写一段裸汇编:
再把这段裸汇编写入到目标进程地址空间里面分配的内存空间:
向目标进程注入代码加载DLL
目标::
写代码(控制台应用程序)把用MFC写的DLL(26课的DLL的全路径)注入(写入)到目标进程(23课)里面去(通过CreateRemoteThread创建远程线程,调用LoadLibrary加载该DLL)。
- VirtualAllocEx
- CreateRemoteThread
- LoadLibraryA
LoadLibrary有两个作用:
1、加载DLL到当前进程
2、获取模块的基址(模块句柄)
有时候我们会碰到一些动态基址的游戏,那么我们就需要用到这个函数LoadLibrary来动态的定位它那个主模块的基址。
这就是我们要注入的DLL的全路径,我们写进来了。
029-分析角色对象的属性
外平栈的call计算参数数量
要进到该call内部,查看[ebp+8]是第一个参数,[ebp+C]是第二个参数,[ebp+10]是第三个参数。
上图最上方有push esi和push edi,是用来保存non-volatile非易失性寄存器的,后面有对应的pop edi和pop esi:
所以edi和esi不是附近84E0E0这个call的参数,这个call只有3个参数。
C,C++编写代码读取对象属性值
因为每个进程都有一个控制台,所以我们为了方便测试,可以通过AllocConsole
这个API打开控制台,方便输出。
如果测试当中,控制台显示不正常(不出结果),可以先通过FreeConsole0
这个API把它关闭,再重新打开试试看。
我们怎么来挂接这个主线程呢?
挂接主线程这是一个比较复杂的过程,方法有很多,现在我们能够快速用到的,可以用定时器的方法把它附加到游戏窗口所在的线程,因为游戏这个窗口界面就是运行在主线程的,所以我们可以用这种方法来挂接游戏主线程(后面的课还会讲其他挂接主线程的方法)。
我们先通过spy++取得游戏窗口的标题和类名:
如果这个游戏窗口句柄找到了,通过SetTimer这个方式我们就能够把这个主线代码给附加进去,当然以前我们用的是子类化(用SetWindowLong来进行子类化),或者是SetWindowsHookEx来挂消息钩子,还可以做一个内联的inline hook也可以,这些都可以挂到主线程,这里我们用简单的方式来挂主线程。
有了游戏窗口的标题和类名,我们就可以获得它的窗口句柄,然后安装定时器:
上图SetTimer的第2个参数是随便一个标识该定时器的ID值,第3个参数是每隔多长时间(毫秒级别)执行第4个参数所表示的函数,第4个参数这个函数有固定的一个格式,它实际上是定时器的一个回调:
主线代码中的KillTimer函数的执行,可以把安装的定时器1236给关闭掉,这样就可以实现每点击一次按钮(安装1236定时器)只执行一次(关闭定时器,同时执行主线代码)。
经测试我们发现没有返回值,我们修改一下代码,看看输出的当前线程ID是否是主线程,看它是否挂到了游戏的主线程上面,并通过添加MessageBeep(1);
这行代码,可以在x64dbg中方便定位到MessageBeep函数下面的这个CALL,以便调试:
从上图我们可以发现主线程是2CE4,说明挂上游戏的主线程了,但是为什么没有调用CALL成功呢?
我们ctrl+g转到MessageBeep函数,在开头下个断点:
执行到MessageBeep这里,双击返回地址返回到上一层,找到MessageBeep函数调用下面的那个CALL,看看给该CALL传的参数,以及返回的eax值是否正确:
我们发现传的参数有问题,传的是包含"player"这个字符串变量的地址,也就是多了一层,即应该传入70A57024,结果传的却是03A2FD80。
为了测试,我们把上图右下角压入堆栈的03A2FD80修改为70A57024,再看看CALL调用是否成功:
主线代码中的__asm语句块,不应该用lea指令取参数1的地址,而应该直接把参数1压栈即可:
因为主线代码所在的DLL已经在游戏的地址空间中了,所以我们不需要再通过API函数ReadProcessMemory(为了跨进程)来读进程数据,可以直接用指针来读(速度特别的快):
测试的时候我们发现控制台窗口经常没有输出,这可能是由于被游戏给重定向了(比方说被游戏重定向到某个文件了),之后我们再修改控制台窗口的控制代码,这里可以先把护甲值输出到MessageBoxA弹窗或者编辑框中,避免了控制台被游戏给重定向。
因为这个弹窗在主线程中,会卡主线程,它不是一下子就显示出来的,所以这个显示输出的方式我们还要另外想办法;
比方说输出调试信息的方式(结合DebugView工具查看调试信息):
我们把代码中的printf输出语句注释掉,避免被游戏重定位转到了其他地方,导致printf被卡住,调试信息输出不到DebugView。
但是我们发现,定时器挂接主线程这种方法不是太稳定,主线回调那里经常执行不进去,我们后面再优化解决这个问题。
C,C++输入输出重定向
我们可以控制printf的输出到屏幕、某个设备上,或者文件,这里我们要让它输出到控制台的屏幕上,避免被游戏给重定向到文件。
stdout, stdin, stderr的中文名字分别是标准输出,标准输入和标准错误
freopen( “CONIN$” , “r+t” , stdin); // 重定向 STDIN
freopen( “CONOUT$” , “w+t” , stdout); // 重定向STDOUT
CONOUT$表示我们控制台的屏幕,这里就是把标准的输出stdout恢复到屏幕CONOUT$,因为游戏可能也是用到这个freopen函数把我们标准的输出stdout定位到文件里面去了。
这句新增的代码是重新把我们的输出定位到标准输出里面,用另外的话表示,就是把printf这一类的函数输出到黑窗口屏幕上。
上图就是用来解决error C4996,通过定义这个_CRT_SECURE_NO_WARNINGS这个宏就可以继续使用不安全的函数freopen。
注入进去好像是卡住了,把代码注入器给卡住了,因为它在等着我们那个线程返回,但是由于我们这个动态链接库DLL在A031_MFC_DLL.cpp源文件中的InitInstance函数初始化这里就被卡住了:
但是我们的代码注入器一直在等待我们的代码执行到上图第65行return TRUE;
这里,但是64行这里的.DoModal
它是阻塞的,就卡在这里了,那像这种该怎么解决呢?
应该把这两句代码放到新的线程里面去执行:
MFC的函数必须要将AFX_MANAGE_STATE(AfxGetStaticModuleState());
这个宏添加到显示窗口函数的最前面,也就是说我们在DLL中用到了MFC的窗口,那就需要把这行代码放到函数中的第一行,这个宏会做一些资源的初始化工作,我们后面在显示窗口的时候,它才能够查找到相应的资源,才能够正常的显示窗口。
如果编写的是DLL程序的话,你会发现在DLL中调用AfxGetApp这个函数会得到DLL的应用对象。原因出现在DLL的模块状态上。应用程序在调用DLL时为了保证资源不出问题,往往会调用一句:
AFX_MANAGE_STATE(AfxGetStaticModuleState());
//switch thread state back to dll
注意这是一个宏。它的作用是切换模块的全局变量范围,即把应用程序的那些全局变量拷贝切换到这个DLL的全局变量拷贝,自然用AfxGetApp得到的就是DLL里面的这个APP了。如果想用AfxGetApp这个函数访问应用程序的App对象,那么只要把模块状态切换回去就可以了,记着执行完后一定要把状态再切换回来啊,否则就要出问题了。
例如:
// switch thread state back to application
_AFX_THREAD_STATE* pState = AfxGetThreadState();
AfxSetModuleState(pState->m_pPrevModuleState);
// do something with the application
AfxGetApp()->...
// switch thread state back to dll
AFX_MANAGE_STATE(AfxGetStaticModuleState())
参考链接:https://blog.csdn.net/tianmeshi/article/details/4209904
这里通过CreateRemoteThread或者CreateThread创建独立的线程之后,它就不会等待代码执行到64行那里(就不会阻塞了),它会直接执行74行返回,所以就不会卡代码注入器了。
因为它到时候只会在新线程里面执行到并卡在63行这里,我们代码注入器只是等待这个InitInstance函数初始化完成,即执行CreateRemoteThread这句代码后,直接执行74行return TRUE;
返回就OK了,通过CreateRemoteThread所创建的这个独立的新线程执行没执行完,就跟InitInstance的执行就没关系了(执行InitInstance函数的线程和执行显示窗口的线程不同)。
这时候读取护甲值就非常顺畅了,随便改变装备也能迅速把最新的护甲值给读取出来。
通过控制台重定向和创建新线程解决界面阻塞,就解决了上节课控制台输出不稳定的问题。
我们也可以把读取的值写到C盘123.txt文件里面:
还有一个问题,我们每次注入DLL之后,都要通过上图这个代码注入器这里卸载注入的DLL,每次都要手动的这样做也是很麻烦的,我们可以添加一句代码,在窗口关闭的时候,我们就用代码来卸载掉这个动态链接库DLL:
只要注入的窗口一直在显示的话,63行之后的代码就不会被执行(阻塞在63行.DoModal()函数这里),直到这个窗口关闭之后才会执行到后面的代码,卸载掉这个DLL并退出这个线程。
这里我们又发现了一个问题,在我们点击读取护甲值的按钮之后,写入到了C盘123.txt文件里面,但是我们在C盘看到123.txt文件的大小是0KB,这是由于缓存的原因导致数据没有立即写入到文件,我们可以通过fclose
函数关闭一下控制台即可立即写入文件,如下修改代码:
这种立即写入文件的方式要修改很多代码,这里我们还是先用简单的输出到控制台的方式吧(把上面2张图修改的代码注释掉)。
C,C++定时器与主线程
定时器,顾名思义就是时钟的意思,也就是说它每过多长时间就去执行一下相应的代码。
定时器(微软文档)
A timer is an internal routine that repeatedly measures a specified interval, in milliseconds.
定时器是一个内部例程,用于重复测量指定的时间间隔(以毫秒为单位)。
微软参考链接:SetTimer function
-
Parameters
-
[in, optional] hWnd
Type: HWND
A handle to the window to be associated with the timer. This window must be owned by the calling thread. If a NULL value for hWnd is passed in along with an nIDEvent of an existing timer, that timer will be replaced in the same way that an existing non-NULL hWnd timer will be.
与计时器关联的窗口句柄。该窗口必须为调用线程所有。如果将 hWnd 的 NULL 值与现有定时器的 nIDEvent 一起传入,则该定时器将以与现有的非 NULL hWnd 定时器一样被替换。 -
[in] nIDEvent
Type: UINT_PTR
A nonzero timer identifier. If the hWnd parameter is NULL, and the nIDEvent does not match an existing timer then it is ignored and a new timer ID is generated. If the hWnd parameter is not NULL and the window specified by hWnd already has a timer with the value nIDEvent, then the existing timer is replaced by the new timer. When SetTimer replaces a timer, the timer is reset. Therefore, a message will be sent after the current time-out value elapses, but the previously set time-out value is ignored. If the call is not intended to replace an existing timer, nIDEvent should be 0 if the hWnd is NULL.
一个非零的定时器标识符。如果 hWnd 参数为 NULL,且 nIDEvent 与现有定时器不匹配,则该nIDEvent参数会被忽略并生成一个新的定时器标识符。如果 hWnd 参数不为 NULL,且 hWnd 指定的窗口已有一个值为 nIDEvent 的定时器,则现有定时器将被新定时器取代。当SetTimer 替换定时器时,定时器将被重置。因此,在当前超时值uElapse结束后,将发送一条信息,但之前设置的超时值将被忽略。如果调用的目的不是替换现有的定时器,则如果 hWnd 为 NULL,则 nIDEvent 应为 0。 -
[in] uElapse
Type: UINT
The time-out value, in milliseconds.
超时值,以毫秒为单位。 -
[in, optional] lpTimerFunc
Type: TIMERPROC
A pointer to the function to be notified when the time-out value elapses. For more information about the function, see TimerProc. If lpTimerFunc is NULL, the system posts a WM_TIMER message to the application queue. The hwnd member of the message’s MSG structure contains the value of the hWnd parameter.
超时值(uElapse)过期时要通知的函数指针。有关函数的更多信息,请参阅 TimerProc。 如果 lpTimerFunc 为 NULL,系统将向应用程序队列发送一条 WM_TIMER 消息。消息 MSG 结构的 hwnd 成员包含 hWnd 参数的值。
The timer identifier, nIDEvent, is specific to the associated window. Another window can have its own timer which has the same identifier as a timer owned by another window. The timers are distinct.
计时器标识符nIDEvent是特定于关联的窗口。另一个窗口可以拥有自己的计时器,该计时器具有与另一个窗口拥有的计时器相同的标识符。计时器是不同的。
也就是说不同窗口的定时器标识符可以相同,它们是通过各自关联的窗口句柄来区分(定时器标识符是各窗口私有的)。
-
-
Return value
- Type: UINT_PTR
If the function succeeds and the hWnd parameter is NULL, the return value is an integer identifying the new timer. An application can pass this value to the KillTimer function to destroy the timer.
如果函数执行成功,且 hWnd 参数为 NULL,则返回值是一个标识新定时器的整数。应用程序可将此值传递给 KillTimer 函数,以销毁定时器。
If the function succeeds and the hWnd parameter is not NULL, then the return value is a nonzero integer. An application can pass the value of the nIDEvent parameter to the KillTimer function to destroy the timer.
如果函数执行成功,且 hWnd 参数不是 NULL,那么返回值就是一个非零整数。应用程序可将 nIDEvent 参数的值传递给 KillTimer 函数,以销毁定时器。
If the function fails to create a timer, the return value is zero. To get extended error information, call GetLastError.
如果函数未能创建定时器,则返回值为零。要获取扩展错误信息,请调用 GetLastError。
- Type: UINT_PTR
-
Remarks
An application can process WM_TIMER messages by including a WM_TIMER case statement in the window procedure or by specifying a TimerProc callback function when creating the timer. When you specify a TimerProc callback function, the DispatchMessage calls the callback function instead of calling the window procedure when it processes WM_TIMER with a non-NULL lParam. Therefore, you need to dispatch messages in the calling thread, even when you use TimerProc instead of processing WM_TIMER.
应用程序可以通过在窗口过程中包含WM_TIMER case语句或在创建计时器时指定TimerProc回调函数来处理WM_TIMER消息。当您指定TimerProc回调函数时,DispatchMessage会在使用非空lParam处理WM_TIMER时调用回调函数,而不是调用窗口过程。因此,您需要在调用线程中调度消息,即使您使用TimerProc而不是处理WM_TIMER。
The wParam parameter of the WM_TIMER message contains the value of the nIDEvent parameter.
WM_TIMER消息的wParam参数包含nIDEvent参数的值。
The timer identifier, nIDEvent, is specific to the associated window. Another window can have its own timer which has the same identifier as a timer owned by another window. The timers are distinct.
定时器标识符 nIDEvent 是特定于相关窗口的。另一个窗口可以拥有自己的定时器,其标识符与另一个窗口拥有的定时器相同。这些定时器是不同的。
SetTimer can reuse timer IDs in the case where hWnd is NULL.
SetTimer可以在hWnd为NULL的情况下重用定时器id。
If your application creates a timer without specifying a window handle, your application must monitor the message queue for WM_TIMER messages and dispatch them to the appropriate window.
如果你的应用程序在创建定时器时没有指定窗口句柄,则你的应用程序必须监控消息队列中的 WM_TIMER 消息,并将其分派到相应的窗口。 -
Timer Notifications
- WM_TIMER
Posted to the installing thread’s message queue when a timer expires. The message is posted by the GetMessage or PeekMessage function.
当计时器到期时,发布到安装线程的消息队列中。该消息由GetMessage或PeekMessage函数发布。
- WM_TIMER
// MyTimerProc is an application-defined callback function that processes WM_TIMER messages.
VOID CALLBACK MyTimerProc(
HWND hwnd, // handle to window for timer messages
UINT message, // WM_TIMER message
UINT idTimer, // timer identifier
DWORD dwTime) // current system time
代码实例
这里我们添加一个按钮,用来创建一个定时器,并把定时器挂在按钮所在的这里窗口上,因为定时器都要求有一个窗口来接收定时器的消息:
第141行的代码每过1秒钟就执行一次,这就是定时器的作用。
我们修改代码,让这个定时执行的函数每按一次按钮才执行一次,同时在该函数里面打印一下当前线程的ID,以便和执行主线代码的线程ID进行比较:
你也可以修改printf那句代码,把m_hWnd和hWnd都各自打印一下看看。
这是我们创建的线程(线程ID为25DC),而打印的主线代码的线程ID其实是游戏主线程的线程ID(20D4):
我们可以看到主线程的ID为20D4。
上图这个线程25DC是我们自己注入的,我们可以到该线程的入口地址那里看看:
从上图可以看到,显示窗口函数正是我们自己创建的线程的线程函数。
如果我们的代码(创建定时器)传入的不是游戏的窗口句柄,那么肯定就不能挂到游戏的主线程上面,那么我们的代码就不能正常的跑起来。
有的CALL需要挂主线程,有的CALL不需要的,这要看它的CALL里面有没有在多个线程里面相互的传递数据,大部分游戏都是需要挂接主线程才能正常的跑。
基址偏移分析、角色信息复习
在CE中也可以查看当前函数的堆栈视图:
怎么快速找到当前函数的上一层CALL,方法有很多,比如下断、然后在堆栈中return返回地址(最保险),为准确起见,要下条件断点;或者执行到返回,但是容易跑崩、跟飞(尤其游戏有保护的情况下);
或者如下图所示,搜索当前模块、常数,输入该函数地址(找下图这个函数72CC30的上一层),会搜到CALL该函数地址的位置:
我们在堆栈窗口中可以看到黑色的竖线条,每一段线条叫做栈帧,每个栈帧代表一个CALL的环境。
我们在上图堆栈窗口选中的那行返回地址处双击,反汇编窗口就会定位到49545B这条返回指令上,即该指令上方的CALL wow.494F30
指令就是这个返回地址的CALL,堆栈窗口中3BDFE04到3BDFDE8这段栈帧就是wow.494F30
这个CALL的栈帧(下图选中的部分):
我们之前的课讲过,在下图左下角的内存窗口(数据窗口)2AA8883C那里下一个硬件访问断点(1字节或者4字节),当打开角色信息面板就会断在反汇编窗口4F54E1那条指令,它上面4F54DA地址处的指令mov esi, dword ptr ds:[eax+edx*4+174]
就访问了2AA8883C地址处的内容,该指令上面有3条push指令,所以下图右下角堆栈窗口那里选中的有红字的那行就是返回地址,即上一层CALL的位置:
如上图选中的那条指令所示,我们要往上找esi的来源:
从上图我们看到,eax里面放的就是那个"player"字符串,然后按F8执行到00610F01这里的时候:
如上图所示,点击一下内存窗口,然后按ctrl+G,输入将在内存窗口中转到的表达式...
里面的公式计算出来的值正好就是护甲值的地址2AA8883C。
那个游戏的控制台如果你直接关闭的话,wow游戏也会跟着退出,所以要用FreeConsole();
来关闭游戏的控制台。