《WINDOWS 环境下32位汇编语言程序设计》第13章 过程控制(2)
13.3 进程调试
在DOS操作系统下,一个程序可以读写系统中的所有内存,所以可以方便地修改任何地方的代码和数据,不管这些代码和数据是不是自己所有的,另外,程序可以自由存取所有的寄存器,自由设置所有的中断,程序可以通过设置单步中断或断点中断来跟踪代码的执行。这些功能可以归结为对一个进程进行调试。
在Windows操作系统中,不同进程之间的地址空间是隔离的,要用指令直接存取其他进程地址空间中的代码和数据是不可能的,用户程序也没有权限去截获中断,通常情况下,甚至连在自己的代码段中写数据都是不合法的,那么在Windows中还可以实现类似DOS中的调试功能吗?答案是肯定的,但必须通过专用的API函数来完成,本节要讨论的就是这方面的内容。
13.3.1 获取运行中的进程句柄
要对进程进行某种操作,就必须首先知道该进程的进程句柄或者进程ID,否则一切无从谈起,对于程序自己创建的子进程来说,CreateProcess函数返回了进程句柄和进程ID,但如果需要调试系统中已经运行的进程,那就必须首先获取它们的句柄才行。
Win32中并没有直接获取其他进程句柄的函数,但如果知道进程ID的话,却可以由此得到进程句柄,所以可以首先通过某种途径获取进程ID。
1.从窗口句柄获取进程句柄获
取进程ID的方法之一是使用GetWindowThreadProcessId函数,这个函数可以从一个窗口句柄获得创建该窗口的进程的进程ID,而通过FindWindow函数得到窗口句柄是很简单的,所以GetWindowThreadProcessId函数的用途相当广泛。该函数的用法是:
invoke GetWindowThreadProcessId,hWnd,lpdwProcessId
其中hWnd参数指定需要用来获取进程ID的窗口句柄,lpdwProcessId指向一个双字变量,函数在这里返回创建窗口的进程ID,函数的返回值是目标进程中创建该窗口的线程的线程ID(一个有用的副产品!)。
得到了进程ID以后,就可以通过OpenProcess函数来获取该进程的句柄了:
invoke OpenProcess,dwDesiredAccess,bInheritHandle,dwProcessId
.if eaxmov hProcess,eax
.endif
函数的参数定义如下。
● dwDesiredAccess——指定需要对该进程进行的操作,要对目标进程进行某种操作,必须指定操作代码,但是在Windows NT操作系统中,对其他进程操作需要有相应的权限,如需要结束目标进程就必须有PROCESS_TERMINATE权限才行,当权限不够的时候,打开进程的操作就会失败。一般来说,除了系统进程以外,可以对其他进程进行任何操作,操作码可以是以下取值的组合:
■ PROCESS_ALL_ACCESS——等于下面全部操作码的组合。
■ PROCESS_CREATE_THREAD——允许创建远程线程。
■ PROCESS_DUP_HANDLE——允许进程句柄被复制。
■ PROCESS_QUERY_INFORMATION——允许使用GetExitCodeProcess函数查询进程的退出码或使用GetPriorityClass函数查询进程的优先级。
■ PROCESS_SET_INFORMATION——允许使用SetPriorityClass函数设置进程的优先级。
■ PROCESS_TERMINATE——允许终止进程。
■ PROCESS_VM_OPERATION —— 允许使用WriteProcessMemory函 数 或VirtualProtectEx函数修改进程的地址空间。
■ PROCESS_VM_READ——允许对进程的地址空间进行读操作。
■ PROCESS_VM_WRITE——允许对进程的地址空间进行写操作。
● bInheritHandle——指明返回的进程句柄是否可以被当前进程的子进程继承,如果参数指定为TRUE,则句柄可以被继承。
● dwProcessId——指定目标进程的进程ID。
如果函数执行成功,返回值是被打开的进程句柄。如果函数执行失败则返回NULL。一般打开失败的原因是由权限不够引起的。当完成对目标进程的操作以后,必须使用CloseHandle将获得的句柄关闭。
2.从快照函数获取进程句柄
使用GetWindowThreadProcessId获取进程ID的先决条件是进程必须创建了窗口,对于在后台运行的没有窗口的进程该如何处理呢?这就要通过枚举系统中运行的进程来解决了,这个功能可以由CreateToolhelp32Snapshot函数来实现。
通过CreateToolhelp32Snapshot函数可以获得一个进程的列表,可以从列表中得到进程的ID、进程对应的可执行文件名和创建该进程的父进程ID等数据,这个函数支持Windows 9x系列和Windows 2000及以上的系统,不支持Windows NT 4.0(幸好使用NT 4.0的机会已经不多了)。
CreateToolhelp32Snapshot函数的名称比较奇怪,“Snapshot”是快照的意思,难道函数和拍快速成像照片有某种联系吗?没有联系,“快照”只是函数执行方式的一种形象的比喻罢了。就像自然界中的生命循环一样,系统中的进程也是生生不息的,随时都可能有进程被结束,也随时会有新的进程诞生。“快照”保留了函数被调用时的进程列表,在以后读取“快照”数据的过程中如果有进程创建或结束,就不会影响“快照”中的列表,就好比我们照了一张照片后走人,照片还是可以留下来慢慢地看。
所附光盘的Chapter13\ProcessList目录中的例子演示了快照函数的使用方法,程序显示了如图13.4所示的列表框,用户可以选择列表框中的某个进程,并且使用“终止”按钮将它结束。左图是程序在Windows 2000下的运行结果,右图是程序在Windows 98下运行的结果,Windows 2000下的文件名是不带路径的,可见,函数在不同操作系统下得到的可执行文件名的表现方式稍微有些不同。
图13.4 “快照”例子的运行界面
目录中的ProcessList.rc文件定义了如图13.4所示的对话框。
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#include <resource.h>
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#define ICO_MAIN 1000
#define DLG_MAIN 1000
#define IDC_PROCESS 1001
#define IDC_REFRESH 1002
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ICO_MAIN ICON "Main.ico"
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN DIALOG 76, 95, 190, 108
STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "进程列表"
FONT 9, "宋体"
{
LISTBOX IDC_PROCESS, 8, 5, 173, 86, LBS_STANDARD | LBS_SORT
PUSHBUTTON "刷新(&R)", IDC_REFRESH, 87, 90, 45, 14
DEFPUSHBUTTON "终止(&T)", IDOK, 137, 90, 45, 14, BS_DEFPUSHBUTTON
| WS_DISABLED | WS_TABSTOP
}
汇编源文件ProcessList.asm的内容如下:
; ProcessList.asm -------------- 列出系统中当前运行的进程
;---------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff ProcessList.asm
; rc ProcessList.rc
; Link /subsystem:windows ProcessList.obj ProcessList.res
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
includelib c:/masm32/lib/user32.lib
;include c:/masm32/include/kernel32.inc
;includelib c:/masm32/lib/kernel32.lib
include kernel32.inc
includelib kernel32.lib ; equ 等值定义
ICO_MAIN equ 1000
DLG_MAIN equ 1000
IDC_PROCESS equ 1001
IDC_REFRESH equ 1002 ; 数据段
.data?
hInstance dword ?
hWinList dword ?.const
szErrTerminate byte '无法结束指定进程!',0; 代码段
.code
_GetProcessList proc _hWnd local @stProcess:PROCESSENTRY32 local @hSnapShot invoke RtlZeroMemory, addr @stProcess, sizeof @stProcess invoke SendMessage, hWinList, LB_RESETCONTENT, 0, 0 mov @stProcess.dwSize, sizeof @stProcess invoke CreateToolhelp32Snapshot, TH32CS_SNAPPROCESS, 0mov @hSnapShot, eax invoke Process32First, @hSnapShot, addr @stProcess .while eax invoke SendMessage, hWinList, LB_ADDSTRING, 0, addr @stProcess.szExeFile invoke SendMessage, hWinList, LB_SETITEMDATA, eax, @stProcess.th32ProcessID invoke Process32Next, @hSnapShot, addr @stProcess .endw invoke CloseHandle, @hSnapShot invoke GetDlgItem, _hWnd, IDOK invoke EnableWindow, eax, FALSE ret
_GetProcessList endp _ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam mov eax, wMsg .if eax == WM_CLOSE invoke EndDialog, hWnd, NULL .elseif eax == WM_INITDIALOG invoke GetDlgItem, hWnd, IDC_PROCESS mov hWinList, eax invoke _GetProcessList, hWnd .elseif eax == WM_COMMAND mov eax, wParam .if ax == IDOK invoke SendMessage, hWinList, LB_GETCURSEL, 0, 0 invoke SendMessage, hWinList, LB_GETITEMDATA, eax, 0 invoke OpenProcess, PROCESS_TERMINATE, FALSE, eax .if eax mov ebx, eax invoke TerminateProcess, ebx, -1 invoke CloseHandle, ebx invoke Sleep, 200 invoke _GetProcessList, hWnd jmp @F ;跳转到下面最近的@@标号.endif invoke MessageBox, hWnd, addr szErrTerminate, NULL, MB_OK or MB_ICONWARNING @@: ;空.elseif ax == IDC_REFRESH invoke _GetProcessList, hWnd .elseif ax == IDC_PROCESS shr eax, 16 .if ax == LBN_SELCHANGE invoke GetDlgItem, hWnd, IDOK invoke EnableWindow, eax, TRUE .endif .endif .else mov eax, FALSE ret .endif mov eax, TRUE ret
_ProcDlgMain endp ;main函数
main proc invoke GetModuleHandle, NULL mov hInstance, eax invoke DialogBoxParam, hInstance, DLG_MAIN, NULL, offset _ProcDlgMain, NULL invoke ExitProcess, 0
main endp
end main
运行结果:
程序在初始化、按下“刷新”按钮,以及结束一个线程以后都要调用_GetProcessList子程序来重新获取系统中运行的进程列表。在_GetProcessList子程序中,程序首先向列表框发送LB_RESETCONTENT消息删除原来的列表内容,并调用CreateToolhelp32Snapshot函数获得一个快照。函数的使用格式是:
invoke CreateToolhelp32Snapshot,dwFlags,th32ProcessID
.if eaxmov hSnapShot,eax
.endif
dwFlags参数用来指定“快照”中需要返回的对象,本函数不仅可以获取进程列表,也可以用来获取线程和模块等对象的列表,参数可以指定的值是:
● TH32CS_SNAPHEAPLIST——对指定进程中的堆进行枚举。
● TH32CS_SNAPMODULE——对指定进程中的模块进行枚举。
● TH32CS_SNAPPROCESS——对系统范围中的进程进行枚举。
● TH32CS_SNAPTHREAD——对系统范围中的线程进行枚举。
th32ProcessID参数用来指定一个进程ID,当dwFlags指定为TH32CS_SNAPHEAPLIST或者TH32CS_SNAPMODULE来枚举某个进程中的堆,以及模块的时候,这个参数用来指定被枚举的进程ID。对于TH32CS_SNAPPROCESS和TH32CS_SNAPTHREAD标志,由于枚举的范围是系统范围内的,所以th32ProcessID参数将被忽略。
如果函数执行成功,将返回一个快照句柄,否则返回−1。程序可以通过这个快照句柄获取进程列表。
从快照句柄中获取进程参数使用Process32First和Process32Next函数,函数的每次调用仅返回一个进程的信息。Process32First函数用来进行首次调用,以后的调用由Process32Next函数循环完成,直到所有的进程信息都被获取为止,当不再有剩余信息的时候,函数返回FALSE,所以一般使用下面的循环结构来获取进程列表:
.data?
stProcess PROCESSENTRY32 <?>
hSnapShot dd ?
.code
invoke CreateToolhelp32Snapshot,TH32CS_SNAPPROCESS,0
mov hSnapShot,eax
mov stProcess.dwSize,sizeof stProcess
invoke Process32First,hSnapShot,addr stProcess
.while eax;在这里处理返回到PROCESSENTRY32中的进程信息invoke Process32Next,hSnapShot,addr stProcess
.endw
invoke CloseHandle,hSnapShot
Process32First和Process32Next函数的第一个参数是前面得到的快照句柄,第二个参数指向一个PROCESSENTRY32结构,进程信息将被返回到这个结构中。结构的定义如下:
PROCESSENTRY32 STRUCTdwSize DWORD ? ;结构的长度,必须预先设置cntUsage DWORD ? ;进程的引用计数th32ProcessID DWORD ? ;进程IDth32DefaultHeapID DWORD ? ;进程默认堆的IDth32ModuleID DWORD ? ;进程模块的IDcntThreads DWORD ? ;被进程创建的线程数th32ParentProcessID DWORD ? ;进程的父进程IDpcPriClassBase DWORD ? ;被进程创建的线程的基本优先级dwFlags DWORD ? ;内部使用szExeFile db MAX_PATH dup(?) ;进程对应的可执行文件名
PROCESSENTRY32 ENDS
注意:在使用前需要先将dwSize填写为结构的长度,否则函数的执行会失败,在返回所有的进程信息后,需要使用CloseHandle函数将快照句柄关闭。
结构中返回的进程ID字段(th32ProcessID)和可执行文件名字段(szExeFile)是我们最关心的。通过比较文件名,就可以找出需要寻找的可执行文件产生的进程,然后通过进程ID就可以用OpenProcess函数获得进程句柄,有了进程句柄以后就可以对进程进行各种操作了。
在例子中,每当在PROCESSENTRY32结构中返回了一个进程的信息后,程序向列表框发送LB_ADDSTRING消息将可执行文件名添加到列表框中。由于列表框能够为每个项目定义一个32位的自定义数据,利用这个特征可将文件对应的进程ID保存到这里,方法就是向列表框发送LB_SETITEMDATA消息,这样在按下“终止”按钮以后,程序就可以通过LB_GETITEMDATA消息取回进程ID,使用OpenProcess函数获得该进程的句柄以后,再使用TerminateProcess函数将进程终止。
当例子程序在Windows 2000中运行时,如果试图打开系统底层的进程(如System或者[System Process]等进程)是不会成功的,因为用户程序并没有这么高的权限。
在详细介绍了枚举系统中所有进程的方法后,下面再附带介绍一下枚举堆、线程和模块的方法,换句话说,就是CreateToolhelp32Snapshot函数中使用了TH32CS_SNAPHEAPLIST、TH32CS_SNAPMODULE、TH32CS_SNAPTHREAD参数后,如何再得到堆、线程和模块列表的问题。
难道是同样使用Process32First和Process32Next函数构成一个循环吗?答案是否定的,实际上,循环的结构是同样的,但构成循环的函数要分别换成Heap32First和Heap32Next,Thread32First和Thread32Next,另外还有Module32First和Module32Next。用来返回数据的结构也是不同的,它们分别是HEAPENTRY32、THREADENTRY32和MODULEENTRY32。关于这些函数和结构的资料,读者可以自己从MSDN中查看相关的资料。
13.3.2 读写进程的地址空间
1.进程地址空间的读写函数
当一个进程能够被我们以足够的权限打开以后,就可以通过ReadProcessMemory和WriteProcessMemory函数读写它的地址空间。只要能够对其他进程的地址空间进行读写,那么我们能够做的事情就多了,只要发挥想象力,就能够编出一些超乎想象的程序来。在广为流传的应用程序中,最为著名的就是FPE之类的游戏修改器。
FPE是Fix People Expert的缩写,不过这个软件可不是用来修理人(Fix People)而是用来对付游戏的。FPE程序列出当前系统中运行的进程,让用户选择要对付的游戏程序名(现在读者可以骄傲地说,这一招我也会,13.3.1节中的进程列表例子不就是这样吗),然后让用户输入一个数值,比如,现在游戏主角还剩下3条命就输入3,FPE将扫描游戏进程的所有地址空间,将当前数据为3的地址列入黑名单,接下来继续游戏,当又少了一条命的时候,再次输入2并扫描,如果上次黑名单中某个地址中的数据现在变成了2,代表生命的数据十有八九就存放在这个地址中,将它改成100的话,主角就长命百岁了!同样道理想让主角变成千千岁,万万岁也不在话下!另外,如果找到代表生命的地址,让FPE锁定(就是每隔很短的时间将要锁定的数值重新写到这个地址中)数值,游戏主角就是金刚不坏之躯了。
FPE是通用的修改软件,另一类专用的游戏修改器也使用同样的技术,比如,打《暗黑破坏神》游戏的时候,很多人用过增加经验点数的修改器,因为这个游戏对数据经过了某种处理,用FPE一类的软件无法直接将要修改的数据搜索出来,有人就通过跟踪找到了变换后的数据地址和变换算法,并专门写了针对这个游戏的进程内存读写程序。
当然,经过本节介绍以后,读者就会觉得这些软件使用的技术并没有那么神秘,我们自己也可以写出同样的程序来。
首先来介绍一下这两个函数的用法:
invoke ReadProcessMemory,hProcess,lpBaseAddress,lpBuffer,\dwSize,lpNumberOfBytesRead (读进程内存)
invoke WriteProcessMemory,hProcess,lpBaseAddress,lpBuffer,\dwSize,lpNumberOfBytesWritten (写进程内存)
这两个函数的参数定义是一样的,各参数的定义如下:
● hProcess——指定将要被读写的目标进程句柄。
● lpBaseAddress——目标进程中被读写的起始线性地址。
● lpBuffer——用来接收读取数据的缓冲区(对于ReadProcessMemory函数)或者要写到目标进程的数据缓冲区(对于WriteProcessMemory函数)。
● dwSize——要读写的字节数。
● lpNumberOfBytesRead或lpNumberOfBytesWritten——指向一个双字变量,用来供函数返回实际读写的字节数,如果不关心读写的结果,可以在这里使用NULL。
如果函数执行成功,那么返回值是非0值,执行失败的话返回0。
使用这两个函数需要注意的地方有:
● 注意lpBaseAddress和lpBuffer参数指向的地址位于不同的进程空间内,lpBuffer指向的缓冲区位于本进程的地址空间内,而lpBaseAddress指向的地址位于目标进程的地址空间内。
● 要对目标进程进行读写的话,打开(或者创建)目标进程的时候必须包含对应的权限,如要读取目标进程必须包括PROCESS_VM_READ权限;要对目标进程进行写操作的时候,必须包括PROCESS_VM_OPERATION或者PROCESS_VM_WRITE权限。
● lpBaseAddress位置开始的dwSize大小的内存必须是可存取的,函数在执行前会对整个区域进行测试,如果中间有某处是不可存取的(如没有被递交到物理内存),那么函数直接返回失败,所以一般不会出现只读写了一部分内存的情况。
● 虽然在自己的进程中,代码段是不可写的,但是使用WriteProcessMemory函数去写目标进程的代码段却是允许的。
FPE等软件就是使用这两个函数来读写目标进程的数据的,但由于这些函数也可以用来读写目标进程的代码部分,所以也有一些程序使用它们来做内存补丁,下面介绍的一个例子演示了如何利用该函数来修改代码。
2.一个内存补丁例子
与这个例子相关的文件放在所附光盘的Chapter13\Patch1目录中,其中Test.asm是将要被修改的目标程序,这个程序中只有几句代码:
; Test.asm ---------- 被用来打内存补丁的测试程序
;---------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Test.asm
; Link /subsystem:windows Test.obj
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
includelib c:/masm32/lib/user32.lib
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/kernel32.lib ; 数据段
.const
szErr byte '对不起,你使用的是盗版软件!',0
szOK byte '感谢您使用正版软件!',0
szCaption byte '谢谢',0; 代码段
.code
main proc xor eax, eax .if eax invoke MessageBox, NULL, addr szOK, addr szCaption, MB_OK .else invoke MessageBox, NULL, addr szErr, NULL, MB_OK or MB_ICONSTOP .endif invoke ExitProcess, 0
main endp
end main
在程序中模拟测试序列号的过程,当eax返回0的时候,显示“对不起,你使用的是盗版软件”,否则显示“感谢您使用正版软件”,为了简化程序,程序用了一句xor eax,eax指令使eax永远为0,所以显示的总是“盗版软件……”。我们的目标就是编写一个内存补丁程序,让它去调用Test.exe并修改测试代码,这样就可以跳过“反盗版”测试。
要对某个软件进行内存补丁,就必须首先对它进行分析,这样才能知道该往目标进程的什么地方写入什么数据。这方面的工作属于软件跟踪的课题,在本书中暂不涉及,有兴趣的读者可以另外研究。对于上面这个简单的Test.exe文件,采用静态分析的方法,用W32Dasm将它反汇编,就可以得到下面的代码(在实际的使用中,就是仁者见仁、智者见智了,读者可以用自己擅长的方法分析目标文件):
:00401000 33C0 xor eax, eax
:00401002 0BC0 or eax, eax ;.if eax
:00401004 7415 je 0040101B ;<---- 修改这条指令
:00401006 6A00 push 00000000
:00401008 6840204000 push 00402040
:0040100D 682C204000 push 0040202C
:00401012 6A00 push 00000000
:00401014 E819000000 call 00401032 ;“正版”MessageBox
:00401019 EB10 jmp 0040102B
:0040101B 6A10 push 00000010 ;.else
:0040101D 6A00 push 00000000
:0040101F 6810204000 push 00402010
:00401024 6A00 push 00000000
:00401026 E807000000 call 00401032 ;MessageBox
分析这段代码就可以发现,只要将00401004h地址的je指令略过就可以让程序执行“正版”逻辑,在内存补丁程序中可以使用两个nop指令代替je指令,nop指令的机器码为90h,为单字节指令,而je指令有两个字节,所以需要两个nop指令。
读者可能还有一个问题:反汇编的时候显示地址是00401004h,难道执行的时候实际装入的地址也是这个吗?对于exe程序来说,这是肯定的,因为不同进程的地址空间是隔离的,不会有其他东西占用这部分地址;对于DLL来说就不一定了,因为一个exe文件可能装入多个DLL,当两个DLL的默认装入地址相同时,有一个肯定会被重新定位到其他地方,所以对DLL进行静态反汇编得到的地址不一定是正确的,在这种情况下可以扫描目标进程的整个空间来找到DLL的实际装入位置(这方面的知识请参考第17章:PE文件)。
分析目标代码得到要补丁的地址和该地址处的新旧机器码以后,就可以写出下面的补丁程序了,程序的汇编源文件为Patch1.asm:
; Patch1.asm ---------- 内存补丁例子一:对 Test.exe 进行内存补丁
;---------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Patch1.asm
; rc Patch1.rc
; Link /subsystem:windows Patch1.obj Patch1.res
.386
.model flat, stdcall
option casemap:none ;区分大小写; include
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib
;-------------------------------------------------------------------
PATCH_POSITION equ 00401004h
PATCH_BYTES equ 2; 数据段
.data?
dbO1dBytes byte PATCH_BYTES dup (?)
stStartUp STARTUPINFO <?>
stProcInfo PROCESS_INFORMATION <?>.const
dbPatch byte 74h, 15h
dbPatched byte 90h, 90h ;nop指令的机器码
szExecFileName byte 'Test.exe',0
szErrExec byte '无法装载执行文件!',0
szErrVersion byte '执行文件的版本不正确,无法修正!',0; 代码段
.code
main proc ;创建进程invoke GetStartupInfo, addr stStartUp invoke CreateProcess, offset szExecFileName, NULL, NULL, NULL, NULL, \NORMAL_PRIORITY_CLASS or CREATE_SUSPENDED, NULL, NULL, \offset stStartUp, offset stProcInfo .if eax ;读进程内存并验证内容是否正确invoke ReadProcessMemory, stProcInfo.hProcess, PATCH_POSITION, \addr dbO1dBytes, PATCH_BYTES, NULL .if eax mov ax, word ptr dbO1dBytes .if ax == word ptr dbPatch invoke WriteProcessMemory, stProcInfo.hProcess, \PATCH_POSITION, addr dbPatched, PATCH_BYTES, NULL invoke ResumeThread, stProcInfo.hThread .else invoke TerminateProcess, stProcInfo.hProcess, -1 invoke MessageBox, NULL, addr szErrVersion, NULL, MB_OK or MB_ICONSTOP .endif .endif invoke CloseHandle, stProcInfo.hProcess invoke CloseHandle, stProcInfo.hThread .else invoke MessageBox, NULL, addr szErrExec, NULL, MB_OK or MB_ICONSTOP .endif invoke ExitProcess, 0
main endp
end main
运行结果(Win XP环境双击 Patch1.exe):
编译链接后执行Patch1.exe程序就会发现,Test.exe被执行了,而且正确地显示了“感谢您使用正版软件”消息框,这说明补丁程序产生效果了。现在来分析一下补丁程序的实现过程。
程序的开头用equ语句定义补丁的地址00401004h,并在dbPatch常量中定义补丁位置的原先代码,在dbPatched常量中定义了要写入的补丁内容。
程序首先使用CreateProcess函数创建Test.exe进程,如果创建成功,则先用ReadProcessMemory函数读出补丁处的原来内容,并验证是否和我们保留的相同。因为用户可能将补丁程序用在不同版本的目标程序中,这时候补丁就会写入错误的地址,这可能会引起目标程序崩溃,首先验证补丁处的原有内容是否正确,就可以防止发生这种错误的发生。通过了内容的校验以后,程序再使用WriteProcessMemory函数将补丁写入正确的位置。
虽然程序很简单,但是读者还要注意两个细节:首先是创建进程的时候最好使用CREATE_SUSPENDED标志,这样目标进程的主线程一开始是被挂起的,可以防止补丁程序在执行补丁代码的过程中被Windows打断,然后切换到目标进程去的情况,当补丁完成以后,使用ResumeThread函数恢复目标进程的执行就可以了;第二点是完成补丁以后,不要忘记使用CloseHandle语句将CreateProcess返回的进程句柄和线程句柄关闭。
例子中使用了CreateProcess函数来创建目标进程,对于使用这种方法得到的目标进程句柄,父进程拥有全部权限,可以自由读写子进程的地址空间。如果使用OpenProcess去获取运行中的目标进程的句柄时,不要忘了指定PROCESS_VM_READ和PROCESS_VM_WRITE权限,否则补丁操作会失败。
13.3.3 调试API的使用
对于上面的内存补丁例子,读者可能会说:这种东西还好意思拿出来亮相,又没有实用价值!只要用十六进制编辑器去Test.exe中查找数据串“74h、15h”然后直接改成“90h、90h”就一劳永逸了,何必这样兴师动众写一个补丁程序呢?的确如此,不过使用这个例子是为了介绍ReadProcessMemory和WriteProcessMemory函数的用法,而且可以用来继续引出本节中的例子程序。
稍微接触过加密解密的读者肯定见过“加壳程序”和“脱壳程序”这两个名词。“加壳”指将可执行文件的代码和数据经过某种变换后存储,并在原来的可执行文件中添加一段用于还原的代码,这样在执行程序的时候,这些代码会自动将原来可执行文件的代码和数据还原并执行,用户并不会感觉到程序被改动过,这段用于还原的代码就像是一层壳附在原来文件外面,所以对文件进行变换处理的程序就被叫做“加壳程序”。
“加壳”的原因有两个:压缩和加密。压缩程序可以将可执行文件的内容压缩存储,这样文件占用的磁盘空间可以缩小,这时“壳代码”就是解压缩代码;而加密程序则是为了保证可执行文件的内容不被随便修改(就像上面讲的用十六进制编辑器去修改关键代码),这时“壳代码”就是解密代码,一般解密代码中同时包含有反跟踪模块。现在的大部分加密软件同时有压缩的功能,如Aspack,PECompact和ASProtect等软件。在被加壳的文件中,原来的代码和数据已经面目全非了,用十六进制编辑器去寻找特征码是根本无法找到的,所以无法用修改文件的方法进行静态补丁。
要对加过壳的软件进行修改必须首先将它脱壳,但大部分的加壳软件都无法用简单的方法去对付,除了一些加密程度不高的“壳”可以利用逆算法完全恢复原来的文件外,有很多“壳”是无法用逆运算对付的。虽然可以用Soft-ICE等跟踪软件跟踪到壳代码的内部,并一直跟踪到壳代码将原来的可执行文件恢复为止,然后再手工去改动内存中的关键代码,但用户不可能为了执行程序而每次都用调试器去载入可执行文件,并且花一段时间去跟踪并做“手工补丁”。
既然无法用算法将文件还原,那么尝试从已经正常运行的进程中拷贝代码会怎么样呢?这就是ProcDump之类的软件做的事情,这些软件可以等待壳代码将文件在内存中恢复后从进程的地址空间中拷贝已还原的代码并写成一个可执行文件,但实际使用效果并不那么令人满意。
在这种情况下,内存补丁技术就又派上用场了,这时的关键就在于补丁程序必须有调试器的功能,可以模拟手工使用Soft-ICE等软件进行跟踪的过程,一直跟踪到“壳代码”执行完毕,可执行文件的代码被完全恢复以后再去打内存补丁,Win32中的调试API就可以让我们做到这一点。
1.改进后的内存补丁程序
首先来改造Test.exe,将它加在一层壳里面,为了简化操作,我们使用Upx压缩软件将它压缩。现在用W32Dasm将压缩后的Test.exe反汇编,可以发现入口地址已经不一样了,入口处的代码也不一样了。下面就是Upx生成的动态解压缩代码:
//******************** Program Entry Point ********:00405120 60 pushad <--- 这里将设置断点中断指令:00405121 BE00504000 mov esi, 00405000:00405126 8DBE00C0FFFF lea edi, dword ptr [esi+FFFFC000]:0040512C 57 push edi:0040512D 83CDFF or ebp, FFFFFFFF:00405130 EB10 jmp 00405142:00405132 90 nop:00405133 90 nop...:0040526E 61 popad:0040526F E98CBDFFFF jmp 00401000
注意:整段代码的最后有一句jmp 00401000,00401000h就是Test.exe程序原来的入口地址,实际上执行到这里的时候,解压缩代码已经结束,可执行文件原来的代码和数据已经被恢复。我们要做的事情就是使用调试API将程序执行到解压缩代码结束的地方,再进行内存补丁。
改进后的补丁程序放在所附光盘的Chapter13\Patch2目录中。Patch2.asm文件如下:
; Patch2.asm ------------- 使用断点和单步跟踪跳过
; Test.exe 上的压缩外壳代码,再进行内存补丁的例子程序
;---------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Patch2.asm
; rc Patch2.rc
; Link /subsystem:windows Patch2.obj Patch2.res
.586
.model flat,stdcall
option casemap:none ; include
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
includelib c:/masm32/lib/user32.lib
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/kernel32.lib BREAK_POINT1 equ 00405120h
BREAK_POINT2 equ 00401000h
PATCH_POSITION equ 00401004h ; 数据段
.data?
align dword
stCT CONTEXT <?>
stDE DEBUG_EVENT <?>
stStartUp STARTUPINFO <>
stProcInfo PROCESS_INFORMATION <>
szBuffer byte 1024 dup(?).const
dbPatched byte 90h,90h
dbInt3 byte 0cch
dbOldByte byte 60h
szExecFileName byte 'Test.exe',0
szErrExec byte '无法装载执行文件!',0; 代码段
.code
main proc ;创建进程invoke GetStartupInfo, addr stStartUp invoke CreateProcess, offset szExecFileName, NULL, NULL, NULL, NULL, \DEBUG_PROCESS or DEBUG_ONLY_THIS_PROCESS, NULL, NULL, \offset stStartUp, offset stProcInfo .if !eax invoke MessageBox, NULL, addr szErrExec, NULL, MB_OK or MB_ICONSTOP invoke ExitProcess, NULL .endif ;调试进程.while TRUE invoke WaitForDebugEvent, addr stDE, INFINITE .break .if stDE.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT ;如果进程开始,则将入口地址处的代码改为 int 3 断点中断.if stDE.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT invoke WriteProcessMemory, stProcInfo.hProcess, \BREAK_POINT1, addr dbInt3, 1, NULL ;如果发生断点中断,则恢复断点处代码并设置单步中断.elseif stDE.dwDebugEventCode == EXCEPTION_DEBUG_EVENT .if stDE.u.Exception.pExceptionRecord.ExceptionCode == EXCEPTION_BREAKPOINT mov stCT.ContextFlags, CONTEXT_FULLinvoke GetThreadContext, stProcInfo.hThread, addr stCT .if stCT.regEip == BREAK_POINT1 + 1 dec stCT.regEip invoke WriteProcessMemory, stProcInfo.hProcess, \BREAK_POINT1, addr dbOldByte, 1, NULL or stCT.regFlag, 100h invoke SetThreadContext, stProcInfo.hThread, addr stCT .endif ;如果单步中断到了指定位置,则进行内存补丁.elseif stDE.u.Exception.pExceptionRecord.ExceptionCode == EXCEPTION_SINGLE_STEP mov stCT.ContextFlags, CONTEXT_FULL invoke GetThreadContext, stProcInfo.hThread, addr stCT .if stCT.regEip == BREAK_POINT2 invoke WriteProcessMemory, stProcInfo.hProcess, \PATCH_POSITION, addr dbPatched, sizeof dbPatched, NULL .else or stCT.regFlag, 100h invoke SetThreadContext, stProcInfo.hThread, addr stCT .endif .endif .endif invoke ContinueDebugEvent, stDE.dwProcessId, stDE.dwThreadId, DBG_CONTINUE .endw invoke CloseHandle, stProcInfo.hProcess invoke CloseHandle, stProcInfo.hThread invoke ExitProcess, 0
main endp
end main
程序不长,不到100行,这在Win32汇编程序中的规模已经是相当小了,但由于调试API使用的数据结构比较复杂,所以代码看起来比较费解,如下面的代码:
.if stDE.u.Exception.pExceptionRecord.ExceptionCode == EXCEPTION_BREAKPOINT
读者看到这里千万不要打退堂鼓,这只是4层结构的嵌套而已,接下来就会介绍它们的使用方法。(不过退一步讲,如果调试API使用起来很简单,Soft-ICE之类的软件就显示不出它们的伟大了!)
在程序的开始,还是例行公事地使用CreateProcess去创建被调试的进程,不过在创建标志中必须指定DEBUG_PROCESS标志,只有指定了这个标志,Windows才会将父进程视为子进程的调试器并让子进程进入被调试状态。
在其他的一些情况中,也许会遇到调试不是由程序本身创建的子进程的情况,这时可以通过DebugActiveProcess函数让目标进程进入调试状态:
invoke DebugActiveProcess,dwProcessId
dwProcessId参数指定需要调试的进程ID。
2.调试API
在用做调试器的父进程中,可以通过调试API完成下面的功能,如果愿意的话,可以使用这些功能来写一个全功能的Debug程序:
● 获取被调试程序的底层信息,如进程ID、入口地址和映象基址等。
● 当发生与调试有关的事件时获得通知,如进程或线程的开始和结束,DLL的加载和释放,以及发生各种异常等。
● 修改被调试的进程或线程。
当被调试进程发生与调试有关的事件时,Windows将目标进程的线程挂起并给调试器发送一个事件通知,调试器进程使用WaitForDebugEvent函数获取这些事件,在处理完调试事件后,可以恢复目标进程的执行并等待下一个事件的发生,例子程序就是在这样一个无限循环中使用WaitForDebugEvent函数来获取调试事件并进行处理的。
WaitForDebugEvent函数的用法是:
invoke WaitForDebugEvent,lpDebugEvent,dwMilliseconds
dwMilliseconds指定一个以ms为单位的等待时间。如果要一直等待到某个事件发生函数才返回,可以在这里使用INFINITE值。lpDebugEvent参数指向一个DEBUG_EVENT结构,函数在这里返回调试事件的具体信息。DEBUG_EVENT结构的定义比较复杂,例子代码看上去很复杂的原因就是因为这个结构的复杂性。结构的定义为:
DEBUG_EVENT STRUCTdwDebugEventCode DWORD ? ;调试事件类型dwProcessId DWORD ? ;发生调试事件的进程IDdwThreadId DWORD ? ;发生调试事件的线程IDu DEBUGSTRUCT <> ;事件的具体信息DEBUG_EVENT ENDS
dwDebugEventCode字段指定了发生的调试事件类型。因为可能发生的事件类型有很多种,所以程序要检查此字段并根据不同的事件做不同的处理。例子程序中设置了一个逻辑分支代码来处理EXIT_PROCESS_DEBUG_EVENT,CREATE_PROCESS_DEBUG_EVENT以及EXCEPTION_DEBUG_EVENT事件。可能发生的事件有:
● CREATE_PROCESS_DEBUG_EVENT——进程被创建。当使用CreateProcess函数刚创建被调试进程(还未开始运行),或者已经运行中的进程刚被DebugActiveProcess函数捆绑到调试器中时发生这个事件。
● EXIT_PROCESS_DEBUG_EVENT——被调试进程退出。
● CREATE_THEAD_DEBUG_EVENT——被调试进程中新建立了一个线程(但是被调试进程的主线程被创建的时候不会收到这个事件)。
● EXIT_THREAD_DEBUG_EVENT——被调试进程中某个线程结束。
● LOAD_DLL_DEBUG_EVENT——被调试进程装入一个DLL时发生本事件。当系统分析可执行文件并根据文件头中的导入表装入DLL时,程序会收到这个事件;当被调试进程使用LoadLibrary装入DLL时也会发生本事件。
● UNLOAD_DLL_DEBUG_EVENT——当一个DLL从被调试进程中卸载时发生本事件。
● EXCEPTION_DEBUG_EVENT——被调试进程中发生异常事件。被调试进程开始执行第一条指令前本事件会发生一次,以后只有在发生调试中断(遇到int 3或者单步中断),以及发生异常的时候才会发生本事件。
● OUTPUT_DEBUG_STRING_EVENT——当被调试进程调用DebugOutputString函数时发生本事件。被调试进程可以通过这种方法向调试器程序发送消息字符串。
● RIP_EVENT——系统调试发生错误。
DEBUG_EVENT结构中的dwProcessId和dwThreadId字段为发生调试事件的进程和线程ID。虽然使用CreateProcess创建被调试进程的时候就已经得到两个ID值,但这两个ID是属于子进程的。当没有指定DEBUG_ONLY_THIS_PROCESS标志时,调试事件可能发生在“孙”进程中,这时dwProcessId和dwThreadId字段指明的就是“孙”进程的ID。通过检测这个ID值和调用CreateProcess函数时获取的ID值是否一致,可以知道事件是发生在子进程还是孙进程中。
u字段包含了调试事件的详细信息,根据dwDebugEventCode的不同,它被定义为不同的结构,结构的名称和事件的对应关系如表13.1所示。在这里由于篇幅的关系就不列出所有的结构定义了,读者可以具体参考Win32 API手册。
表13.1 发生不同调试事件时u字段中的结构
当程序使用WaitForDebugEvent函数获取了一个事件并进行处理以后,被调试进程还处在挂起状态,调试事件处理完毕后让它恢复运行是调试器的责任,恢复被调试进程的运行可以使用ContinueDebugEvent函数:
invoke ContinueDebugEvent,dwProcessId,dwThreadId,dwContinueStatus
其中,dwProcessId和dwThreadId参数指定被恢复运行的进程ID和线程ID,在这里可以直接使用在DEBUG_EVENT结构中返回的同名字段。dwContinueStatus参数指定恢复运行的方式,一般指定为DBG_CONTINUE。
总之,使用下面的循环结构进行调试过程:
.while TRUEinvoke WaitForDebugEvent,addr DebugEvent,INFINITE.break .if DebugEvent.dwDebugEventCode==EXIT_PROCESS_DEBUG_EVENT.if DebugEvent.dwDebugEventCode==XXXXXXX<处理调试事件1>.elseif DebugEvent.dwDebugEventCode==YYYYYYY<处理调试事件2>....endifinvoke ContinueDebugEvent,DebugEvent.dwProcessId,\DebugEvent.dwThreadId, DBG_CONTINUE
.endw
循环中首先用WaitForDebugEvent函数获取调试事件,然后用一个分支语句检测事件类型,当发现事件代码为EXIT_PROCESS_DEBUG_EVENT(进程退出)时,用 .break语句结束循环;在其他情况下,程序根据不同的事件码进行不同的处理。
例子程序中处理了CREATE_PROCESS_DEBUG_EVENT和EXCEPTION_DEBUG_EVENT事件。当发生CREATE_PROCESS_DEBUG_EVENT事件时,表示建立了被调试进程,这时例子程序在目标进程的入口代码处(地址为00405120h,原指令为pushad,机器码为60h)写入一个0cch(int 3的机器码),当目标进程开始执行时,Windows就会以EXCEPTION_DEBUG_EVENT(异常事件)通知程序。
由于已经在静态反汇编分析中知道了该地址原来的内容,所以将int 3指令写入之前不必使用ReadProcessMemory函数先保存原来的指令;如果不知道被覆盖的指令码是什么,那么在写入int 3之前就必须保存原来的指令码,因为以后还要将它恢复回去。
接下来就是等待这个int 3发生了,也就是EXCEPTION_DEBUG_EVENT事件的发生,对于EXCEPTION_DEBUG_EVENT事件,u字段被定义为EXCEPTION_DEBUG_INFO结构:
EXCEPTION_DEBUG_INFO STRUCTpExceptionRecord EXCEPTION_RECORD <?,?,?,?,?,EXCEPTION_MAXIMUM_PARAMETERS
dup(?)>dwFirstChance DWORD ?EXCEPTION_DEBUG_INFO ENDS
结构中的pExceptionRecord字段又被定义为一个EXCEPTION_RECORD结构,在这个结构中,我们需要的信息才浮出水面:
EXCEPTION_RECORD STRUCTExceptionCode DWORD ? ;异常事件码ExceptionFlags DWORD ? ;标志pExceptionRecord DWORD ?ExceptionAddress DWORD ?NumberParameters DWORD ?ExceptionInformation DWORD EXCEPTION_MAXIMUM_PARAMETERS dup(?)EXCEPTION_RECORD ENDS
读者不要被嵌套得这么深的结构吓倒了,实际上对它们的引用很简单,只要使用“结构1.结构2.结构3.结构4.字段n”的格式就可以了。EXCEPTION_RECORD结构的ExceptionCode字段定义了异常事件的类型,异常事件的类型有很多,我们关心的是EXCEPTION_BREAKPOINT(断点中断)和EXCEPTION_SINGLE_STEP(单步中断)两种异常。好了,现在程序可以在异常事件的处理中再设置一个分支,并根据断点中断和单步中断两种情况做不同的处理。
在分析例子程序对这两种异常事件的处理代码之前,还需要了解一个新概念,即线程环境。
3.线程环境
在第12章中已经提到过,Windows为不同的线程循环分配时间片,当挂起一个线程的时候,为了以后能够将它恢复执行,系统必须首先将线程的运行环境保存下来,当线程在下一个时间片恢复执行时,将运行环境恢复回去,线程就不会感觉到自己被打断过,这就像甲外出的时候把办公室交给乙管,不管乙把办公室搞成什么样子,只要在甲回来之前把所有东西恢复原状,甲就不会意识到甲出去的时候办公室被挪做它用了。
线程环境就是这个道理,Windows中将线程环境称为“Thread Context”(注意:没有进程Context,因为进程是不活动的),对一个线程来说,只要所有的寄存器没有改变,环境就没有改变,所以线程环境实际上就是寄存器的状态,它可以用一个CONTEXT结构来表示。结构的定义如下:
CONTEXT STRUCTContextFlags DWORD ?iDr0 DWORD ?iDr1 DWORD ?iDr2 DWORD ?iDr3 DWORD ?iDr6 DWORD ?iDr7 DWORD ?FloatSave FLOATING_SAVE_AREA <>regGs DWORD ?regFs DWORD ?regEs DWORD ?regDs DWORD ?regEdi DWORD ?regEsi DWORD ?regEbx DWORD ?regEdx DWORD ?regEcx DWORD ?regEax DWORD ?regEbp DWORD ?regEip DWORD ?regCs DWORD ?regFlag DWORD ?regEsp DWORD ?regSs DWORD ?ExtendedRegisters db MAXIMUM_SUPPORTED_EXTENSION dup(?)CONTEXT ENDS
结构中的字段包括80x86系列处理器中的全部寄存器,其中FloatSave字段用来保存浮点寄存器的内容,ExtendedRegisters字段用来保存扩展寄存器的内容(如MMX寄存器等),ContextFlags字段是供结构自己用的标志。
CONTEXT结构是Windows中唯一与硬件平台相关的结构,因为Windows设计成可以在不同的硬件平台上运行,当运行于MIPS,Alpha和PowerPC等平台上时,显然寄存器名称就和80x86系列的不同了,这时CONTEXT结构的定义也相应改变了。
在线程处于休眠状态的时候,它的线程环境由Windows保存,其他程序可以通过API获取它们并修改它们,当线程分配到时间片恢复运行时,Windows将修改过的线程环境恢复回去,而线程并不会意识到环境已经被修改了。用这种方法可以修改regEip字段,让某个线程转移到其他地方执行。
用于获取和重新设置线程环境的函数是GetThreadContext和SetThreadContext。有了这两个函数,调试手段中就多了一种利器,想一想,能够随意修改目标线程的内容,也可以随意修改它的运行环境,还有什么事情做不到呢?
这两个函数的用法是:
invoke GetThreadContext,hThread,lpContext
invoke SetThreadContext,hThread,lpContext
hThread指定目标线程句柄,lpContext指向一个CONTEXT结构,GetThreadContext函数会将目标线程的环境返回到这个结构中,SetThreadContext函数将结构中的寄存器设置到 目 标线 程 中。为 了 执 行这 两 个 函数,程 序必 须 对 目标 线 程 拥有THREAD_GET_CONTEXT和THREAD_SET_CONTEXT权限。
在执行函数前,必须设置CONTEXT结构中的ContextFlags字段,这个字段表示需要操作的寄存器的范围。访问通用寄存器可以指定CONTEXT_INTEGER;访问段寄存器可以指定CONTEXT_SEGMENTS;要访问全部寄存器则指定为CONTEXT_FULL。
另外,在定义CONTEXT结构的时候,应该将它定义为双字对齐,否则,在NT下将得到奇怪的结果,双字对齐的方法是在结构的定义前加上“align dword”关键字。
在执行GetThreadContext函数前,最好使用SuspendThread函数将目标线程挂起,防止函数执行到一半的时候被Windows切换走了,在执行SetThreadContext以后可以再使用ResumeThread函数将目标线程恢复运行。但是在调试事件中就没有这个必要了,因为在ContinueDebugEvent函数返回之前,目标线程不会恢复运行。
现在回过头来看例子程序,在发生异常事件的逻辑分支中,当检测到断点中断的时候,程序使用GetThreadContext函数获取线程环境,并比较eip是否到达我们设置的断点+1处(因为断点指令执行以后才发生异常,这时候eip已经指向了下一条指令),如果到达,则用WriteProcessMemory函数将断点处原来的代码恢复回去,并将eip寄存器减1以便目标线程能够重新执行这条指令,同时,程序将eflags标志的单步标志置1,这样以后每执行一条指令,就会发生单步中断回到调试器中,就可以一步步跟踪,直到壳代码将Test.exe的内容完全解压缩为止。完成了对线程环境的修改以后,使用SetThreadContext函数将线程环境设置回去,现在就等下一次的单步中断发生了。
当单步中断发生的时候,程序同样使用GetThreadContext函数获取线程环境,并比较eip是否到达Test.exe程序原来的入口处(00401000h),如果没有到达,程序继续设置单步标志,这是因为Windows执行一条语句以后会自动清除单步标志,为了继续进行单步跟踪,必须每次重新设置单步标志;如果发现已经执行到00401000h处了,程序开始打内存补丁,这时程序不必再设置单步标志,因为要做的工作全部完成了!
运行程序,在经过几秒的等待后,“感谢您使用正版软件”的消息框终于出现了,这就意味着我们成功地突破了壳代码的封锁。
但是单步中断的开销是很大的,毕竟在原来的一条指令之间要多执行成千上万条指令,效率也就相应低了很多,这就是前面要等待几秒的原因。实际上有时候可以不用单步中断,而采用分步进行断点中断的办法;有时候,一个合适的断点就可以解决全部问题,比如对于用做例子的Test.exe程序来说,观察反汇编后的代码:
:0040526E 61 popad
:0040526F E98CBDFFFF jmp 00401000
可以在0040526Eh处设置断点,这样就可以在壳代码完成解压缩操作以后再将它中断,而且只要中断一次就可以了,按这种方法编写的补丁程序放在所附光盘的Chapter13\Patch3目录中,这个程序执行起来没有一点延时,有兴趣的读者自己分析一下。实际上,Patch2程序舍近求远用了单步中断的原因仅是为了给读者做处理单步中断的示范。
当然,使用调试API编写补丁程序的时候,采用的方案是建立在对目标程序的分析上的,并没有一个很确定的方案,本节的例子给出了Patch1到Patch3总共3个程序,就是为了给读者演示同一种问题的不同解决方法,在具体的使用中,读者还应该根据实际情况灵活应用。
【完整代码笔记】
; Patch3.asm
; 使用单步跟踪跳过 Test.exe 上的压缩外壳代码,再进行内存补丁的例子程序
;---------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Patch3.asm
; rc Patch3.rc
; Link /subsystem:windows Patch3.obj Patch3.res
.586
.model flat,stdcall
option casemap:none ; include 数据
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib BREAK_POINT1 equ 0040526Eh
PATCH_POSITION equ 00401004h; 数据段
.data?
align dword
stCT CONTEXT <?>
stDE DEBUG_EVENT <?>
stStartUp STARTUPINFO <>
stProcInfo PROCESS_INFORMATION <>
dwTemp dword ?
szBuffer byte 1024 dup(?).const
dbPatched byte 90h,90h
dbInt3 byte 0cch
dbOldByte byte 61h
szExecFileName byte 'Test.exe',0
szErrExec byte '无法装载执行文件!',0; 代码段
.code
main proc ;创建进程invoke GetStartupInfo, addr stStartUp invoke CreateProcess, offset szExecFileName, NULL, NULL, NULL, NULL, \DEBUG_PROCESS or DEBUG_ONLY_THIS_PROCESS, NULL, NULL, \offset stStartUp, offset stProcInfo .if !eax invoke MessageBox, NULL, addr szErrExec, NULL, MB_OK or MB_ICONSTOP invoke ExitProcess, 0.endif ;调试进程.while TRUE invoke WaitForDebugEvent, addr stDE, INFINITE .break .if stDE.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT ;如果进程开始,则将入口地址处的代码改为 int 3 断点中断.if stDE.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT invoke WriteProcessMemory, stProcInfo.hProcess, \BREAK_POINT1, addr dbInt3, 1, addr dwTemp ;如果发生断点中断,则恢复断点处代码并进行内存补丁.elseif stDE.dwDebugEventCode == EXCEPTION_DEBUG_EVENT .if stDE.u.Exception.pExceptionRecord.ExceptionCode == EXCEPTION_BREAKPOINT mov stCT.ContextFlags, CONTEXT_FULL invoke GetThreadContext, stProcInfo.hThread, addr stCT .if stCT.regEip == BREAK_POINT1 + 1 dec stCT.regEip invoke WriteProcessMemory, stProcInfo.hProcess, \BREAK_POINT1, addr dbOldByte, 1, addr dwTemp invoke SetThreadContext, stProcInfo.hThread, addr stCT invoke WriteProcessMemory, stProcInfo.hProcess, \PATCH_POSITION, addr dbPatched, sizeof dbPatched, addr dwTemp .endif .endif .endif invoke ContinueDebugEvent, stDE.dwProcessId, stDE.dwThreadId, DBG_CONTINUE.endw invoke CloseHandle, stProcInfo.hProcess invoke CloseHandle, stProcInfo.hThread invoke ExitProcess, 0
main endp
end main
运行结果:
13.4 进程的隐藏
进程隐藏技术用得最多的地方就是在病毒和木马中,因为这些不适合出现在阳光下的程序,越隐蔽生存率就越高。在当今Windows环境下,病毒和木马流传得越来越广泛,让读者适当了解这方面的技术可以在防治方面起到积极的作用,技术这种东西就是这样,大家都知道的“秘技”也就不再是“秘技”了,所以,大家都知道了进程隐藏是怎么一回事,进程隐藏起来也就不那么隐蔽了。
13.4.1 在Windows 9x中隐藏进程
在Windows 9x系列操作系统中,可以通过Kernel32.dll中的一个未公开函数来完成隐藏功能,这个函数就是RegisterServiceProcess,该函数的功能是将一个进程注册为系统服务进程,由于Windows的任务管理器并不列出系统服务进程,所以可以用它来隐藏进程,不过该函数在Windows NT系列中并不存在。
RegisterServiceProcess函数的使用方法是:
invoke RegisterServiceProcess,dwProcessID,dwFlag
dwProcessID指明目标进程的进程ID,参数dwFlag指定是注册还是撤销,指定TRUE的话,进程被注册为系统服务进程,如果指定为FALSE,则进程的属性恢复为普通进程属性。
Kernel32.lib导入库中并没有这个函数的导入信息,如果要使用这个函数,程序需要自己装入库文件并使用GetProcAddress函数获取入口地址后使用(具体的方法请复习第11章)。所附光盘的Chapter13\HideProcess9x目录中的例子程序演示了该函数的使用方法。汇编源代码HideProcess9x.asm的内容如下:
; HideProcess9x.asm
; 在 Windows 9x 操作系统中将进程注册为系统进程,以实现隐藏功能
;--------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff HideProcess9x.asm
; Link /subsystem:windows HideProcess9x.obj
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib ; 数据段
.const
szFunction byte 'RegisterServiceProcess',0
szDllKernel byte 'Kernel32.dll',0
szText byte '现在请按下Ctrl+Alt+Del调出任务管理器查看是否存在本进程',0
szCaption byte '在Windows 9x中隐藏进程',0
szErr byte '本功能只在Windows 9x中提供',0; 代码段
main proc invoke GetModuleHandle, offset szDllKernel .if eax invoke GetProcAddress, eax, offset szFunction .if eax mov ebx, eax push TRUE invoke GetCurrentProcessId push eax call ebx invoke MessageBox, NULL, offset szText, offset szCaption, MB_OK jmp @F .endif .endif invoke MessageBox, NULL, offset szErr, NULL, MB_OK or MB_ICONWARNING
@@:invoke ExitProcess, 0
main endp
end main
由于可以确认Kernel32.dll库已经被装载到进程的地址空间中(GetProcAddress等函数就包括在这个库中,因此这个库肯定已经被装入),所以例子中使用GetModuleHandle函数而不是使用LoadLibrary函数来获取库句柄,这样就可以省去一个FreeLibrary的调用。接下来,程序使用GetProcAddress函数获取RegisterServiceProcess函数的入口地址,如果获取成功,则使用GetCurrentProcessId函数获取当前进程的ID并将这个ID注册为系统服务进程。
RegisterServiceProcess的缺点就是只能“欺骗”Windows的任务管理器,使用快照函数还是可以将全部进程枚举出来,即使是13.3.1节中的ProcessList.exe例子,也可以发现用RegisterServiceProcess隐藏的进程。所以使用这个函数只能实现简单的进程隐藏功能。相比之下,Windows NT下远程线程的功能就要强大得多了。
13.4.2 Windows NT中的远程线程
在Windows 9x中将进程注册为系统服务进程就能够从任务管理器中隐形,但在NT下就不同了。首先,NT下不存在RegisterServiceProcess函数;其次,NT的任务管理器会列出所有的进程(包括系统进程),即使一个进程将自己的可执行文件放在很隐蔽的目录中,文件名还是会被任务管理器列出来,所以想让别人看不见进程是不可能的。
当然,如果不用进程也能运行程序的话,那是最好不过的办法了,但是不用进程是无法执行文件的。
再从另一个角度考虑,如果进程显示的不是正确的名称呢,这也可以起到掩护作用,如果在DLL中执行我们的代码,系统报告的进程名称是装入DLL的进程的名称,而不是DLL本身的名称。
在Windows NT中还有另一种办法,那就是使用远程线程,使用它可以在其他进程中创建一个线程,由于线程是被所属进程拥有的,所以任务管理器中列出来的还是所属进程的名称。
1.Windows NT的远程操作函数
有两个函数可以用来实现上述功能:VirtualAllocEx和CreateRemoteThread。这两个函数都只能在Windows NT下使用。
VirtualAllocEx函数可以用来在其他进程的地址空间内申请内存,当然申请到的内存也是位于目标进程的地址空间内的,将这个函数和WriteProcessMemory函数配合就可以在目标进程的地址空间中“造”出任何代码或数据来。
VirtualAllocEx函数的用法是:
invoke VirtualAllocEx,hProcess,lpAddress,dwSize, flAllocationType,flProtect
.if eaxmov lpMemory,eax
.endif
在10.1.5节中已经介绍过虚拟内存管理函数VirtualAlloc,VirtualAllocEx函数就是这个函数的扩充,相比之下,VirtualAllocEx函数多了一个参数hProcess,其他参数定义和使用的方法都和VirtualAlloc函数相同,读者可以回过头去查看这些参数的用法。新增的hProcess参数用来指定要申请内存的进程句柄,如果hProcess指定的不是当前进程本身,那么申请到的就是其他进程地址空间中的内存!如果内存申请成功,函数返回一个指针,指向申请到的内存块,当然这个指针是针对目标进程的地址空间的。
如果内存申请失败,函数返回NULL。注意:如果需要在目标进程中使用VirtualAllocEx函数,那么必须对进程拥有PROCESS_VM_OPERATION权限。
CreateRemoteThread函数用来在其他进程内创建一个线程,当然创建的线程也是运行于目标进程的地址空间内的,它和目标进程自己创建的线程并没有什么区别。函数的用法是:
invoke CreateRemoteThread,hProcess,lpThreadAttributes,dwStackSize,\lpStartAddress,lpParameter,dwCreationFlags,lpThreadId
该函数是CreateThread函数的扩充,与CreateThread相比,CreateRemoteThread函数多了一个hProcess参数,其他所有参数的定义和用法都与CreateThread的参数相同。hProcess用来指定要创建线程的目标进程句柄。注意:lpStartAddress指向的线程函数的地址是位于目标进程的地址空间内的。如果需要在目标进程中使用CreateRemoteThread函数,那么必须对进程拥有PROCESS_CREATE_THREAD权限。
使用VirtualAllocEx和CreateRemoteThread函数,再配合WriteProcessMemory函数,就能够让一段代码在其他进程中运行,由于远程线程是属于目标进程的,所以在任务管理器中不会产生新的进程,事实上,谁也不会发现列出的某个进程中会多了一个不属于它自己控制的线程。整个实现的过程归纳如下:
(1)使用VirtualAllocEx函数在目标进程中申请一块内存,内存块的长度必须能够容纳线程使用的代码和数据,内存块的属性应该是PAGE_EXECUTE_READWRITE,这样拷贝到内存块中的代码就可以被执行。
(2)使用WriteProcessMemory函数将需要在远程线程中执行的代码(包括它使用的数据)拷贝到第(1)步申请到的内存块中。
(3)使用CreateRemoteThread函数创建远程线程。
2.远程线程存在的技术问题
实现远程线程的框架结构已经搭好了,但是在具体的实现中还有一些技术问题需要解决,归纳起来主要有两点:代码的重定位问题和函数的导入问题。
代码的重定位问题可以用下面的例子来说明,先来看一段简单的源代码:
dwVar dd ?;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>Proc1 proc _dwParamlocal @dwLocalmov eax,dwVarmov eax,@dwLocalmov eax,_dwParamretProc1 endp;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>invoke Proc1,1234
这段源代码中包括了调用子程序,存取全局变量、局部变量和参数的情况,经过编译链接以后再反汇编,就变成了下面的样子:
:00400FFC 0000 ;dwVar变量:00401000 55 push ebp:00401001 8BEC mov ebp, esp:00401003 83C4FC add esp, FFFFFFFC:00401006 A1FC0F4000 mov eax, dword ptr [00400FFC] ;mov eax,dwVar:0040100B 8B45FC mov eax, dword ptr [ebp-04] ;mov eax,@dwLocal:0040100E 8B4508 mov eax, dword ptr [ebp+08] ;mov eax,_dwParam:00401011 C9 leave:00401012 C20400 ret 0004:00401015 68D2040000 push 000004D2:0040101A E8E1FFFFFF call 00401000 ;invoke Proc1,1234
分析一下机器码就可以发现,存取全局变量的指令mov eax,dwVar中,全局变量的地址是包含在机器码中的(指令的机器码是A1FC0F4000,第一个字节A1h是mov eax,xxx的机器码,后面的FC0F4000按照高字节在后的顺序读就是变量的地址00400FFCh);存取局部变量和参数的指令中并不包含绝对地址;call指令中的地址数据也是相对的,所以,当这段机器码原封不动地从00401000h地址被搬到00801000h处的时候,就变成了下面的样子:
:00800FFC 0000:00801000 55 push ebp:00801001 8BEC mov ebp, esp:00801003 83C4FC add esp, FFFFFFFC:00801006 A1FC0F4000 mov eax, dword ptr [00400FFC] ;mov eax,dwVar:0080100B 8B45FC mov eax, dword ptr [ebp-04] ;mov eax,@dwLocal:0080100E 8B4508 mov eax, dword ptr [ebp+08] ;mov eax,_dwParam:00801011 C9 leave:00801012 C20400 ret 0004:00801015 68D2040000 push 000004D2:0080101A E8E1FFFFFF call 00801000 ;invoke Proc1,1234
这时候,A1FC0F4000机器码还是被解释为存取00400FFCh地址,而实际的变量地址已经被搬到00800FFCh处了,这就是说,指令存取的是错误的地址,所以这段指令要想正常执行,就必须放在00401000h地址开始的地方,如果想搬到别的地方去执行,就必须对访问全局变量的指令进行修正,这就是重定位的问题由
此可见,如果想把这段指令放到远程线程中去执行,由于无法保证将代码放到00401000h处,所以几乎可以肯定它是不能正常工作的,但是根据代码最后执行的实际位置来修正某些指令的话,在远程线程中执行它还是可行的。
对于高级语言来说,重定位问题是个致命的问题,是根本不可能解决的,因为高级语言无法在机器码级别上进行细微的操作,所以,即使在相对比较低级的C语言中也无法将一段代码拷贝到远程线程中去执行,大部分的教科书和资料在介绍远程线程的时候,都采用了变通的方法,就是将DLL嵌入到目标进程中去执行。
如Jeffrey Richer的《Windows高级编程指南》中就介绍了使用远程线程将DLL注入目标进程的方法,其实现步骤是将需要远程执行的代码写到一个DLL中,然后在目标进程中申请一块内存并将DLL文件名写入,最后将目标进程地址空间中的LoadLibrary函数当做线程函数来执行,输入的参数就是前面的DLL文件名,这样LoadLibrary函数执行到ret的时候,远程线程结束,但是DLL也被装入了目标进程中,在这个DLL的入口函数中创建一个新的线程,就可以执行指定的代码了。这种方法以系统装入DLL时会自动重定位的方法回避了重定位问题。
在所附光盘的Chapter13\RemoteThreadDll中的例子演示了这种方法的汇编版本,程序将一个DLL文件插入到文件管理器Explorer.exe中运行,有兴趣的读者可以自己分析一下。
虽然DLL文件在目标进程中运行的时候,任务管理器中不会列出DLL文件名,看到的只是目标进程的文件名,但是有一些工具可以查看一个进程究竟装入了哪些DLL文件,通过这些工具还是可以发现进程中的可疑DLL。
要彻底解决这个问题,就必须脱离DLL文件,让远程运行的代码只存在于内存中,这样就不会有任何的蛛丝马迹显示有某个文件被非法装入,这个问题的关键也就是这个重定位问题。但现在Win32汇编程序员可以很骄傲地说“我可以实现它”,因为自定位的代码正是汇编语言的拿手好戏,在快成为历史的DOS病毒中,10个病毒中就有9个用到了自定位技术,这些技术完全可以用在这个地方。
自定位技术其实很简单,观察下面这段代码:
dwVar dd ?call @F
@@:pop ebxsub ebx,offset @Bmov eax,[ebx + offset dwVar]
翻译成机器码就是:
:0040100000000000 BYTE 4 DUP(0):00401004 E800000000 call 00401009:00401009 5B pop ebx:0040100A 81EB09104000 sub ebx, 00401009:00401010 8B8300104000 mov eax, dword ptr [ebx+00401000]
这段代码不存在重定位的问题,分析如下。
call指令会将返回地址压入到堆栈中,当整段代码在没有移动的情况下执行的时候时,call @F指令执行后堆栈中的返回地址就是@@标号的地址00401009h,下一句pop指令将返回地址弹出到ebx中,再接下来ebx减去00401009h,现在ebx等于0,所以mov eax,[ebx+ offset dwVar]指令就等于mov eax,dwVar指令。
当整段代码被移动到其他地方时(假设被移到00801000处执行),@@标号现在对应的地址是00801009h,变量dwVar的地址对应00801000h,当call指令执行后,压入到堆栈的地址是00801009h,pop到ebx中的就是这个数值,经过sub ebx,00401009指令以后,ebx等于00400000h,现在mov eax,dword ptr [ebx+00401000]指令就相当于mov eax,[00801000],而00801000这个地址刚好等于dwVar现在所处的位置,所以,虽然代码被移动了位置,mov eax,dwVar指令还是访问了正确的地方。
call/pop/sub这3个指令组合的用途就是计算出代码当前的位置和设计时位置的偏移值之差,只要用这个差值去修正包含绝对地址的指令,如访问全局变量的指令,就能够保证修正后的地址是正确的,这就解决了重定位的问题。
另一个问题就是函数的导入问题,由于Win32编程不可避免地要用到API函数,而API函数又存在于DLL中,当远程代码要用到一个API函数时,就必须保证目标进程中已经装入了相应的DLL,还必须知道API函数的地址,否则对函数的调用就无从谈起。
所以在设计远程代码的时候,不能直接使用API函数,因为函数的地址在不同的进程中会随着DLL装入位置的不同而不同,如果在代码中直接调用API函数,那么系统会按照当前进程的DLL装入位置填入函数地址,但这个地址搬到远程线程中可能是错误的。
要在远程代码中使用API函数,就必须手动完成本来由系统完成的工作,那就是自己装入每个要使用的DLL,并使用GetProcAddress函数获取全部要使用的API函数的入口地址。由于这个过程要用到DLL文件的名称和函数名称,这些字符串必须放在全局变量中,这就又遇到了重定位的问题(所以在高级语言中实现函数的手动导入也是个很大的麻烦),当然现在这个问题是很容易解决的。
3.远程线程的具体实现
好了,经过这么长时间的纸上谈兵,现在动真格的(本节中的所有源程序都可以在所附光盘的Chapter13\RemoteThread目录中找到),还记得第4章中的窗口例子吗?我们的目标就是将这个创建窗口的程序整个搬到Windows的文件管理器Explorer.exe进程中去执行。选定文件管理器开刀的原因是,它是Windows的“常任理事”,这个进程任何时刻都在运行,所以不必担心找不到它。
读者可能会问,难道连包含消息循环、窗口过程的代码都可以放到远程线程中去执行吗?当然可以,因为第12章中已经介绍过,每个线程的消息队列是独立的,远程线程的消息循环并不会和Explorer.exe程序原来的消息循环互相混淆。
工作的第一步就是设计远程线程使用的代码,代码中必须包括一个线程函数用做远程线程执行的入口,在线程函数中必须完成所有所需DLL的装入工作和API函数地址的获取工作,然后调用创建窗口的主程序;第二步就是修改所有访问全局变量的代码,以解决重定位问题。
完工后的远程代码存放在RemoteCode.asm文件中:
REMOTE_CODE_START equ this byte
_lpLoadLibrary dd ? ;导入函数地址表
_lpGetProcAddress dd ?
_lpGetModuleHandle dd ?
_lpDestroyWindow dd ?
_lpPostQuitMessage dd ?
_lpDefWindowProc dd ?
_lpLoadCursor dd ?
_lpRegisterClassEx dd ?
_lpCreateWindowEx dd ?
_lpShowWindow dd ?
_lpUpdateWindow dd ?
_lpGetMessage dd ?
_lpTranslateMessage dd ?
_lpDispatchMessage dd ?
_hInstance dd ?
_hWinMain dd ?
_szClassName db 'RemoteClass',0
_szCaptionMain db 'RemoteWindow',0
_szDestroyWindow db 'DestroyWindow',0
_szPostQuitMessage db 'PostQuitMessage',0
_szDefWindowProc db 'DefWindowProcA',0
_szLoadCursor db 'LoadCursorA',0
_szRegisterClassEx db 'RegisterClassExA',0
_szCreateWindowEx db 'CreateWindowExA',0
_szShowWindow db 'ShowWindow',0
_szUpdateWindow db 'UpdateWindow',0
_szGetMessage db 'GetMessageA',0
_szTranslateMessage db 'TranslateMessage',0
_szDispatchMessage db 'DispatchMessageA',0,0
_szDllUser db 'User32.dll',0
;---------------------------------------------------------------------
_RemoteThread proc uses ebx edi esi lParamlocal @hModulecall @F@@:pop ebxsub ebx,offset @B
;----------------------------------------------------------------------_invoke [ebx + _lpGetModuleHandle],NULLmov [ebx + _hInstance],eaxlea eax,[ebx + offset _szDllUser]_invoke [ebx + _lpGetModuleHandle],eaxmov @hModule,eaxlea esi,[ebx + offset _szDestroyWindow]lea edi,[ebx + offset _lpDestroyWindow].while TRUE_invoke [ebx + _lpGetProcAddress],@hModule,esimov [edi],eaxadd edi,4@@:lodsbor al,aljnz @B.break .if ! byte ptr [esi].endw
;----------------------------------------------------------------------call _WinMainret
_RemoteThread endp
;---------------------------------------------------------------------
_ProcWinMain proc uses ebx edi esi,hWnd,uMsg,wParam,lParamcall @F@@:pop ebxsub ebx,offset @B
;--------------------------------------------------------------------------mov eax,uMsg.if eax == WM_CLOSE_invoke [ebx + _lpDestroyWindow],hWnd_invoke [ebx + _lpPostQuitMessage],NULL
;-------------------------------------------------------------------------.else_invoke [ebx + _lpDefWindowProc],hWnd,\uMsg,wParam,lParamret.endif
;------------------------------------------------------------------------xor eax,eaxret
_ProcWinMain endp
;------------------------------------------------------------------------
_ZeroMemory proc _lpDest,_dwSizepush edimov edi,_lpDestmov ecx,_dwSizexor eax,eaxcldrep stosbpop ediret
_ZeroMemory endp
;--------------------------------------------------------------------
_WinMain proc uses ebx esi edi _lParamlocal @stWndClass:WNDCLASSEXlocal @stMsg:MSGcall @F@@:pop ebxsub ebx,offset @B
;-------------------------------------------------------------------invoke _ZeroMemory,addr @stWndClass,sizeof @stWndClass_invoke [ebx + _lpLoadCursor],0,IDC_ARROWmov @stWndClass.hCursor,eaxpush [ebx + _hInstance]pop @stWndClass.hInstancemov @stWndClass.cbSize,sizeof WNDCLASSEXmov @stWndClass.style,CS_HREDRAW or CS_VREDRAWlea eax,[ebx + offset _ProcWinMain]mov @stWndClass.lpfnWndProc,eaxmov @stWndClass.hbrBackground,COLOR_WINDOW + 1lea eax,[ebx + offset _szClassName]mov @stWndClass.lpszClassName,eaxlea eax,@stWndClass_invoke [ebx + _lpRegisterClassEx],eax
;----------------------------------------------------------------------------
; 建立并显示窗口
;---------------------------------------------------------------------------lea eax,[ebx + offset _szClassName]lea ecx,[ebx + offset _szCaptionMain]_invoke [ebx + _lpCreateWindowEx],WS_EX_CLIENTEDGE,eax,ecx,\WS_OVERLAPPEDWINDOW,\100,100,600,400,\NULL,NULL,[ebx + _hInstance],NULLmov [ebx + _hWinMain],eax_invoke [ebx + _lpShowWindow],[ebx + _hWinMain],SW_SHOWNORMAL_invoke [ebx + _lpUpdateWindow],[ebx + _hWinMain]
;----------------------------------------------------------------------------
; 消息循环
;----------------------------------------------------------------------------.while TRUElea eax,@stMsg_invoke [ebx + _lpGetMessage],eax,NULL,0,0.break .if eax == 0lea eax,@stMsg_invoke [ebx + _lpTranslateMessage],eaxlea eax,@stMsg_invoke [ebx + _lpDispatchMessage],eax.endwret
_WinMain endp
REMOTE_CODE_END equ this byte
REMOTE_CODE_LENGTH equ offset REMOTE_CODE_END - offset REMOTE_CODE_START
在上面的代码中,所有对API函数的调用被换成了对函数入口地址的调用,因为入口地址被存放在全局变量中,所以要用call [ebx + XXXX]的格式调用以解决重定位问题,但是这样的话就无法使用invoke伪指令了。
因为用一大堆的push指令来压入参数太麻烦,笔者写了一个宏来自动压入参数,宏的名称定为_invoke,宏定义存放在Macro.inc文件中。文件的内容是:
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 将参数列表的顺序翻转
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
reverseArgs macro arglist:VARARGlocal txt,counttxt TEXTEQU <>count = 0for i,<arglist>count = count + 1txt TEXTEQU @CatStr(i,<!,>,<%txt>)endmif count GT 0txt SUBSTR txt,1,@SizeStr(%txt)-1endifexitm txt
endm
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 建立一个类似于invoke的Macro
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_invoke macro _Proc,args:VARARGlocal count
% for i,< reverseArgs( args ) >count = 0count = count + 1push iendmcall dword ptr _Proc
endm
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
远程代码中的线程函数是_RemoteThread,在这里程序首先获取要用到的API函数的地址,所有API函数的名称被放到了一系列相连的字符串中,最后以一个附加的0结束,这样可以很方便地通过循环来处理它们。
要获取函数地址必须使用LoadLibrary,GetProcAddress和GetModuleHandle函数,但这些函数地址又从哪里得到呢(这就好像一个“先有鸡,还是先有蛋”的问题),幸亏这些函数都存在于Kernel32库中,Kernel32.dll库文件和User32.dll,Gdi32.dll一样,都是最常用的库,在不同的进程中,系统基本上会将它装入到同样的地址中,所以对于它们来说,在本地进程中获取的地址可以用在远程线程中。
如果从绝对保险的角度考虑,必须要在目标进程中得到这些函数地址的话,也可以使用第17章第6节中动态获取API入口地址的方法——搜寻目标进程的整个地址空间来得到函数的入口地址,不过那样会使程序复杂不少,为了让例子简单起见,例子代码中暂时使用上一段假设中的结论,也就是用本地这3个函数的地址代替远程函数的地址。
完成获取API函数地址的工作后,就可以调用_WinMain函数来创建窗口了,注意在_WinMain函数的后面不能使用ExitProcess函数来结束进程,这样会将整个Explorer.exe结束掉,必须使用ret指令来结束线程。
_WinMain函数和其他的相关代码改编自第4章中的FirstWindow.asm程序,只不过是将程序中所有涉及全局变量的指令全部改成了以ebx为基址的指令而已。另外,在所有的子程序的开始处,都加上了call/pop/sub这3句用来计算偏移差的指令。
完成远程线程的代码后,现在来看如何将这段代码装载到目标进程中,装载代码存放在RemoteThread.asm中:
; RemoteThread.asm
; 向 Explorer.exe 进程中嵌入一段远程执行的代码
;-----------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff RemoteThread.asm
; rc RemoteThread.rc
; Link /subsystem:windows RemoteThread.obj RemoteThread.res.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib
include Macro.inc ; 数据段
.data?
lpLoadLibrary dword ?
lpGetProcAddress dword ?
lpGetModuleHandle dword ?
dwProcessID dword ?
dwThreadID dword ?
hProcess dword ?
lpRemoteCode dword ?.const
szErrOpen byte '无法打开远程线程!',0
szDesktopClass byte 'Progman',0
szDesktopWindow byte 'Program Manager',0
szDllKernel byte 'Kernel32.dll',0
szLoadLibrary byte 'LoadLibraryA',0
szGetProcAddress byte 'GetProcAddress',0
szGetModuleHandle byte 'GetModuleHandleA',0.code
include RemoteCode.asm main proc invoke GetModuleHandle, addr szDllKernel mov ebx, eax invoke GetProcAddress, ebx, offset szLoadLibrary mov lpLoadLibrary, eax invoke GetProcAddress, ebx, offset szGetProcAddress mov lpGetProcAddress, eax invoke GetProcAddress, ebx, offset szGetModuleHandle mov lpGetModuleHandle, eax ;查找文件管理器窗口并获取进程ID,然后打开进程invoke FindWindow, addr szDesktopClass, addr szDesktopWindow invoke GetWindowThreadProcessId, eax, offset dwProcessID mov dwThreadID, eax invoke OpenProcess, PROCESS_CREATE_THREAD or PROCESS_VM_OPERATION or \PROCESS_VM_WRITE, FALSE, dwProcessID .if eax mov hProcess, eax ;在进程中分配空间并将执行代码拷贝过去,然后创建一个远程线程invoke VirtualAllocEx, hProcess, NULL, REMOTE_CODE_LENGTH, MEM_COMMIT, PAGE_EXECUTE_READWRITE .if eax mov lpRemoteCode, eax invoke WriteProcessMemory, hProcess, lpRemoteCode, \offset REMOTE_CODE_START, REMOTE_CODE_LENGTH, NULL invoke WriteProcessMemory, hProcess, lpRemoteCode, \offset lpLoadLibrary, sizeof dword * 3, NULL mov eax, lpRemoteCode add eax, offset _RemoteThread - offset REMOTE_CODE_START invoke CreateRemoteThread, hProcess, NULL, 0, eax, 0, 0, NULL invoke CloseHandle, eax .endif invoke CloseHandle, hProcess .else invoke MessageBox, NULL, addr szErrOpen, NULL, MB_OK or MB_ICONWARNING .endif invoke ExitProcess, 0
main endp
end main
运行结果:
在程序开始,首先获取LoadLibrary,GetProcAddress和GetModuleHandle函数的入口地址,这些地址将在远程线程中被用来获取其他API的入口地址。
接下来就是打开Explorer.exe进程的操作,程序通过GetWindowThreadProcessId和OpenProcess函数来完成,函数中使用的窗口句柄是桌面的窗口句柄,因为桌面就是由文件管理器进程创建的,桌面的窗口类是“Progman”,窗口名称是“Program Manager”,使用FindWindow函数就可以很方便地找到它。在打开进程的时候必须包括对应的权限,PROCESS_CREATE_THREAD权限将允许创建远程线程,PROCESS_VM_OPERATION权限将允许在目标进程中分配内存并将远程代码写到里面。
程序使用VirtualAllocEx函数在目标进程中分配内存,在分配内存的时候,内存属性必须指定为PAGE_EXECUTE_READWRITE,这样分配到的内存可以有执行和读写的权限,分配方式必须指定为MEM_COMMIT,这样内存才会被提交到物理内存中去。
在分配到内存以后,程序使用WriteProcessMemory将远程代码写入,然后再一次将LoadLibrary,GetProcAddress和GetModuleHandle函数的地址写入到远程代码的数据段中。并不将这3个函数的地址存放到远程代码中一次性写入的原因在于:远程代码(包括远程代码使用的数据)是定义在本地的代码段中的,而本地的代码段是只读的,我们无法在本地对它们进行写入初始化数据的操作,所以只好采用远程写入的方式。
最后,用CreateRemoteThread函数创建远程线程后就万事大吉了。编译链接以后运行可执行文件可看到,窗口正常出现了,一眼看上去,这个窗口和别的窗口没有任何不同!但是在任务管理器中却没有多出任何新的进程。
假如远程线程不是这样“招摇过市”地创建了一个窗口,而是在后台偷偷地运行的话,大家能不能从各种蛛丝马迹来发现它的存在呢?反正笔者是找不到它的,因为它仅存在于目标进程的内存中,并不对应任何磁盘文件,当远程线程被执行的时候,唯一可以发现的就是Explorer.exe进程中的活动线程多了一个,使用的内存多了一点而已,但是活动线程用工具软件查看也只能看到一个线程ID,又怎么知道这个线程不是Explorer.exe进程自己的呢?
以上代码用在不合适的地方可能产生危害,笔者公开这段代码,其目的就是希望能对有害代码的防治起到积极的作用,请读者负责任地使用这段代码。