【Windows】《深入浅出Windows API程序设计:核心编程篇》笔记-Chapter4-进程

第4章 进程

进程(Process)是系统中正在运行的一个可执行文件,可执行文件一旦运行就成为进程,是一个动态的概念,是一个活动的实体。进程是一个正在运行的可执行文件所使用资源的总和,包括虚拟地址空间、代码、数据、对象句柄、环境变量等。当一个可执行文件被同时多次执行时,产生的是多个进程,虽然它们由同一个文件执行而来,但是它们的虚拟地址空间相互隔离,类似于不同的可执行文件在执行。

运行一个可执行文件就是创建了一个进程。本章将学习如何在程序中动态创建一个进程,多个进程之间如何进行通信,进程的枚举和调试等。

4.1 创建进程

运行一个程序可以调用ShellExecute函数。该函数可以打开的对象包括可执行文件、文档文件和网址等:

复制代码
HINSTANCE ShellExecute(
     _In_opt_ HWND     hwnd,            // 父窗口的句柄,不需要可以设置为NULL
     _In_opt_ LPCTSTR lpOperation,    // 要执行的操作
     _In_      LPCTSTR lpFile,          // 要操作的文件或文件夹
     _In_opt_ LPCTSTR lpParameters,    // 如果lpFile指定的是可执行文件,为命令行参数
     _In_opt_ LPCTSTR lpDirectory,    // 要操作的文件的默认工作目录,可以设置为NULL
     _In_      INT      nShowCmd);     // 显示标志,同ShowWindow函数的nCmdShow参数
  • lpOperation参数指定要执行的操作,可以是表4.1所示的值之一。

表4.1

操作类型 含义
open 打开lpFile参数指定的文件或文件夹(由关联的默认程序打开)
explore 通过资源管理器打开lpFile参数指定的文件夹
edit 启动编辑器(通常是记事本)并打开lpFile参数指定的文档,如果不是文档文件,则函数调用会失败
print 打印lpFile参数指定的文件,如果不是文档文件,则函数调用会失败
find 从lpDirectory参数指定的目录开始搜索
  • lpFile参数指定要操作的文件或文件夹,如果指定的是一个可执行文件,lpParameters参数可以设置为命令行参数,在其他情况下可以设置为NULL。

  • lpDirectory参数指定要操作的文件的默认工作目录,如果设置为NULL,则使用当前目录。

如果函数执行成功,则返回值是一个大于32的HINSTANCE类型值;如果函数执行失败,则可能返回表4.2所示的值。

表4.2

常量 含义
0 操作系统内存或资源不足
ERROR_FILE_NOT_FOUND 找不到指定的文件
ERROR_PATH_NOT_FOUND 找不到指定的路径
ERROR_BAD_FORMAT .exe文件无效
SE_ERR_ACCESSDENIED 操作系统拒绝对指定文件的访问
SE_ERR_ASSOCINCOMPLETE 文件名关联不完整或无效
SE_ERR_DLLNOTFOUND 找不到指定的动态链接库
SE_ERR_FNF 找不到指定的文件
SE_ERR_NOASSOC 没有与给定文件扩展名关联的应用程序,如果尝试打印不可打印的文件,也会返回该错误
SE_ERR_OOM 没有足够的内存来完成操作
SE_ERR_PNF 找不到指定的路径
SE_ERR_SHARE 发生共享冲突

要运行指定的程序还可以调用WinExec函数,提供该函数是为了与16位Windows系统兼容,新应用程序应该使用CreateProcess函数:

复制代码
UINT WINAPI WinExec(
     _In_ LPCSTR lpCmdLine,      // 要执行的应用程序的命令行(文件名加可选参数)
     _In_ UINT    uCmdShow);      // 显示标志,同ShowWindow函数的nCmdShow参数

如果函数执行成功,则返回值大于31。

要运行一个可执行文件,也可以调用CreateProcess函数,该函数为指定名称的可执行文件创建进程以及进程的主线程。如果某个进程创建了一个新的进程,被创建的进程称为子进程,创建它的进程称为父进程,子进程可以从父进程继承环境变量以及其他对象,还可以在子进程中继续创建孙进程。子进程独立于父进程运行,当系统终止一个进程时,不会终止该进程创建的任何子进程。CreateProcess的函数原型如下:

复制代码
BOOL WINAPI CreateProcess(
     _In_opt_      LPCTSTR                  lpApplicationName,     // 要执行的可执行文件的名称
     _Inout_opt_    LPTSTR                    lpCommandLine,          // 命令行参数
     _In_opt_       LPSECURITY_ATTRIBUTES    lpProcessAttributes,    // 新进程的安全属性结构,可为NULL
     _In_opt_        LPSECURITY_ATTRIBUTES    lpThreadAttributes,    // 新进程的主线程安全属性结构,  
                                                        // 可为NULL
     _In_            BOOL                        bInheritHandles,          // 调用进程的一些句柄是否可被新  
                                                        // 进程继承
     _In_           DWORD                       dwCreationFlags,          // 创建标志和进程的优先级,可设为0
     _In_opt_      LPVOID                     lpEnvironment,            // 指向新进程的环境块的指针,  
                                                        // 可为NULL
     _In_opt_      LPCTSTR                    lpCurrentDirectory,       // 指定新进程的当前目录,可为NULL
     _In_           LPSTARTUPINFO           lpStartupInfo,          // 指向STARTUPINFO或  
                                                        // STARTUPINFOEX结构
     _Out_         LPPROCESS_INFORMATION    lpProcessInformation);// 指向PROCESS_INFORMATION  
                                                        // 结构的指针
  • lpApplicationName参数指定要执行的可执行文件的名称,如果该参数设置为NULL,则需要在lpCommandLine参数中包含可执行文件名称。

  • lpCommandLine参数指定命令行参数,如果lpApplicationName参数设置为NULL,则命令行参数的第一个组成部分用来指定可执行文件名(位于lpCommandLine参数的最前面并由空格符与后面的字符串分开);如果两个参数都不为NULL,则lpApplicationName参数指定可执行文件名称,lpCommandLine参数指定命令行参数。lpCommandLine参数是一个Inout_opt可选的输入输出参数,因此应该指定为一个缓冲区指针,而不能使用常字符串。例如:

    TCHAR szCommandLine[MAX_PATH] = TEXT("Notepad D:\Test.txt");
    CreateProcess(NULL, szCommandLine, ...);

函数将运行记事本程序并打开D:\Test.txt文件。

如上面的代码所示,本书建议把lpApplicationName参数设置为NULL,只使用lpCommandLine一个参数,命令行参数的第一个组成部分用来指定可执行文件名,如果可执行文件名没有扩展名,默认扩展名是.exe,可执行文件名既可以指定完整路径名也可以指定相对路径名。如果是相对路径名,则函数按以下顺序查找可执行文件。

(1)调用进程.exe文件所在的目录。

(2)调用进程的当前目录。

(3)Windows系统目录。

(4)Windows目录。

(5)PATH环境变量中列出的目录。

如果使用lpApplicationName参数指定可执行文件名,则必须指定扩展名,系统不会自动假定文件名有一个.exe扩展名,可执行文件名既可以指定完整路径名也可以指定相对路径名,如果是相对路径名,函数会假定可执行文件位于当前目录,函数不会在其他任何目录中查找文件,如果当前目录中不存在指定的可执行文件,函数调用会失败。如果使用lpApplicationName参数指定可执行文件名,应该指定一个完整路径名,例如下面的代码:

复制代码
TCHAR szCommandLine[MAX_PATH] = TEXT(" D:\\Test.txt");  // 字符串开始有一个空格
CreateProcess(TEXT("C:\\Windows\\System32\\Notepad.exe"), szCommandLine, ...);

要运行一个批处理文件,需要将lpApplicationName参数设置为cmd.exe,并将lpCommandLine参数设置为:/c加上批处理文件的名称。

  • lpProcessAttributes参数是一个指向新进程的安全属性结构的指针,通常设置为NULL。

  • lpThreadAttributes参数是一个指向新进程的主线程安全属性结构的指针,通常设置为NULL。

  • bInheritHandles参数指定调用进程的一些可继承句柄是否可以被新进程继承,通常设置为FALSE。

  • dwCreationFlags参数指定创建标志和进程的优先级。创建标志可以是表4.3所示的值的组合。

表4.3

创建标志 含义
DEBUG_PROCESS 0x00000001 父进程希望调试子进程以及子进程将来生成的所有进程,在任何一个子进程(被调试程序)中发生特定事件时,会通知父进程(调试器)
DEBUG_ONLY_THIS_PROCESS 0x00000002 类似于DEBUG_PROCESS,但是只有在子进程中发生特定事件时,父进程才会得到通知,如果子进程又生成了新的进程,那么在这些孙进程中发生特定事件时,调试器不会收到通知
CREATE_SUSPENDED 0x00000004 新进程的主线程处于挂起状态,后续可以通过调用ResumeThread函数恢复运行
EXTENDED_STARTUPINFO_PRESENT 0x00080000 使用扩展的启动信息创建进程,lpStartupInfo参数是一个指向STARTUPINFOEX结构的指针
CREATE_UNICODE_ENVIRONMENT 0x00000400 默认情况下,lpEnvironment参数指向的环境块使用ANSI字符,指定该标志后,将使用Unicode字符
CREATE_DEFAULT_ERROR_MODE 0x04000000 默认情况下,新进程会继承调用进程的错误模式,设置该标志后,新进程不会继承调用进程的错误模式,将使用默认错误模式
DETACHED_PROCESS 0x00000008 用于控制台用户界面(Console User Interface,CUI)进程,默认情况下,新进程使用父进程的控制台窗口,指定该标志后,新进程不会使用父进程的控制台窗口,程序可以通过调用AllocConsole函数来创建一个新的控制台,该标志不能与CREATE_NEW_CONSOLE一起使用
CREATE_NEW_CONSOLE 0x00000010 用于控制台用户界面进程,默认情况下,新进程使用父进程的控制台窗口,指定该标志后,系统会为新进程新建一个控制台窗口,该标志不能与DETACHED_PROCESS一起使用
CREATE_NO_WINDOW 0x08000000 用于控制台用户界面进程,指定该标志表示不要创建控制台窗口,如果不是控制台应用程序,或者与CREATE_NEW_CONSOLE或DETACHED_PROCESS一起使用,则忽略该标志

进程的优先级可以设置为表4.4所示的值。

表4.4

优先级 含义
REALTIME_PRIORITY_CLASS 0x00000100 实时,具有最高优先级的进程。实时优先级类进程的线程优先于所有其他进程的线程,通常用于一些执行重要任务的系统进程
HIGH_PRIORITY_CLASS 0x00000080 高,用于执行时间紧迫的任务。该类进程的线程抢占正常或空闲优先级类进程线程的时间片,应该谨慎使用
ABOVE_NORMAL_PRIORITY_CLASS 0x00008000 高于标准,进程的优先级高于NORMAL_PRIORITY_CLASS,低于HIGH_PRIORITY_CLASS
NORMAL_PRIORITY_CLASS 0x00000020 标准,正常优先级
BELOW_NORMAL_PRIORITY_CLASS 0x00004000 低于标准,进程的优先级高于IDLE_PRIORITY_CLASS,低于NORMAL_PRIORITY_CLASS
IDLE_PRIORITY_CLASS 0x00000040 低,进程的线程仅在系统空闲时运行,并且可以被运行在更高优先级类中的任何进程的线程抢占,用于一些不太重要的程序例如屏幕保护程序
  • lpEnvironment参数是一个指向新进程的环境块的指针,通常可以设置为NULL表示新进程会继承父进程的环境变量。

  • lpCurrentDirectory参数指定新进程的当前目录,通常可以设置为NULL表示新进程将具有和调用进程相同的当前驱动器和目录。

  • lpStartupInfo参数是一个指向STARTUPINFO或STARTUPINFOEX结构的指针:

    typedef struct _STARTUPINFO {
    DWORD cb; // 该结构的大小
    LPTSTR lpReserved; // 保留字段,必须为NULL
    LPTSTR lpDesktop; // 桌面的名称,通常设置为NULL
    LPTSTR lpTitle; // 控制台窗口的标题
    DWORD dwX; // 窗口左上角的X坐标,以像素为单位,屏幕坐标
    DWORD dwY; // 窗口左上角的Y坐标,以像素为单位,屏幕坐标
    DWORD dwXSize; // 窗口的宽度,以像素为单位
    DWORD dwYSize; // 窗口的高度,以像素为单位
    DWORD dwXCountChars; // 控制台窗口中屏幕缓冲区的宽度,以字符列为单位
    DWORD dwYCountChars; // 控制台窗口中屏幕缓冲区的高度,以字符行为单位
    DWORD dwFillAttribute; // 控制台窗口的文本颜色和背景颜色
    DWORD dwFlags; // 位掩码,指定该结构的哪个字段有效
    WORD wShowWindow; // 显示标志,同ShowWindow函数的nCmdShow参数
    WORD cbReserved2; // 保留字段,必须为0
    LPBYTE lpReserved2; // 保留字段,必须为NULL
    HANDLE hStdInput; // 标准输入句柄,默认是键盘缓冲区
    HANDLE hStdOutput; // 标准输出句柄,默认是控制台窗口的缓冲区
    HANDLE hStdError; // 标准错误句柄,默认是控制台窗口的缓冲区
    } STARTUPINFO, *LPSTARTUPINFO;

    typedef struct _STARTUPINFOEX {
    STARTUPINFO StartupInfo; // STARTUPINFO结构的指针
    PPROC_THREAD_ATTRIBUTE_LIST lpAttributeList; // 属性列表,该列表由
    } STARTUPINFOEX, *LPSTARTUPINFOEX; // InitializeProcThreadAttributeList
    // 函数创建

要设置扩展属性,请使用STARTUPINFOEX结构并在dwCreationFlags参数中指定EXTENDED_STARTUPINFO_PRESENT标志。

dwFlags字段是一个位掩码,指定该结构的哪个字段有效,常用的标志如表4.5所示。

表4.5

标志 含义
STARTF_USESIZE dwXSize和dwYSize字段有效
STARTF_USESHOWWINDOW wShowWindow字段有效
STARTF_USEPOSITION dwX和dwY字段有效
STARTF_USECOUNTCHARS dwXCountChars和dwYCountChars字段有效
STARTF_USEFILLATTRIBUTE dwFillAttribute字段有效
STARTF_USESTDHANDLES hStdInput、hStdOutput和hStdError字段有效

通常情况下并不需要新进程的窗口有特殊之处,只需调用GetStartupInfo函数获取当前进程的STARTUPINFO结构并传递给CreateProcess函数即可:

复制代码
VOID WINAPI GetStartupInfo(_Out_ LPSTARTUPINFO lpStartupInfo);  // STARTUPINFO结构的指针
  • lpProcessInformation参数是一个指向PROCESS_INFORMATION结构的指针,该结构返回新进程的进程句柄、主线程句柄、进程ID和主线程ID:

    typedef struct _PROCESS_INFORMATION {
    HANDLE hProcess;
    HANDLE hThread;
    DWORD dwProcessId;
    DWORD dwThreadId;
    } PROCESS_INFORMATION, *PPROCESS_INFORMATION, *LPPROCESS_INFORMATION;

在创建一个新的进程时,系统会为新进程创建一个进程内核对象和一个线程内核对象。在创建时,每个内核对象的引用计数为1,在CreateProcess函数返回前,系统会使用完全访问权限来打开进程内核对象和线程内核对象,并将句柄放入PROCESS_INFORMATION结构的hProcess和hThread字段中,在内部打开这两个内核对象会导致引用计数再加1,因此当函数返回后每个内核对象的引用计数变为2。如果不需要这两个内核对象句柄,则应该及时调用CloseHandle函数关闭句柄。

在创建一个进程内核对象时,系统会为该对象分配一个独一无二的ID;在创建一个线程内核对象时,系统同样会为该对象分配一个独一无二的ID。进程ID(Process ID,PID)和线程ID共享同一个号码池,因此进程和线程不可能有相同的ID,系统中也不会有其他内核对象的ID与进程、线程ID相同。打开任务管理器查看详细信息,可以看到系统中有一个ID为0的系统空闲进程(System Idle Process),该进程时刻显示系统空闲CPU百分比,系统空闲进程的线程数量始终等于计算机的CPU数量,实际上系统空闲进程是系统虚构出来的一个进程。

如果一个程序要使用ID来跟踪进程和线程,需要注意的是,进程和线程ID会被系统重用。例如在创建一个进程后,系统会初始化一个进程内核对象,假设系统为进程内核对象分配的ID为5276,此时如果再创建一个新的进程内核对象,系统不会将同一个ID号分配给它。但是,如果第一个进程内核对象已经释放,系统就可以将5276分配给下一个创建的进程内核对象。一个程序如果使用ID来引用进程或线程,可能是不可靠的。

虽然CreateProcess函数的参数比较多,但是大多数参数只需要设置为NULL即可,例如下面的代码将运行记事本程序并打开D:\Test.txt文件:

复制代码
TCHAR szCommandLine[MAX_PATH] = TEXT("Notepad D:\\Test.txt");
STARTUPINFO si = { sizeof(STARTUPINFO) };
PROCESS_INFORMATION pi = { 0 };

GetStartupInfo(&si);
if (CreateProcess(NULL, szCommandLine, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi))
{
     CloseHandle(pi.hThread);
     CloseHandle(pi.hProcess);
}

通过调用GetModuleHandle函数可以获取调用进程中指定模块的模块句柄(模块基地址):

复制代码
HMODULE WINAPI GetModuleHandle(_In_opt_ LPCTSTR lpModuleName);

lpModuleName参数指定为在调用进程的地址空间中加载的一个模块的名称,如果系统找到了指定名称的模块,则函数会返回该模块的句柄;如果没有找到指定模块,则返回值为NULL。

lpModuleName参数设置为NULL表示获取调用进程的可执行文件模块加载到的基地址。注意,即使调用GetModuleHandle(NULL)的代码是在一个DLL文件中,返回值仍然是可执行文件模块的基地址,而不是DLL文件模块的基地址。

GetModuleFileName函数用于获取当前进程中已加载模块的完整路径,要获取另一个进程中已加载模块的完整路径可以使用GetModuleFileNameEx函数:

复制代码
DWORD WINAPI GetModuleFileName(
     _In_opt_      HMODULE      hModule,        // 模块句柄,设置为NULL表示获取当前进程的可执行文件完整路径
     _Out_        LPTSTR      lpFilename,    // 返回模块hModule对应的文件完整路径
     _In_         DWORD        nSize);       // lpFilename缓冲区的大小,以字符为单位
DWORD WINAPI GetModuleFileNameEx(
     _In_         HANDLE   hProcess,     // 包含模块hModule的进程句柄
     _In_opt_ HMODULE   hModule,     // 模块句柄,设置为NULL表示获取hProcess进程的可执行文件完整路径
     _Out_        LPTSTR   lpFilename,    // 返回模块hModule对应的文件完整路径
     _In_         DWORD       nSize);       // lpFilename缓冲区的大小,以字符为单位

hProcess参数指定包含模块hModule的进程句柄,必须具有对该进程句柄的PROCESS_QUERY_INFORMATION和PROCESS_VM_READ访问权限。如果函数执行成功,则返回值是复制到缓冲区中的字符个数,不包括终止的空字符;如果函数执行失败,则返回值为0。GetModuleFileNameEx函数需要Psapi.h头文件。

在创建一个子进程后,父进程可能需要与子进程进行通信,CreateProcess函数调用会马上返回,但是子进程的加载以及初始化需要一些时间,这时可以调用WaitForInputIdle函数来等待子进程初始化完毕,该函数会挂起调用线程直到指定的进程初始化完毕或者函数等待超时返回。WaitForInputIdle的字面意思是等待输入空闲,即没有待处理的输入,正在等待用户输入:

复制代码
DWORD WINAPI WaitForInputIdle(
     _In_ HANDLE hProcess,       // 进程句柄,等待该进程输入空闲
     _In_ DWORD   dwMilliseconds);// 等待时间,以毫秒为单位,设置为INFINITE表示永久等待

函数的返回值及含义如表4.6所示。

表4.6

返回值 含义
0 等待成功
WAIT_TIMEOUT 超时时间已过
WAIT_FAILED 发生错误

4.2 多个进程间共享内核对象

可以通过调用GetCurrentProcess函数来得到当前进程的句柄,通过调用GetCurrentThread函数来得到调用线程的句柄:

复制代码
HANDLE WINAPI GetCurrentProcess(void);
HANDLE WINAPI GetCurrentThread(void);

GetCurrentProcess函数获取的是当前进程的伪句柄,进程伪句柄是一个代表当前进程的特殊常量,值通常为0xFFFFFFFF(−1)。同样,GetCurrentThread获取的是调用线程的伪句柄,线程伪句柄是一个代表当前线程的特殊常量,值通常为0xFFFFFFFE(−2)。伪句柄不能由子进程继承,当不再需要伪句柄时,不需要将其关闭,使用伪句柄调用CloseHandle函数无效。

可以通过调用GetCurrentProcessId函数来得到当前进程的ID,通过调用GetCurrentThreadId函数来得到调用线程的线程ID:

复制代码
DWORD WINAPI GetCurrentProcessId(void);
DWORD WINAPI GetCurrentThreadId(void);

可以通过调用GetProcessId函数来获取指定进程句柄的进程ID,通过调用GetThreadId来获取指定线程句柄的线程ID:

复制代码
DWORD WINAPI GetProcessId(_In_ HANDLE Process);
DWORD WINAPI GetThreadId(_In_ HANDLE Thread);

如果已经有一个进程的ID,则可以通过调用OpenProcess函数来获取该进程的句柄。调用OpenProcess函数成功打开一个进程句柄会导致进程对象引用计数加1,当不再需要进程句柄时应该调用CloseHandle函数关闭句柄:

复制代码
HANDLE WINAPI OpenProcess(
     _In_ DWORD    dwDesiredAccess,    // 对进程的访问权限
     _In_ BOOL       bInheritHandle,     // 返回的进程句柄是否可以被调用进程的子进程继承
     _In_ DWORD     dwProcessId);        // 进程ID

dwDesiredAccess参数指定对进程的访问权限,系统会根据目标进程的安全描述符检查dwDesiredAccess参数所指定的访问权限。要打开一个目标进程并获得完全访问权限,调用进程必须启用SE_DEBUG_NAME(#define SE_DEBUG_NAME TEXT("SeDebugPrivilege"))特权,后面会介绍调用进程如何启用该特权。常用的访问权限可以是表4.7所示的组合。

表4.7

常量 含义
PROCESS_QUERY_INFORMATION 0x0400 可以获取进程的相关信息,例如访问令牌、退出码和进程优先级
PROCESS_SET_INFORMATION 0x0200 可以设置进程的相关信息,例如进程优先级
PROCESS_SUSPEND_RESUME 0x0800 可以挂起或恢复进程
PROCESS_TERMINATE 0x0001 可以通过调用TerminateProcess函数终止进程
PROCESS_DUP_HANDLE 0x0040 可以通过调用DuplicateHandle函数复制句柄
PROCESS_VM_OPERATION 0x0008 可以修改进程的地址空间,例如写入内存、修改页面保护属性
PROCESS_VM_READ 0x0010 可以对进程的地址空间进行读取操作
PROCESS_VM_WRITE 0x0020 可以对进程的地址空间进行写入操作
PROCESS_CREATE_PROCESS 0x0080 可以创建进程
PROCESS_CREATE_THREAD 0x0002 可以创建线程
SYNCHRONIZE 0x00100000 可以调用等待函数等待进程终止
PROCESS_ALL_ACCESS 所有可能的访问权限

如果函数执行成功,则返回值是指定ID的进程句柄;如果函数执行失败,则返回值为NULL。如果打开系统空闲进程或csrss进程等系统关键进程,函数调用也会失败。

根据一个线程句柄,可以通过调用GetProcessIdOfThread来获取其所在进程的ID:

复制代码
DWORD WINAPI GetProcessIdOfThread(_In_ HANDLE  hThread);

我们经常需要创建、打开和处理各种内核对象,例如事件对象、文件对象、文件映射对象、互斥量对象、信号量对象、进程对象、线程对象、可等待的计时器对象等,每个内核对象是由系统内核分配和管理的一个小型数据结构,其结构成员维护着与对象有关的信息,少数成员例如安全描述符和引用计数等是所有对象共有的,其他大多数成员是不同类型的对象所特有的,例如,进程对象有一个进程ID、一个基本优先级和一个退出码,而文件对象有一个文件偏移、共享模式等。为了增强操作系统的可靠性,内核对象的句柄值是与进程相关的,如果把一个进程中的一个内核对象句柄值传递给另一个进程中的线程(通过某种进程间通信方式),那么另一个进程就会根据该句柄值在其当前进程句柄表中的索引来引用一个可能完全不同的内核对象。

很多时候我们需要在多个进程中共享同一个内核对象,例如利用文件映射对象可以在多个进程之间共享数据,事件对象、互斥量对象、信号量对象等通常可以用于不同进程或相同进程中的线程同步。如何在多个进程中共享同一个内核对象,具体方法如下。

(1)为内核对象命名,大部分创建内核对象的函数可以指定一个名称,使用这个名称可以在其他进程中打开该内核对象,内核对象的名称区分大小写,最多包含MAX_PATH个字符。

(2)当进程之间具有父子关系时,可以使用内核对象句柄继承。如果父进程有一个或多个内核对象句柄可以继承,那么父进程生成一个子进程后,子进程就可以访问父进程的这些内核对象。要想使用内核对象句柄继承,必须完成以下两个方面的工作。

  • 创建内核对象的函数通常都有一个SECURITY_ATTRIBUTES结构的参数,通常设置为NULL,表示使用默认的安全属性,返回的对象句柄不可以被调用进程的子进程继承,这里不可以再把SECURITY_ATTRIBUTES结构参数设置为NULL,可以按如下方式使用:

    SECURITY_ATTRIBUTES sa;
    sa.nLength = sizeof(SECURITY_ATTRIBUTES);
    sa.lpSecurityDescriptor = NULL;
    sa.bInheritHandle = TRUE;
    hHandle = Create内核对象(&sa, ...);

以上代码初始化了一个SECURITY_ATTRIBUTES结构,sa.lpSecurityDescriptor = NULL;表示内核对象使用默认的安全属性,bInheritHandle字段设置为TRUE,表示内核对象句柄可以被子进程继承。

  • 可以通过调用CreateProcess函数生成一个子进程,CreateProcess函数的bInheritHandles参数应该设置为TRUE,表示父进程的一些可继承句柄可以被子进程继承。如果子进程调用CreateProcess函数又生成了它自己的子进程并将bInheritHandles参数设置为TRUE,那么孙进程也会继承这些内核对象句柄,但是内核对象句柄的继承只会在生成子进程的时候发生。如果在父进程之后又创建了新的内核对象,并同样将它们的句柄设置为可以继承,则正在运行的子进程不会继承这些新句柄。继承内核对象句柄会导致引用计数加1,因此不需要时应该调用CloseHandle函数关闭句柄。

(3)调用DuplicateHandle函数复制内核对象句柄。

DuplicateHandle函数用于复制内核对象句柄,复制内核对象句柄会导致内核对象的引用计数加1:

复制代码
BOOL WINAPI DuplicateHandle(
     _In_   HANDLE    hSourceProcessHandle,    // 源进程句柄
     _In_   HANDLE    hSourceHandle,             // 源进程hSourceProcessHandle中的源内核对象句柄
     _In_   HANDLE    hTargetProcessHandle,    // 目标进程句柄
     _Out_  LPHANDLE lpTargetHandle,           // 目标内核对象句柄的指针
     _In_   DWORD     dwDesiredAccess,          // 新句柄的访问权限,可以设置为0
     _In_   BOOL      bInheritHandle,            // 新句柄是否可以被目标进程的子进程继承,通常设置为TRUE
     _In_   DWORD     dwOptions);                // 操作选项,可以设置为0

dwOptions参数指定操作选项,可以设置为0,或者表4.8所示的值的组合。

表4.8

常量 含义
DUPLICATE_CLOSE_SOURCE 0x00000001 调用函数后关闭源句柄,如果指定该标志,函数调用后内核对象的引用计数不变
DUPLICATE_SAME_ACCESS 0x00000002 复制句柄与源句柄具有相同的访问权限,指定该标志的情况下会忽略dwDesiredAccess参数,通常可以指定该标志

函数复制源进程hSourceProcessHandle中的源内核对象句柄hSourceProcessHandle到lpTargetHandle参数中,函数执行成功,lpTargetHandle句柄值就是相对于目标进程hTargetProcessHandle的一个句柄值,hSourceHandle和lpTargetHandle引用的是同一个内核对象,例如复制的是文件句柄,那么两个句柄的当前文件偏移始终相同。

源进程或目标进程(或既是源进程又是目标进程)或第三个进程都可以调用DuplicateHandle函数复制内核对象句柄,如果不是在目标进程中调用DuplicateHandle函数,则需要一些进程间通信机制将目标句柄传递到目标进程中。lpTargetHandle句柄值是相对于目标进程hTargetProcessHandle的一个句柄值,不可以在调用进程中调用CloseHandle函数关闭目标句柄,因为该句柄值对应的可能是调用进程中另一个不同的内核对象,即lpTargetHandle句柄值只对目标进程有意义。

DuplicateHandle函数可以用于在32位和64位进程之间复制句柄,生成的句柄是32位或64位大小。

4.3 进程终止

进程可以通过以下3种方式终止。

(1)主线程的入口点函数返回(强烈推荐的方式)。

(2)进程中的任何一个线程调用ExitProcess函数(要尽量避免使用这种方式)。

(3)另一个进程中的线程调用TerminateProcess函数(要尽量避免使用这种方式)。

当一个进程终止时,系统会执行以下操作。

(1)进程中的所有其他线程都被标记为终止。

(3)该进程分配的所有资源都将被释放。

(3)所有内核对象句柄都会被关闭,引用计数会减1,内核对象会不会释放取决于引用计数是否为0。

(4)进程代码从内存中删除。

(5)进程对象的退出码从STILLL_ACTIVE变为主线程函数的返回值,或ExitProcess、TerminateProcess函数设置的退出码(系统创建进程对象时的退出码被设置为STILL_ACTIVE)。

(6)进程对象的状态变为有信号状态。

通过主线程的入口点函数返回并结束一个进程是正常、自然的结束方式,系统会执行正确的清理工作,例如线程分配的C++对象都会调用析构函数释放,为线程栈分配的内存也会得到释放。

ExitProcess函数用于终止调用进程及其所有线程:

复制代码
VOID WINAPI ExitProcess(_In_ UINT uExitCode);   // uExitCode会被设置为进程和所有线程的退出码

ExitProcess函数不会返回,因为进程已经终止,如果在ExitProcess函数调用后还有其他代码,那么这些代码永远不会执行。

实际上,当主线程的入口点函数(WinMain、wWinMain、main或wmain)返回时,会返回到C/C++运行库启动代码中,后者将正确清理进程使用的全部C/C++运行时资源,释放C/C++运行时资源后,C/C++运行库启动代码将显式调用ExitProcess函数,并将入口点函数的返回值传递给它。不管进程中是否还有其他线程正在运行,只要程序的主线程入口点函数返回,C/C++运行库启动代码就会调用ExitProcess函数来终止进程。注意,如果在入口点函数中调用的是ExitThread函数,而不是调用ExitProcess或者入口点函数返回,程序的主线程将停止执行,但是只要进程中还有其他线程正在运行,进程就不会终止。

C/C++应用程序应该避免显式调用ExitProcess函数,因为不能调用C/C++运行库启动代码来执行清理工作,C++对象的析构函数也得不到执行。

ExitProcess函数只能用于终止调用进程,TerminateProcess函数则可以终止调用进程或其他进程及其所有线程:

复制代码
BOOL WINAPI TerminateProcess(
     _In_ HANDLE hProcess,    // 进程句柄,必须具有对该进程的PROCESS_TERMINATE访问权限
     _In_ UINT    uExitCode); // uExitCode会被设置为进程和所有线程的退出码

TerminateProcess函数是异步的,如果需要确保进程已终止,可以调用WaitForSingleObject函数。

TerminateProcess函数用于无条件地终止一个进程,被终止的进程得不到自己要被终止的通知,进程不能被正确清理,并且不能阻止它自己被强行终止(除非通过一些安全机制),只有在无法通过其他方式强制一个进程退出时,才应该调用该函数。

调用ExitProcess或TerminateProcess函数,进程可能没有机会执行清理工作,但是操作系统会在进程终止后进行彻底清理,确保不会泄露任何系统资源。

要获取一个进程的退出码可以调用GetExitCodeProcess函数:

复制代码
BOOL WINAPI GetExitCodeProcess(
     _In_   HANDLE  hProcess,          // 进程句柄,必须具有对该进程的PROCESS_QUERY_INFORMATION访问权限
     _Out_ LPDWORD lpExitCode);     // 返回进程退出码

该函数会立即返回,如果函数执行成功但是进程尚未终止,则返回的退出码为STILL_ACTIVE。

4.4 进程间通信

Windows提供了多种机制,使应用程序之间能够快速、方便地共享数据和信息,这些机制包括RPC、COM、OLE、DDE、Windows消息(例如WM_COPYDATA)、剪贴板、邮件槽(Mailslot)、管道(Pipe)、套接字(socket)等。前面已经学习了通过内存映射文件在多个进程间共享数据,剪贴板和套接字是后面章节的话题,本节主要介绍通过WM_COPYDATA、邮件槽、管道进行进程间通信(InterProcess Communication,IPC)的方法。

首先我们再来回顾一下SendMessage和PostMessage函数。SendMessage函数将指定的消息发送到一个或多个窗口,为指定的窗口调用窗口过程,直到窗口过程处理完消息后函数才返回,返回值为指定消息处理的结果,即当窗口过程处理完该消息后,Windows才把控制权交还给紧跟在SendMessage调用的下一条语句;与SendMessage函数不同的是,PostMessage函数将一个消息投递到一个线程的消息队列后立即返回,即把消息发送到指定窗口句柄所在线程的消息队列再由线程来分发。

另外需要注意的是,异步发送消息函数PostMessage(还有SendNotifyMessage和SendMessageCallback)的wParam和lParam参数中不能传递指针,因为这些函数立即返回,函数返回后指针指向的内存会被释放,函数调用会失败,调用GetLastError函数返回ERROR_MESSAGE_SYNC_ONLY,即消息只能与同步操作一起使用。

4.4.1 WM_COPYDATA

调用发送消息函数SendMessage、SendNotifyMessage、SendMessageCallback和PostMessage、PostThreadMessage向其他进程发送消息时,只能发送系统消息(0~WM_USER 1)。通过发送消息的方式在进程间通信通常使用WM_COPYDATA (0x004A)消息,该消息的wParam参数是目标进程的窗口句柄,lParam参数是一个指向COPYDATASTRUCT结构的指针,该结构包含要传递的数据。目标进程窗口过程处理完WM_COPYDATA消息后应返回TRUE。COPYDATASTRUCT结构在WinUser.h头文件中定义如下:

复制代码
typedef struct tagCOPYDATASTRUCT {
     ULONG_PTR              dwData;    // 传递给目标进程的数据,32位或64位无符号整型
     DWORD                   cbData;    // lpData字段指向的数据的大小,以字节为单位
     _Field_size_bytes_(cbData) PVOID lpData;      
                                          // 传递给目标进程的数据的指针,可以设置为NULL
} COPYDATASTRUCT, * PCOPYDATASTRUCT;
  • lpData字段可以指定为指向任何数据类型的指针。如果只需要传递一个简单的数据类型,可以只设置dwData字段。有时可能需要根据不同场合传递不同类型的数据结构,这时可以把dwData字段设置为代表不同数据类型的一个标志值,而lpData字段则指向具体的数据结构,在目标进程中根据dwData字段的不同标志值,强制转换lpData字段为对应数据结构的指针即可。

  • 在发送WM_COPYDATA消息时,系统会根据cbData字段指定的大小分配一块共享内存,并把lpData字段指向的数据复制到共享内存中,然后将共享内存映射到目标进程,即调用进程和目标进程的lpData字段指向的是同一块内存,只是作了不同的映射,目标进程处理完WM_COPYDATA消息后,系统会释放共享内存。需要注意的是,目标进程不可以修改共享内存的数据,共享内存仅在消息处理期间有效。如果目标进程需要在消息处理完毕后访问共享内存,应该提前把共享内存数据复制到自己所属进程的缓冲区中。

接下来实现一个示例程序 CopyDataDemo,程序运行效果如原书的图 4.1 所示,为了演示通过 WM_COPYDATA消息在两个进程之间通信的效果,需要两个程序,一个用于发送数据,另一个用于接收数据。

如果用户在"发送数据端"按下"发送个人信息"按钮,程序会向"接收数据端"发送包含个人信息分组框中3个编辑控件的内容;如果用户在"发送数据端"按下"发送考试成绩"按钮,程序会向"接收数据端"发送包含考试成绩分组框中3个编辑控件的内容。如前所述,有时可能需要根据不同场合传递不同类型的数据结构,这时可以把dwData字段设置为代表不同数据类型的一个标志值,而lpData字段则指向具体的数据结构。为此,我们定义2个代表不同数据结构的常量标志,并分别定义代表个人信息(姓名、年龄、存款)和考试成绩(语文、数学、英语)的2个数据结构,因为"发送数据端"和"接收数据端"程序都需要这些定义,因此定义在一个头文件中,DataStructure.h头文件的内容如下:

复制代码
#pragma once

// 常量定义
#define PERSONDATA  1
#define SCOREDATA   2

// 数据结构定义
typedef struct _PersonStruct
{
     TCHAR   m_szName[32];       // 姓名
     int     m_nAge;                 // 年龄
     double  m_dMoney;           // 存款
}PersonStruct, *PPersonStruct;

typedef struct _ScoreStruct
{
     double  m_dChinese;         // 语文
     double  m_dMath;            // 数学
     double  m_dEnglish;         // 英语
}ScoreStruct, *PScoreStruct;

因为需要"发送数据端"和"接收数据端"两个程序,为了便于管理,我们在一个解决方案中建立2个项目,首先创建一个CopyDataDemo项目,然后添加CopyDataDemo.cpp源文件,以及对话框资源等;CopyDataDemo项目作为"发送数据端",还需要一个"接收数据端",在VS左侧的解决方案资源管理器视图中,用鼠标右键单击解决方案,然后选择添加→新建项目,再创建一个 CopyDataDemo_Receive项目,然后添加CopyDataDemo_Receive.cpp源文件,以及对话框资源等,现在一个解决方案中有2个项目。如果需要编译其中一个项目,用鼠标右键单击项目名称,然后选择设为启动项目即可。

CopyDataDemo.cpp源文件的内容如下:

复制代码
#include <windows.h>
#include <tchar.h>
#include "resource.h"
#include "DataStructure.h"

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     COPYDATASTRUCT cds = { 0 };
     PersonStruct ps = { 0 };
     ScoreStruct ss = { 0 };
     TCHAR szBuf[32] = { 0 };
     HWND hwndTarget;

     switch (uMsg)
     {
     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_PERSON:
               // 查找具有指定类名和窗口名的窗口
               hwndTarget = FindWindow(TEXT("#32770"), TEXT("接收数据"));
               if (hwndTarget)
               {
                    // 获取姓名、年龄、存款
                    GetDlgItemText(hwndDlg, IDC_EDIT_NAME, ps.m_szName, _countof (ps.m_ szName));
                    ps.m_nAge = GetDlgItemInt(hwndDlg, IDC_EDIT_AGE, NULL, FALSE);
                    GetDlgItemText(hwndDlg, IDC_EDIT_MONEY, szBuf, _countof(szBuf));
                    ps.m_dMoney = _ttof(szBuf);

                    // 发送WM_COPYDATA消息
                    cds.dwData = PERSONDATA;
                    cds.cbData = sizeof(PersonStruct);
                    cds.lpData = &ps;
                    SendMessage(hwndTarget, WM_COPYDATA, (WPARAM)hwndTarget, (LPARAM) &cds);
               }
               break;

          case IDC_BTN_SCORE:
               // 查找具有指定类名和窗口名的窗口句柄
               hwndTarget = FindWindow(TEXT("#32770"), TEXT("接收数据"));
               if (hwndTarget)
               {
                    // 获取语文、数学、英语成绩
                    GetDlgItemText(hwndDlg, IDC_EDIT_CHINESE, szBuf, _countof(szBuf));
                    ss.m_dChinese = _ttof(szBuf);
                    GetDlgItemText(hwndDlg, IDC_EDIT_MATH, szBuf, _countof(szBuf));
                    ss.m_dMath = _ttof(szBuf);
                    GetDlgItemText(hwndDlg, IDC_EDIT_ENGLISH, szBuf, _countof(szBuf));
                    ss.m_dEnglish = _ttof(szBuf);

                    // 发送WM_COPYDATA消息
                    cds.dwData = SCOREDATA;
                    cds.cbData = sizeof(ScoreStruct);
                    cds.lpData = &ss;
                    SendMessage(hwndTarget, WM_COPYDATA, (WPARAM)hwndTarget, (LPARAM) &cds);
               }
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

CopyDataDemo_Receive.cpp源文件的内容如下:

复制代码
#include <windows.h>
#include <strsafe.h>
#include "resource.h"
#include "../CopyDataDemo/DataStructure.h"

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     PCOPYDATASTRUCT pCDS;
     PPersonStruct pPS;
     PScoreStruct pSS;
     TCHAR szBuf[128] = { 0 };

     switch (uMsg)
     {
     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;

     case WM_COPYDATA:
          pCDS = (PCOPYDATASTRUCT)lParam;
          if (pCDS->dwData == PERSONDATA)
          {
               pPS = (PPersonStruct)(pCDS->lpData);
               StringCchPrintf(szBuf, _countof(szBuf), 
                    TEXT("个人信息:\n姓名:%s\n年龄:%d\n存款:%.2lf"),
                    pPS->m_szName, pPS->m_nAge, pPS->m_dMoney);
               MessageBox(hwndDlg, szBuf, TEXT("个人信息"), MB_OK);
          }
          else if (pCDS->dwData == SCOREDATA)
          {
               pSS = (PScoreStruct)pCDS->lpData;
               StringCchPrintf(szBuf, _countof(szBuf), 
                    TEXT("考试成绩:\n语文:%6.2lf\n数学:%6.2lf\n英语:%6.2lf"),
                    pSS->m_dChinese, pSS->m_dMath, pSS->m_dEnglish);
               MessageBox(hwndDlg, szBuf, TEXT("考试成绩"), MB_OK);
          }
          return TRUE;
     }

     return FALSE;
}

在"发送数据端"程序中,单击"发送个人信息"或"发送考试成绩"按钮时,首先应该确定"接收数据端"程序是否正在运行,FindWindow函数用于查找具有指定窗口类名和窗口标题的窗口:

复制代码
HWND WINAPI FindWindow(
     _In_opt_ LPCTSTR lpClassName,       // 窗口类名,不区分大小写
     _In_opt_ LPCTSTR lpWindowName);     // 窗口标题,不区分大小写
  • 窗口类名可以是通过调用RegisterClass或RegisterClassEx函数注册的窗口类名称,也可以是任何预定义的控件类名称,例如对话框的窗口类名为#32770。lpClassName参数可以设置为NULL,函数将查找窗口标题与lpWindowName参数匹配的任何窗口。

  • lpWindowName参数指定窗口标题,该参数也可以设置为NULL,函数将查找窗口类名与lpClassName参数匹配的任何窗口。

如果函数执行成功,则返回值是具有指定窗口类名和窗口标题的窗口的句柄;如果函数执行失败,则返回值为NULL。

因为简单易用,以前我们一直使用wsprintf函数格式化字符串,但是本例中需要输出浮点数,由于wsprintf函数不支持输出浮点数,所以这里使用更安全的C运行库新增函数StringCchPrintf,也是微软建议使用的格式化字符串函数,该函数需要strsafe.h头文件:

复制代码
HRESULT StringCchPrintf(
     _Out_ LPTSTR  pszDest,      // 目标缓冲区,接收从pszFormat及其参数创建的格式化的以零结尾的字符串
     _In_  size_t  cchDest,      // 目标缓冲区的大小,以字符为单位,允许的最大字符数为STRSAFE_MAX_CCH
     _In_  LPCTSTR pszFormat,    // 格式字符串
     _In_          ...);             // 不定数目的参数

4.4.2 管道

管道是一种通过共享内存进行进程间通信的技术,管道有两个端点,单向管道允许一个进程从管道的一端写入数据,另一个进程可以从管道的另一端读取数据,而双向(也称双工)管道的每一个端点都可以读写数据。

管道有两种类型:匿名管道和命名管道。匿名管道是一种未命名的单向管道,通常用于在同一台计算机的父进程和子进程之间传输数据,匿名管道不支持异步读取和写入操作。命名管道可以用于在同一台计算机上的进程之间或网络上不同计算机上的进程之间进行通信,创建管道的进程称为管道服务器,连接到管道的进程称为管道客户端,命名管道提供的是一种对等通信,任何进程都可以充当服务器或客户端。

1.匿名管道

CreatePipe函数用于创建一个匿名管道:

复制代码
BOOL WINAPI CreatePipe(
     _Out_    PHANDLE                 hReadPipe,            // 返回管道的读取句柄
     _Out_    PHANDLE                 hWritePipe,           // 返回管道的写入句柄
     _In_opt_ LPSECURITY_ATTRIBUTES lpPipeAttributes,    // 含义同其他内核对象的安全属性结构
     _In_      DWORD                   nSize);              // 管道缓冲区的大小,以字节为单位,
                                                               // 可以设置为0表示使用默认大小

匿名管道创建成功后,对于管道的操作就如同读写文件一样简单,可以调用WriteFile函数向管道的写入句柄写入数据,调用 ReadFile 函数从管道的读取句柄读取数据。匿名管道的原理如原书的图4.2所示。

当不再需要读写数据时,应该调用CloseHandle函数关闭匿名管道的读取句柄hReadPipe和写入句柄hWritePipe。

匿名管道是一种未命名的单向管道,通常用于在同一台计算机的父进程和子进程之间传输数据,因此在创建匿名管道时,CreatePipe函数的lpPipeAttributes参数通常不能再指定为NULL,因为我们希望子进程可以继承这个管道内核对象,按如下方式创建一个匿名管道:

复制代码
HANDLE hReadPipe, hWritePipe;
SECURITY_ATTRIBUTES sa = { sizeof(sa) };
sa.bInheritHandle = TRUE;
CreatePipe(&hReadPipe, &hWritePipe, &sa, 0);

Ping.exe是Windows系统目录中的一个网络诊断工具,用于检查网络是否连通,读者可以打开cmd,输入ping IP地址(或网址)查看效果,如原书的图4.3所示。

接下来实现一个示例程序AnonymousPipes,程序运行效果如原书的图4.4所示。

程序调用CreateProcess函数创建一个子进程,命令行参数为ping网址,把子进程ping的输出重定向到匿名管道的写入句柄,然后在程序中读取匿名管道的读取句柄,并显示到编辑控件中。AnonymousPipes.cpp源文件的内容如下:

复制代码
#include <windows.h>
#include <tchar.h>
#include <strsafe.h>
#include "resource.h"

// 常量定义
#define BUF_SIZE    1024

// 全局变量
HWND g_hwndDlg;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);

// 线程函数
DWORD WINAPI ThreadProc(LPVOID lpParameter);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;
          // 初始化编辑控件
          SetDlgItemText(hwndDlg, IDC_EDIT_URL, TEXT("www.baidu.com"));
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_PING:
               // 创建线程
               CloseHandle(CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL));
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     // 创建匿名管道
     HANDLE hReadPipe, hWritePipe;
     SECURITY_ATTRIBUTES sa = { sizeof(sa) };
     sa.bInheritHandle = TRUE;
     CreatePipe(&hReadPipe, &hWritePipe, &sa, 0);

     // 创建子进程,把子进程Ping的输出重定向到匿名管道的写入句柄
     STARTUPINFO si = { sizeof(si) };
     si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
     si.hStdOutput = si.hStdError = hWritePipe;
     si.wShowWindow = SW_HIDE;
     PROCESS_INFORMATION pi;

     // 命令行参数拼接为:Ping www.baidu.com的形式
     TCHAR szCommandLine[MAX_PATH] = TEXT("Ping ");
     TCHAR szURL[256] = { 0 };
     GetDlgItemText(g_hwndDlg, IDC_EDIT_URL, szURL, _countof(szURL));
     StringCchCat(szCommandLine, _countof(szCommandLine), szURL);

     // 创建Ping子进程
     if (CreateProcess(NULL, szCommandLine, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi))
     {
          CHAR szBuf[BUF_SIZE + 1] = { 0 };
          CHAR szOutput[BUF_SIZE * 8] = { 0 };
          DWORD dwNumOfBytesRead;

          CloseHandle(pi.hThread);
          CloseHandle(pi.hProcess);

          while (TRUE)
          {
               // 读取匿名管道的读取句柄
               ZeroMemory(szBuf, sizeof(szBuf));
               ReadFile(hReadPipe, szBuf, BUF_SIZE, &dwNumOfBytesRead, NULL);
               if (dwNumOfBytesRead == 0)
                    break;

               // Ping控制台的输出是ANSI编码,因此使用StringCchCatA和SetDlgItemTextA
               // 把读取到的数据追加到szOutput缓冲区
               StringCchCatA(szOutput, _countof(szOutput), szBuf);
               // 显示到编辑控件中
               SetDlgItemTextA(g_hwndDlg, IDC_EDIT_CONTENT, szOutput);
          }
     }

     CloseHandle(hReadPipe);
     CloseHandle(hWritePipe);
     return 0;
}

具体代码参见Chapter4\AnonymousPipes项目。

也可以创建两个匿名管道,以达到每个进程都可以读写管道的目的,两个匿名管道的通信原理如原书的图4.5所示。

2.命名管道

命名管道提供一种对等通信,任何进程都可以充当服务器或客户端,可以通过单向或双向命名管道的方式在一个服务器和多个客户端之间进行通信。

服务器进程可以通过调用CreateNamedPipe函数创建命名管道内核对象的一个实例,一个命名管道的最大实例个数为PIPE_UNLIMITED_INSTANCES(255),服务器进程和每一个客户端进程的通信都需要一个命名管道实例句柄,即同一时刻可以连接的最大客户端个数是PIPE_UNLIMITED_INSTANCES (255),命名管道的所有实例共享同一个命名管道,但是每个实例都有自己的缓冲区和句柄。有了命名管道实例句柄,可以通过调用ConnectNamedPipe函数等待客户端连接。当完成与一个客户端进程的通信以后,服务器进程可以通过调用DisconnectNamedPipe函数断开连接,从而重新连接到新的客户端进程。客户端进程可以通过调用CreateFile或CallNamedPipe函数连接到一个正在等待连接的命名管道上,连接成功以后可以通过调用读写文件函数读写命名管道。关于命名管道的用法,请自行参考MSDN。

4.4.3 邮件槽

邮件槽是一种进程间单向通信机制,创建并拥有邮件槽的进程称为邮件槽服务器,邮件槽服务器可以从邮件槽中读取数据,其他进程可以打开邮件槽并写入数据,这些进程称为邮件槽客户端。对于邮件槽的操作就如同读写文件一样简单,邮件槽工作方式有三大特点。

(1)单向通信。创建邮件槽的服务器只能读取消息,不能写入消息,而客户端则刚好相反。如果需要某一端应用程序同时具备读取与写入的双向功能,那么两端的应用程序可以分别创建两个邮件槽。

(2)广播消息。如果域中有若干计算机使用同样的名称创建邮件槽,那么邮件槽客户端可以一次性向所有的同名邮件槽服务器发送消息,域是共享组名的工作站和服务器组。

(3)数据报传输。邮件槽对消息的传输为数据报方式,即客户端只负责数据的发送,而服务器端并不回应客户端发送的数据是否已经接收到。

CreateMailslot函数用于创建一个指定名称的邮件槽内核对象:

复制代码
HANDLE WINAPI CreateMailslot(
     _In_      LPCTSTR                   lpName,           // 邮件槽名称,格式:\\.\mailslot\
                                                      // mailslotname
     _In_      DWORD                     nMaxMessageSize, // 可以写入邮件槽的单条消息的最大大小,
                                                 // 以字节为单位
     _In_      DWORD                       lReadTimeout,    // 等待写入邮件槽的时间,以毫秒为单位
     _In_opt_ LPSECURITY_ATTRIBUTES lpSecurityAttributes);  // 含义同其他内核对象的安全属性结构
  • lpName参数指定邮件槽的名称,格式为:\.\mailslot\mailslotname

  • nMaxMessageSize参数指定可以写入邮件槽的单条消息的最大大小,以字节为单位,如果设置为0表示任意大小。

  • lReadTimeout参数指定等待写入邮件槽的时间,以毫秒为单位,设置为0表示如果邮件槽中没有可以读取的消息则立即返回,或者设置为MAILSLOT_WAIT_FOREVER(−1)表示一直等待到邮件槽中有消息可以读取。

如果函数执行成功,则返回一个邮件槽句柄,服务器可以通过该句柄从邮件槽中读取数据;如果函数执行失败,则返回值为INVALID_HANDLE_VALUE。如果指定名称的邮件槽已经存在,则函数调用会失败,返回值为INVALID_HANDLE_VALUE,调用GetLastError函数返回错误代码ERROR_ALREADY_EXISTS。

客户端可以通过调用CreateFile函数打开邮件槽以获得一个邮件槽句柄,然后可以调用WriteFile函数向邮件槽写入数据。

服务器在调用ReadFile函数读取数据前,应该先调用GetMailslotInfo函数来确定邮件槽中是否有消息:

复制代码
BOOL WINAPI GetMailslotInfo(
     _In_       HANDLE    hMailslot,        // 邮件槽句柄
     _Out_opt_ LPDWORD lpMaxMessageSize,    // 返回邮件槽的单条消息的最大大小,以字节为单位,可设置为NULL
     _Out_opt_ LPDWORD lpNextSize,        // 返回下一条消息(也就是待读取消息)的大小,以字节为单位
     _Out_opt_ LPDWORD lpMessageCount,    // 返回待读取消息的总数量
     _Out_opt_ LPDWORD lpReadTimeout);  // 返回读取操作可以等待的时间,以毫秒为单位,可设置为NULL
  • hMailslot参数指定邮件槽句柄,即先前调用CreateMailslot函数返回的句柄。

  • lpMaxMessageSize参数返回邮件槽的单条消息的最大大小,以字节为单位,该值可能大于或等于在创建邮件槽的CreateMailslot函数的nMaxMessageSize参数中指定的值,该参数通常可以设置为NULL。

  • lpNextSize参数返回下一条消息(也就是待读取消息)的大小,以字节为单位,如果返回值为MAILSLOT_NO_MESSAGE(−1),则表示已经没有待读取消息。

  • lpMessageCount参数返回待读取消息的总数量。

  • lpReadTimeout参数返回读取操作可以等待的时间,以毫秒为单位,该参数通常可以设置为NULL。

当不再需要读写数据时,应该调用CloseHandle函数关闭邮件槽句柄。

邮件槽的简单示例程序可以参见Mailslot项目。

4.5 进程枚举

4.5.1 TlHelp32系列函数

要枚举系统中当前正在运行的进程列表,最常用的是TlHelp32系列函数。可以通过调用CreateToolhelp32Snapshot函数捕获系统中当前正在运行的所有进程的快照,得到一个进程列表,然后可以调用Process32First和Process32Next函数遍历快照中记录的进程列表,上述函数需要TlHelp32.h头文件。

CreateToolhelp32Snapshot函数原型如下:

复制代码
HANDLE WINAPI CreateToolhelp32Snapshot(
     _In_ DWORD dwFlags,             // 标志,用于指定快照中需要返回的对象
     _In_ DWORD th32ProcessID);      // 进程ID,设置为0表示当前进程
  • dwFlags参数是一个标志,用于指定快照中需要返回的对象,CreateToolhelp32Snapshot函数可以返回系统中当前正在运行的进程列表、线程列表,也可以返回一个进程的堆列表、模块列表,dwFlags参数可以是表4.9所示的值的组合。

表4.9

标志 含义
TH32CS_SNAPPROCESS 0x00000002 枚举系统中的所有进程
TH32CS_SNAPTHREAD 0x00000004 枚举系统中的所有线程
TH32CS_SNAPHEAPLIST 0x00000001 枚举th32ProcessID参数指定进程中的所有堆
TH32CS_SNAPMODULE 0x00000008 枚举th32ProcessID参数指定进程中的所有模块。在32位进程中指定该标志获取的是32位模块,在64位进程中指定该标志获取的是64位模块,要在64位进程中同时获取32位模块可以同时指定TH32CS_SNAPMODULE32标志
TH32CS_SNAPMODULE32 0x00000010 枚举64位进程中的32位模块,该标志可以与TH32CS_SNAPMODULE或TH32CS_SNAPALL结合使用
TH32CS_SNAPALL 包括系统中所有进程列表、线程列表,以及th32ProcessID参数中指定进程的堆和模块的列表,等效于`TH32CS_SNAPPROCESS
TH32CS_INHERIT 0x80000000 表示快照句柄可继承
  • th32ProcessID 参数指定进程 ID,当 dwFlags 参数指定是 TH32CS_SNAPHEAPLIST、TH32CS_SNAPMODULE、TH32CS_SNAPMODULE32或TH32CS_SNAPALL标志时才会用到该参数,设置为0表示当前进程。如果dwFlags参数指定的是TH32CS_SNAPPROCESS或TH32CS_SNAPTHREAD标志用于枚举系统中的所有进程列表或线程列表,则会忽略该参数。

如果函数执行成功,则返回值是指定快照的句柄;如果函数执行失败,则返回值为INVALID_HANDLE_VALUE。不再需要快照句柄时应该调用CloseHandle函数关闭句柄。

Process32First函数用于获取快照中第一个进程的信息,在成功获取到第一个进程的信息后,可以继续循环调用Process32Next函数获取快照中其他进程的信息,直到函数返回FALSE,Process32Next函数每次获取一个进程的信息:

复制代码
BOOL WINAPI Process32First(
     _In_   HANDLE           hSnapshot,     // 快照句柄
     _Out_ LPPROCESSENTRY32 lppe);         // 用于返回进程信息的PROCESSENTRY32结构
BOOL WINAPI Process32Next(
     _In_   HANDLE           hSnapshot,
     _Out_ LPPROCESSENTRY32 lppe);

lppe参数是一个指向PROCESSENTRY32结构的指针,函数在该结构中返回一个进程的信息,该结构在TlHelp32.h头文件中定义如下:

复制代码
typedef struct tagPROCESSENTRY32 {
     DWORD      dwSize;                   // 该结构的大小
     DWORD      cntUsage;                 // 该字段不再使用,始终为0
     DWORD      th32ProcessID;            // 进程ID
     ULONG_PTR th32DefaultHeapID;       // 该字段不再使用,始终为0
     DWORD      th32ModuleID;                 // 该字段不再使用,始终为0
     DWORD      cntThreads;                   // 进程启动的线程个数
     DWORD      th32ParentProcessID;      // 进程的父进程ID
     LONG       pcPriClassBase;               // 进程创建的线程的基本优先级
     DWORD      dwFlags;                      // 该字段不再使用,始终为0
     TCHAR      szExeFile[MAX_PATH];      // 进程的可执行文件的名称
} PROCESSENTRY32, *PPROCESSENTRY32;

th32ProcessID字段返回进程ID,有了进程ID即可通过调用OpenProcess函数获得进程句柄,利用进程句柄对进程进行各种操作。

szExeFile字段返回不带路径的可执行文件名称,要获取一个进程对应的可执行文件的完整路径可以使用QueryFullProcessImageName函数:

复制代码
BOOL WINAPI QueryFullProcessImageName(
     _In_       HANDLE hProcess,     // 进程句柄,必须具有对该进程的PROCESS_QUERY_INFORMATION访问权限
     _In_       DWORD  dwFlags,      // 返回的路径格式,通常设置为0
     _Out_      LPTSTR lpExeName,    // 用于返回可执行文件路径的缓冲区
     _Inout_    PDWORD lpdwSize);    // 指定缓冲区的大小,返回复制到缓冲区中的字符数(不包括终止的空字符)

dwFlags参数指定返回的路径格式,可以设置为0表示普通格式,或者设置为PROCESS_NAME_NATIVE表示本机系统路径格式,例如:\Device\HarddiskVolume3\Windows\System32\svchost.exe。

也可以使用GetModuleFileNameEx函数,hModule参数设置为NULL表示获取hProcess进程的可执行文件完整路径,例如GetModuleFileNameEx(hProcess, NULL, szPath, _countof(szPath))。如果是为了获取一个进程对应的可执行文件的完整路径,使用QueryFullProcessImageName函数的效率更高一些。

通常可以使用下面的代码枚举系统中的所有进程:

复制代码
HANDLE hSnapshot;
PROCESSENTRY32 pe = { sizeof(PROCESSENTRY32) };
BOOL bRet;

hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
{
     MessageBox(g_hwndDlg, TEXT("CreateToolhelp32Snapshot函数调用失败"), TEXT("提示"), MB_OK);
     return FALSE;
}

bRet = Process32First(hSnapshot, &pe);
while (bRet)
{
     // 对pe.th32ProcessID进程进行处理

     bRet = Process32Next(hSnapshot, &pe);
}

CloseHandle(hSnapshot);
return TRUE;

接下来实现一个枚举系统中所有进程列表的示例程序ProcessList,程序运行效果如原书的图4.6所示,进程列表使用一个列表视图控件进行展示,每一个列表项表示一个进程的相关信息,包括进程的可执行文件的图标、进程名称、进程ID、父进程ID、进程的可执行文件的完整路径。用鼠标右键单击一个列表项,会弹出一个快捷菜单,其中包括刷新进程列表、结束该进程、打开文件所在位置、暂停进程和恢复进程等菜单项。

ProcessList.cpp源文件的内容如下:

复制代码
#include <windows.h>
#include <Commctrl.h>
#include <TlHelp32.h>
#include <Psapi.h>
#include <tchar.h>
#include "resource.h"

#pragma comment(lib, "Comctl32.lib")

#pragma comment(linker,"\"/manifestdependency:type='win32' \
     name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
     processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

// 全局变量
HINSTANCE g_hInstance;
HWND g_hwndDlg;                         // 对话框窗口句柄
HIMAGELIST g_hImagListSmall;        // 列表视图控件所用的图像列表

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 显示进程列表
BOOL GetProcessList();
// 暂停、恢复进程
VOID SuspendProcess(DWORD dwProcessId, BOOL bSuspend);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     g_hInstance = hInstance;

     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     LVCOLUMN lvc = { 0 };
     POINT pt = { 0 };
     int nSelected, nRet;
     LVITEM lvi = { 0 };
     TCHAR szProcessName[MAX_PATH] = { 0 }, szProcessID[16] = { 0 }, szBuf[MAX_ PATH] = { 0 };
     HANDLE hProcess;
     HMENU hMenu;
     BOOL bRet = FALSE;

     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;

          // 设置列表视图控件的扩展样式
          SendMessage(GetDlgItem(hwndDlg, IDC_LIST_PROCESS), LVM_SETEXTENDEDLI STVIEWSTYLE, 0, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES);

          // 设置列标题:进程名称、进程ID、父进程ID、可执行文件路径
          lvc.mask = LVCF_SUBITEM | LVCF_WIDTH | LVCF_TEXT;
          lvc.iSubItem = 0; lvc.cx = 150; lvc.pszText = TEXT("进程名称");
          SendMessage(GetDlgItem(hwndDlg, IDC_LIST_PROCESS), LVM_INSERTCOLUMN, 0, (LPARAM)&lvc);
          lvc.iSubItem = 1; lvc.cx = 60; lvc.pszText = TEXT("进程ID");
          SendMessage(GetDlgItem(hwndDlg, IDC_LIST_PROCESS), LVM_INSERTCOLUMN, 1, (LPARAM)&lvc);
          lvc.iSubItem = 2; lvc.cx = 60; lvc.pszText = TEXT("父进程ID");
          SendMessage(GetDlgItem(hwndDlg, IDC_LIST_PROCESS), LVM_INSERTCOLUMN, 2, (LPARAM)&lvc);
          lvc.iSubItem = 3; lvc.cx = 260; lvc.pszText = TEXT("可执行文件路径");
          SendMessage(GetDlgItem(hwndDlg, IDC_LIST_PROCESS), LVM_INSERTCOLUMN, 3, (LPARAM)&lvc);

          // 为列表视图控件设置图像列表
          g_hImagListSmall = ImageList_Create(GetSystemMetrics(SM_CXSMICON),
               GetSystemMetrics(SM_CYSMICON), ILC_MASK | ILC_COLOR32, 500, 0);
          SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_SETIMAGELIST,
               LVSIL_SMALL, (LPARAM)g_hImagListSmall);

          // 显示进程列表
          GetProcessList();
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case ID_REFRESH:
               // 显示进程列表
               GetProcessList();
               break;

          case ID_TERMINATE:
               // 结束选定进程
               nSelected = SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS),  LVM_GETSELECTIONMARK, 0, 0);

               // 确定要结束进程吗
               lvi.iItem = nSelected; lvi.iSubItem = 0;
               lvi.mask = LVIF_TEXT;
               lvi.pszText = szProcessName;
               lvi.cchTextMax = _countof(szProcessName);
               SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEM, 0, (LPARAM)&lvi);
               wsprintf(szBuf, TEXT("确定要结束 %s 进程吗?"), lvi.pszText);
               nRet = MessageBox(hwndDlg, szBuf, TEXT("结束进程"), MB_OKCANCEL |  MB_ICONINFORMATION | MB_DEFBUTTON2);
               if (nRet == IDCANCEL)
                    return FALSE;

               // 获取进程句柄
               lvi.iSubItem = 1;
               lvi.pszText = szProcessID;
               lvi.cchTextMax = _countof(szProcessID);
               SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEM, 0, (LPARAM)&lvi);
               hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, _ttoi(lvi.pszText));
               if (hProcess)
               {
                    // 结束进程
                    bRet = TerminateProcess(hProcess, 0);
                    CloseHandle(hProcess);
               }

               if (!bRet)
               {
                    wsprintf(szBuf, TEXT("结束 %s 进程失败"), szProcessName);
                    MessageBox(hwndDlg, szBuf, TEXT("错误提示"), MB_OK);
               }
               else
               {
                    // 删除列表项
                    SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_DELETEITEM, nSelected, 0);
               }
               break;

          case ID_OPEN:
               // 打开文件所在位置
               nSelected = SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS),  LVM_GETSELECTIONMARK, 0, 0);
               lvi.iItem = nSelected; lvi.iSubItem = 3;
               lvi.mask = LVIF_TEXT;
               lvi.pszText = szProcessName;
               lvi.cchTextMax = _countof(szProcessName);
               SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEM, 0, (LPARAM)&lvi);

               // 打开父目录并选定指定文件的命令:Explorer.exe /select,文件名称
               wsprintf(szBuf, TEXT("/select,%s"), lvi.pszText);
               ShellExecute(hwndDlg, TEXT("open"), TEXT("Explorer.exe"), szBuf, NULL, SW_SHOW);
               break;

          case ID_SUSPEND:
               // 暂停进程
               nSelected = SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS),  LVM_GETSELECTIONMARK, 0, 0);
               lvi.iItem = nSelected; lvi.iSubItem = 1;
               lvi.mask = LVIF_TEXT;
               lvi.pszText = szProcessID;
               lvi.cchTextMax = _countof(szProcessID);
               SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEM, 0, (LPARAM)&lvi);
               SuspendProcess(_ttoi(lvi.pszText), TRUE);
               break;

          case ID_RESUME:
               // 恢复进程
               nSelected = SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS),  LVM_GETSELECTIONMARK, 0, 0);
               lvi.iItem = nSelected; lvi.iSubItem = 1;
               lvi.mask = LVIF_TEXT;
               lvi.pszText = szProcessID;
               lvi.cchTextMax = _countof(szProcessID);
               SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEM, 0, (LPARAM)&lvi);
               SuspendProcess(_ttoi(lvi.pszText), FALSE);
               break;

          case IDCANCEL:
               ImageList_Destroy(g_hImagListSmall);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;

     case WM_NOTIFY:
          if (((LPNMHDR)lParam)->idFrom == IDC_LIST_PROCESS && ((LPNMHDR)lParam)-> code == NM_RCLICK)
          {
               if (((LPNMITEMACTIVATE)lParam)->iItem < 0)
                    return FALSE;

               // 如果可执行文件路径一列为空,则禁用结束该进程、打开文件所在位置、暂停进程、结束进程菜单
               nSelected = SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_ GETSELECTIONMARK, 0, 0);
               hMenu = LoadMenu(g_hInstance, MAKEINTRESOURCE(IDR_MENU));
               lvi.iItem = nSelected; lvi.iSubItem = 3;
               lvi.mask = LVIF_TEXT;
               lvi.pszText = szProcessName;
               lvi.cchTextMax = _countof(szProcessName);
               SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEM, 0, (LPARAM)&lvi);
               if (_tcsicmp(lvi.pszText, TEXT("")) == 0)
               {
                    EnableMenuItem(hMenu, ID_TERMINATE, MF_BYCOMMAND | MF_DISABLED);
                    EnableMenuItem(hMenu, ID_OPEN, MF_BYCOMMAND | MF_DISABLED);
                    EnableMenuItem(hMenu, ID_SUSPEND, MF_BYCOMMAND | MF_DISABLED);
                    EnableMenuItem(hMenu, ID_RESUME, MF_BYCOMMAND | MF_DISABLED);
               }

               // 弹出快捷菜单
               GetCursorPos(&pt);
               TrackPopupMenu(GetSubMenu(hMenu, 0), TPM_LEFTALIGN | TPM_TOPALIGN, pt.x, pt.y, 0, hwndDlg, NULL);
          }
          return TRUE;
     }

     return FALSE;
}

BOOL GetProcessList()
{
     HANDLE hSnapshot;
     PROCESSENTRY32 pe = { sizeof(PROCESSENTRY32) };
     BOOL bRet;
     HANDLE hProcess;
     TCHAR szPath[MAX_PATH] = { 0 };
     TCHAR szBuf[16] = { 0 };
     DWORD dwLen;
     SHFILEINFO fi = { 0 };
     int nImage;
     LVITEM lvi = { 0 };

     // 删除图像列表中的所有图像
     ImageList_Remove(g_hImagListSmall, -1);
     // 删除所有列表项
     SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_DELETEALLITEMS, 0, 0);

     hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
     if (hSnapshot == INVALID_HANDLE_VALUE)
     {
          MessageBox(g_hwndDlg, TEXT("CreateToolhelp32Snapshot函数调用失败"), TEXT ("提示"), MB_OK);
          return FALSE;
     }

     bRet = Process32First(hSnapshot, &pe);
     while (bRet)
     {
          nImage = -1;
          ZeroMemory(szPath, sizeof(szPath));
          hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pe.th32ProcessID);
          if (hProcess)
          {
               // 获取可执行文件路径
               dwLen = _countof(szPath);
               QueryFullProcessImageName(hProcess, 0, szPath, &dwLen);
               // 获取程序图标
               SHGetFileInfo(szPath, 0, &fi, sizeof(SHFILEINFO), SHGFI_ICON | SHGFI_SMALLICON);
               if (fi.hIcon)
                     nImage = ImageList_AddIcon(g_hImagListSmall, fi.hIcon);

               CloseHandle(hProcess);
          }

          lvi.mask = LVIF_TEXT | LVIF_IMAGE;
          lvi.iItem = SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_GETITEMCOUNT, 0, 0);
          // 第1列,进程名称
          lvi.iSubItem = 0; lvi.pszText = pe.szExeFile; lvi.iImage = nImage;
          SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_INSERTITEM, 0, (LPARAM)&lvi);
          if (fi.hIcon)
               DestroyIcon(fi.hIcon);

          // 第2列,进程ID
          lvi.mask = LVIF_TEXT;
          lvi.iSubItem = 1; _itot_s(pe.th32ProcessID, szBuf, _countof(szBuf), 10); lvi.pszText = szBuf;
          SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_SETITEM, 0, (LPARAM)&lvi);

          // 第3列,父进程ID
          lvi.iSubItem = 2; _itot_s(pe.th32ParentProcessID, szBuf, _countof (szBuf), 10); lvi.pszText = szBuf;
          SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_SETITEM, 0, (LPARAM)&lvi);

          // 第4列,可执行文件路径
          lvi.iSubItem = 3; lvi.pszText = szPath;
          SendMessage(GetDlgItem(g_hwndDlg, IDC_LIST_PROCESS), LVM_SETITEM, 0, (LPARAM)&lvi);

          bRet = Process32Next(hSnapshot, &pe);
     }

     CloseHandle(hSnapshot);
     return TRUE;
}

VOID SuspendProcess(DWORD dwProcessId, BOOL bSuspend)
{
     HANDLE hSnapshot;
     THREADENTRY32 te = { sizeof(THREADENTRY32) };
     BOOL bRet;
     HANDLE hThread;

     hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
     if (hSnapshot == INVALID_HANDLE_VALUE)
          return;

     bRet = Thread32First(hSnapshot, &te);
     while (bRet)
     {
          if (te.th32OwnerProcessID == dwProcessId)
          {
               hThread = OpenThread(THREAD_SUSPEND_RESUME, FALSE, te.th32ThreadID);
               if (hThread)
               {
                    if (bSuspend)
                         SuspendThread(hThread);
                    else
                         ResumeThread(hThread);

                    // 关闭线程句柄
                    CloseHandle(hThread);
               }
           }

          bRet = Thread32Next(hSnapshot, &te);
     }

     CloseHandle(hSnapshot);
     return;
}

在WM_INITDIALOG消息中,程序设置列表视图控件的扩展样式;因为列表视图控件是LVS_REPORT报表视图样式,因此需要通过发送LVM_INSERTCOLUMN消息添加列标题;另外,每一个列表项的前面需要显示一个程序小图标,所以需要创建一个图像列表,并发送LVM_SETIMAGELIST消息把图像列表分配给列表视图控件;最后调用自定义函数GetProcessList显示进程列表。

在自定义函数GetProcessList中,为了获取进程的可执行文件的图标,使用了SHGetFileInfo函数,把该函数最后一个参数设置为SHGFI_ICON | SHGFI_SMALLICON表示要获取程序的小图标,图标句柄通过SHFILEINFO结构的hIcon字段返回,有了图标句柄即可通过调用ImageList_AddIcon函数将其添加到列表视图控件的图像列表中。

程序对WM_NOTIFY消息的处理就是弹出一个快捷菜单,快捷菜单的子菜单项包括刷新进程列表、结束该进程、打开文件所在位置、暂停进程和恢复进程。如果一个列表项中的可执行文件图标为空,或者可执行文件路径为空,通常说明该进程是重要的系统进程。作为普通用户没有足够的权限去调用OpenProcess函数打开重要的系统进程获取其信息,因此对于可执行文件路径一列为空的列表项,我们禁用快捷菜单的结束该进程、打开文件所在位置、暂停进程、结束进程等子菜单项。重要的系统进程不可以随意暂停或结束,否则会导致系统崩溃。

程序对WM_COMMAND消息的处理,以及对刷新进程列表、结束该进程菜单项的处理,都很简单,查看对打开文件所在位置菜单项的处理,即case ID_OPEN逻辑,我们希望在资源管理器中打开文件所在位置,并选中该文件,打开指定文件的父目录并选定指定文件的命令是"Explorer.exe /select,文件名称",构造该命令并调用ShellExecute函数即可。

对于暂停进程和恢复进程,微软公司并没有提供类似于SuspendProcess和ResumeProcess的API函数,因为Windows中不存在暂停和恢复进程的概念,系统从来不会为进程调度CPU时间。如果需要暂停一个进程中的所有线程,可以枚举该进程中的所有线程,指定TH32CS_SNAPTHREAD标志并调用CreateToolhelp32Snapshot函数可以获取系统中的所有线程列表。枚举线程列表使用的是Thread32First和Thread32Next函数,这两个函数需要一个THREADENTRY32结构参数,将进程ID与THREADENTRY32结构的th32OwnerProcessID字段进行比较即可确定一个线程是否属于指定进程,然后可以对该线程实施暂停或恢复操作,具体参见自定义函数SuspendProcess。关于THREADENTRY32结构和OpenThread函数的用法,读者可以自行参考MSDN。

注意,自定义函数SuspendProcess并不是100%安全的,CreateToolhelp32Snapshot函数获取的只是一个线程列表快照,在枚举完一个进程中的所有线程并暂停所有线程前,可能该进程中有一个线程又创建了新的线程,新线程在线程列表快照中并不存在。另外,在枚举过程中,可能进程中的一个线程已经销毁,而此时其他进程正好创建了一个线程,并且线程ID与刚刚销毁的线程ID相同,这种情况下,操作对象就是其他进程中的线程。当然,这种情况发生的几率比较小。

在调试进程时,也可以暂停该进程中的所有线程,调试器处理WaitForDebugEvent函数返回的调试事件时,Windows将暂停被调试进程中的所有线程,直到调试器调用ContinueDebugEvent函数,后续章节将介绍这些内容。

按Ctrl + F5组合键编译运行程序,程序列出了系统中所有正在运行的进程列表,但是对于个别重要的系统进程无法获取到程序图标和文件路径。通过Chapter4\ProcessList\Debug\ProcessList.exe路径双击运行程序,可以发现不显示图标和文件路径的列表项特别多,将列表视图拉到最底部可以看到ProcessList进程,其父进程是Explorer.exe资源管理器进程。如果在VS中按Ctrl + F5组合键编译运行程序,则ProcessList进程的父进程是devenv.exe即VS程序。VS程序作为ProcessList进程的父进程,具有对ProcessList进程的调试权限,调试权限是一个级别比较高的权限,因此进程列表中可以显示大部分列表项的程序图标和文件路径。另外,如果一个进程的父进程不是Explorer.exe,基本可以断定该进程正在被调试,这一点可以用于反调试、破解。

使一个程序具有调试权限,或者其他某个特权级别,需要提升权限。要为一个进程设置某个特权名称,首先需要调用OpenProcessToken函数打开与进程关联的访问令牌以获得一个访问令牌句柄hToken,然后调用LookupPrivilegeValue函数获取指定的特权名称在系统中的本地唯一标识符(LUID),最后调用AdjustTokenPrivileges函数启用访问令牌句柄hToken中指定的特权名称(LookupPrivilegeValue函数获取到的LUID代表特权名称),这些函数需要Psapi.h头文件,后面会介绍访问令牌。相关函数原型定义:

复制代码
BOOL OpenProcessToken(
     _In_          HANDLE      ProcessHandle,    // 进程句柄,获取该进程的访问令牌句柄
     _In_          DWORD       DesiredAccess,    // 请求的访问令牌访问类型,可以指定为TOKEN_ALL_ACCESS
     _Outptr_      PHANDLE     TokenHandle);     // 返回与ProcessHandle进程关联的访问令牌句柄

不再需要访问令牌句柄时应该调用CloseHandle函数关闭句柄。

复制代码
BOOL LookupPrivilegeValue(
     _In_opt_       LPCTSTR     lpSystemName,        // 系统名称,设置为NULL表示本地系统
     _In_            LPCTSTR     lpName,            // 特权名称,例如常量SE_DEBUG_NAME表示调试权限
     _Out_           PLUID      lpLuid);            // 返回特权lpName在lpSystemName系统中的本地唯一  
                                          // 标识符LUID
BOOL AdjustTokenPrivileges(
     _In_          HANDLE                TokenHandle,              // 进程的访问令牌句柄
     _In_          BOOL                  DisableAllPrivileges,    // 是否禁用所有特权,通常设置为FALSE
     _In_opt_      PTOKEN_PRIVILEGES    NewState,               // 指向TOKEN_PRIVILEGES结构的指针  
     _In_          DWORD                 BufferLength,           // PreviousState参数指向的缓冲区的  
                                                    // 大小,以字节为单位
     _Out_opt_     PTOKEN_PRIVILEGES    PreviousState,          // 返回进程的先前特权状态的TOKEN_   
                                                    // PRIVILEGES结构
     _Out_opt_     PDWORD                ReturnLength);          // 返回PreviousState参数所需的缓  
                                                    // 冲区大小

后面3个参数通常可以分别设置为0、NULL和NULL。需要注意的是,AdjustTokenPrivileges函数无法向访问令牌添加新权限,该函数只能启用或禁用访问令牌的现有权限。

TOKEN_PRIVILEGES结构在winnt.h头文件中定义如下:

复制代码
typedef struct _TOKEN_PRIVILEGES {
     DWORD PrivilegeCount;                           // Privileges数组的数组元素个数
     LUID_AND_ATTRIBUTES Privileges[ANYSIZE_ARRAY];// LUID_AND_ATTRIBUTES结构数组
} TOKEN_PRIVILEGES, * PTOKEN_PRIVILEGES;

typedef struct _LUID_AND_ATTRIBUTES {
     LUID Luid;              // 代表特权名称的本地唯一标识符LUID
     DWORD Attributes;     // SE_PRIVILEGE_ENABLED表示启用,SE_PRIVILEGE_REMOVED表示移除,None表示禁用
} LUID_AND_ATTRIBUTES, * PLUID_AND_ATTRIBUTES;

#define ANYSIZE_ARRAY  1

TOKEN_PRIVILEGES.Privileges字段是一个LUID_AND_ATTRIBUTES结构数组,每个LUID_AND_ATTRIBUTES结构包含代表特权名称的本地唯一标识符LUID和启用、禁用、移除指定特权的操作常量。

在ProcessList程序中创建一个自定义函数AdjustPrivileges,并在WM_INITDIALOG消息中调用以提升本进程的特权:

复制代码
BOOL AdjustPrivileges(HANDLE hProcess, LPCTSTR lpPrivilegeName)
{
     HANDLE hToken;
     TOKEN_PRIVILEGES tokenPrivileges;

     if (OpenProcessToken(hProcess, TOKEN_ALL_ACCESS, &hToken))
     {
          LUID luid;
          if (LookupPrivilegeValue(NULL, lpPrivilegeName, &luid))
          {
               tokenPrivileges.PrivilegeCount = 1;
               tokenPrivileges.Privileges[0].Luid = luid;
               tokenPrivileges.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
               if (AdjustTokenPrivileges(hToken, FALSE, &tokenPrivileges, 0, NULL, NULL))
                   return TRUE;
          }

          CloseHandle(hToken);
     }

     return FALSE;
}

本例为AdjustPrivileges函数声明的lpPrivilegeName参数设置了默认值SE_DEBUG_NAME调试特权。

枚举线程列表、模块列表、堆列表

CreateToolhelp32Snapshot函数的dwFlags参数设置为TH32CS_SNAPTHREAD可以枚举系统中的所有线程,函数返回系统中当前正在运行的所有线程列表。枚举线程列表使用的是Thread32First和Thread32Next函数,这两个函数需要一个THREADENTRY32结构参数。

dwFlags参数设置为TH32CS_SNAPMODULE可以枚举指定进程中的所有模块,函数返回指定进程中的所有模块列表。枚举模块列表使用的是Module32First和Module32Next函数,这两个函数需要一个MODULEENTRY32结构参数。

dwFlags参数设置为TH32CS_SNAPHEAPLIST可以枚举指定进程中的所有堆,函数返回指定进程中的所有堆列表。枚举堆列表使用的是Heap32First和Heap32Next函数,这两个函数需要一个HEAPENTRY32结构参数。

4.5.2 EnumProcesses函数

系统维护正在运行的进程的列表,通过调用EnumProcesses函数来获取这些进程的ID,并通过调用OpenProcess函数获取其进程句柄,有了进程句柄即可对其进行各种操作:

复制代码
BOOL EnumProcesses(
     _Out_      DWORD*     lpidProcess,    // 接收进程ID列表的DWORD数组
     _In_        DWORD       cb,              // lpidProcess数组的大小,以字节为单位
     _Out_      LPDWORD     lpcbNeeded);    // 返回的字节数,*lpcbNeeded / sizeof(DWORD)是枚举到的进程个数

该函数需要Psapi.h头文件。在调用EnumProcesses函数时无法预测有多少个进程,因此接收进程ID列表的DWORD数组应该足够大,例如1024。EnumProcesses函数调用成功,*lpcbNeeded/sizeof (DWORD)就是枚举到的进程个数。

通过EnumProcesses函数只能获取正在运行的进程ID列表,无法获取Process32First和Process32Next函数的PROCESSENTRY32结构参数等丰富信息,进程的一些具体信息需要用户自行设法获取。

NtQueryInformationProcess函数用于获取指定进程的信息,该函数存在于Ntdll.dll动态链接库中,需要Winternl.h头文件,还需要Ntdll.lib导入库。NtQueryInformationProcess函数原型如下:

复制代码
NTSTATUS WINAPI NtQueryInformationProcess(
     _In_      HANDLE             ProcessHandle,              // 要获取其信息的进程句柄
     _In_      PROCESSINFOCLASS ProcessInformationClass,    // 要获取的进程信息的类型
     _Out_     PVOID              ProcessInformation,        // 返回所请求信息的缓冲区
     _In_      ULONG      ProcessInformationLength,     // ProcessInformation缓冲区的大小,以字节为单位
     _Out_opt_ PULONG    ReturnLength);                 // 返回所请求信息的大小,可以设置为NULL

ProcessInformationClass参数指定要获取的进程信息的类型,PROCESSINFOCLASS是一个枚举类型:

复制代码
typedef enum _PROCESSINFOCLASS {
     ProcessBasicInformation = 0,
     ProcessDebugPort = 7,
     ProcessWow64Information = 26,
     ProcessImageFileName = 27,
     ProcessBreakOnTermination = 29
} PROCESSINFOCLASS;

ProcessInformationClass参数指定为不同的枚举值代表获取不同的进程信息,如表4.10所示。

表4.10

枚举值 含义
ProcessBasicInformation 这种情况下ProcessInformation参数需要指定为一个指向PROCESS_BASIC_INFORMATION结构的指针,其中包括指定进程是否正在被调试的进程环境块PEB结构、进程ID和父进程ID等字段。建议使用CheckRemoteDebuggerPresent函数来确定一个进程是否正在被调试,使用GetProcessId函数获取进程ID
ProcessDebugPort 这种情况下ProcessInformation参数需要指定为一个指向DWORD_PTR类型变量的指针,该值是该进程的调试器的端口号,非零值表示该进程正在Ring 3调试器的控制下运行。建议使用CheckRemoteDebuggerPresent或IsDebuggerPresent函数来确定一个进程是否正在被调试
ProcessWow64Information 这种情况下ProcessInformation参数需要指定为一个指向ULONG_PTR类型变量的指针,如果该值不为0,则说明该进程正在WOW64环境中运行;如果该值为0,则说明该进程未在WOW64环境中运行(WOW64是x86模拟器,允许Win32程序在64位Windows系统上运行)。建议使用IsWow64Process函数来确定一个进程是否在WOW64环境中运行
ProcessImageFileName 一般不用。这种情况下ProcessInformation参数需要指定为一个指向UNICODE_STRING结构的指针,其中包含该进程的文件名称字段。建议使用QueryFullProcessImageName或GetProcessImageFileName函数来获取一个进程的文件名称
ProcessBreakOnTermination 在这种情况下,ProcessInformation参数需要指定为一个指向ULONG类型变量的指针,如果该值不为 0,则说明该进程是系统关键进程(不可以随意结束进程,否则会导致系统崩溃);如果该值等于0,则说明该进程不是系统关键进程。建议使用IsProcessCritical函数来确定一个进程是否为系统关键进程(需要PROCESS_QUERY_LIMITED_INFORMATION访问权限)

NtQueryInformationProcess函数的返回值是一个NTSTATUS类型:

复制代码
typedef _Return_type_success_(return >= 0) LONG NTSTATUS;

在驱动程序开发过程中,开发者经常使用NTSTATUS类型返回状态,可以通过使用NT_SUCCESS宏检测状态是否正确。

Ntdll.dll动态链接库中还有一个ZwQueryInformationProcess函数。在用户层,NtQueryInformationProces函数和ZwQueryInformationProcess函数是同一个函数,具有相同的函数地址。

这里解释一下与ProcessBasicInformation和ProcessBreakOnTermination枚举值相关的知识。

1.PROCESS_BASIC_INFORMATION结构

该结构在Winternl.h头文件中定义如下:

复制代码
typedef struct _PROCESS_BASIC_INFORMATION {
     PVOID Reserved1;                    // 保留字段
     PPEB PebBaseAddress;            // 进程环境块PEB结构的指针
     PVOID Reserved2[2];             // 保留字段
     ULONG_PTR UniqueProcessId;      // 进程ID
     PVOID Reserved3;                    // 保留字段,但实际上是父进程ID
} PROCESS_BASIC_INFORMATION, * PPROCESS_BASIC_INFORMATION;

稍后详细讲解关于进程环境块PEB结构的知识。

2.系统关键进程

ProcessInformationClass参数指定为枚举值ProcessBreakOnTermination的情况下,可以判断一个进程是否为系统关键进程,或者可以使用IsProcessCritical函数来确定一个进程是否为系统关键进程(需要PROCESS_QUERY_LIMITED_INFORMATION访问权限)。

如何设置一个进程为系统关键进程?可以使用Ntdll.dll动态链接库中的未公开函数RtlSetProcessIsCritical。设置一个进程为系统关键进程后,通过任何方式结束进程均会导致系统崩溃,包括其他进程调用TerminateProcess或者自身正常结束,所以该函数可以用于保护进程不被非法结束,在需要正常结束时,取消设置自身为系统关键进程即可。

Ntdll.dll是一个系统内核级动态链接库,位于Kernel32.dll和User32.dll等动态链接库中的大部分API函数最终是通过调用Ntdll.dll中的函数实现的,其中大都是微软未公开的函数。读者可以通过Chapter4\Depends.exe打开Ntdll.dll来查看大量的导出函数。

要调用一个未公开函数,通常需要通过调用LoadLibrary / LoadLibraryEx函数自行加载未公开函数所在的动态链接库到进程地址空间中,然后通过调用GetProcAddress函数获取该函数在动态链接库中的地址,最后通过GetProcAddress函数返回的函数指针进行函数调用。

LoadLibrary函数用于将指定的模块加载到调用进程的地址空间中:

复制代码
HMODULE LoadLibrary(_In_ LPCTSTR lpLibFileName); // 模块名称,可以使用相对路径或绝对路径

lpLibFileName参数指定模块名称,可以使用相对路径或绝对路径。模块名称可以是库模块(.dll文件)或可执行模块(.exe文件),如果加载的是库模块(.dll文件)则可以省略扩展名。加载一个模块可能会导致该模块加载其他模块,因为任何一个.exe或.dll文件的运行通常都离不开其他模块的支持(例如调用其他模块中的函数)。如果函数执行成功,则返回值是模块的句柄。模块句柄是一个模块加载到一个进程虚拟地址空间中的基地址,因此模块句柄是相对于进程的,一个进程中获取到的模块句柄不可以用于其他进程。如果函数执行失败,则返回值为NULL。

如果确定一个模块已经加载到进程地址空间中,例如Kernel32.dll、User32.dll和Gdi32.dll是Windows API的三大模块,Ntdll.dll是用户级代码进入系统内核的入口,这些动态链接库模块(以下简称DLL模块)一定会被加载,这时可以通过调用前面介绍的GetModuleHandle函数获取一个模块加载到的基地址,该函数返回其模块句柄。无论一个模块是否已经加载到进程地址空间中,调用LoadLibrary/ LoadLibraryEx函数总是可以返回其模块句柄。

系统为每个进程维护进程中所有已加载模块的引用计数,调用LoadLibrary函数会增加引用计数(使用GetModuleHandle函数获取模块句柄不会增加引用计数);当不再需要所加载的模块时,应该调用FreeLibrary函数释放该模块,FreeLibrary函数会减少引用计数,如果引用计数为0,则系统会从进程的地址空间中取消模块的映射,模块句柄不再有效:

复制代码
BOOL FreeLibrary(_In_ HMODULE hLibModule);  // 模块句柄

有了模块句柄,可以通过调用GetProcAddress函数获取其中一个函数的地址:

复制代码
FARPROC GetProcAddress(
     _In_ HMODULE hModule,     // 模块句柄
     _In_ LPCSTR lpProcName); // 函数名称或函数序数(函数序数后面再讲),区分大小写,const CHAR *类型

如果函数执行成功,则返回值为函数指针类型FARPROC,即导出函数的地址;如果函数执行失败,则返回值为NULL:

复制代码
#ifdef _WIN64
     typedef INT_PTR(FAR WINAPI* FARPROC)();
#else
     typedef int     (FAR WINAPI* FARPROC)();
#endif

LoadLibrary、GetProcAddress和类似功能的函数在加密解密领域使用率特别高。接下来实现一个示例程序RtlSetProcessIsCritical,该程序动态加载Ntdll.dll,获取其导出函数RtlSetProcessIsCritical的函数地址并调用以设置程序自身为系统关键进程。RtlSetProcessIsCritical.cpp源文件的部分内容如下:

复制代码
typedef NTSTATUS(__cdecl* pfnRtlSetProcessIsCritical)(_In_ BOOL NewValue, _Out_ opt_ PBOOL OldValue, _In_ BOOL CheckFlag);
pfnRtlSetProcessIsCritical pRtlSetProcessIsCritical;

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     static HMODULE hNtdll;

     switch (uMsg)
     {
     case WM_INITDIALOG:
          hNtdll = LoadLibrary(TEXT("Ntdll.dll"));
          if (hNtdll)
          {
               pRtlSetProcessIsCritical = (pfnRtlSetProcessIsCritical)GetProcAddress (hNtdll,   
"RtlSetProcessIsCritical");
               if (pRtlSetProcessIsCritical)
                    pRtlSetProcessIsCritical(TRUE, NULL, FALSE);
               else
                    FreeLibrary(hNtdll);
          }
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDCANCEL:
               if (pRtlSetProcessIsCritical)
                    pRtlSetProcessIsCritical(FALSE, NULL, FALSE);
               if (hNtdll)
                    FreeLibrary(hNtdll);
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

完整代码参见Chapter4\RtlSetProcessIsCritical项目。

如前所述,如果GetProcAddress函数执行成功,则返回值为函数指针类型FARPROC,即导出函数的地址,这只是一个int类型的函数指针,还必须将返回的函数指针强制转换为具体函数指针类型,因此我们定义了函数指针pfnRtlSetProcessIsCritical。RtlSetProcessIsCritical是微软未公开的函数,没有相关头文件,MSDN也查询不到函数用法,但很容易通过Depends.exe工具获取到函数名称,通过名称可以判断其功能,然后通过反汇编获取其函数调用约定、函数参数等。本例中,在程序正常退出时,需要取消设置自身为系统关键进程,避免系统崩溃或工作文件得不到保存。

对于枚举进程,还可以通过调用Wtsapi32.dll提供的WTSEnumerateProcesses函数,或者使用Ntdll.dll中的未公开函数Nt(Zw)QuerySystemInformation,实际上CreateToolhelp32Snapshot、EnumProcesses和WTSEnumerateProcesses这些函数都是通过调用Ntdll中的Nt(Zw)QuerySystemInformation函数实现的。

4.5.3 进程环境块PEB

对于恶意软件,最简单的进程伪装方式是修改可执行文件名称,例如将文件名修改为svchost.exe、services.exe和Explorer.exe等系统进程名称,另外还需要把可执行文件复制到系统目录,这样一来进程列表中显示的是系统目录中的某个"系统可执行文件"。

将NtQueryInformationProcess函数的ProcessInformationClass参数设置为ProcessBasicInformation,该函数即可通过ProcessInformation参数返回一个PROCESS_BASIC_INFORMATION结构的指针,该结构包含进程环境块PEB结构的指针、进程ID和父进程ID等字段,进程环境块PEB位于用户地址空间,访问比较方便。

进程环境块PEB结构的定义如下(注意,该结构可能会随着系统版本的不同而不同):

复制代码
typedef struct _PEB {
     BYTE Reserved1[2];
     BYTE BeingDebugged;                             // 值为0或1,表示当前进程是否正在被调试
     BYTE Reserved2[1];
     PVOID Reserved3[2];
     PPEB_LDR_DATA Ldr;                              // 指向包含进程已加载模块信息的PEB_LDR_
                                                     // DATA结构
     PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // 指向包含进程文件路径和命令行参数的结构
     PVOID Reserved4[3];
     PVOID AtlThunkSListPtr;
     PVOID Reserved5;
     ULONG Reserved6;
     PVOID Reserved7;
     ULONG Reserved8;
     ULONG AtlThunkSListPtr32;
     PVOID Reserved9[45];
     BYTE Reserved10[96];
     PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
     BYTE Reserved11[128];
     PVOID Reserved12[1];
     ULONG SessionId;                                 // 会话ID,后面会介绍Session会话
} PEB, * PPEB;

除BeingDebugged、Ldr、ProcessParameters和SessionId外,其他字段都是不建议使用的保留字段。

IsDebuggerPresent函数通过读取PEB.BeingDebugged字段的值来判断程序是否正在被调试。要确定其他进程是否正在被调试可以使用CheckRemoteDebuggerPresent函数(该函数有一个进程句柄参数)。

1.Ldr字段

Ldr字段是指向包含进程已加载模块信息的PEB_LDR_DATA结构的指针,该结构的定义如下:

复制代码
typedef struct _PEB_LDR_DATA {
     BYTE  Reserved1[8];
     PVOID Reserved2[3];
     LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, * PPEB_LDR_DATA;

Ldr→InMemoryOrderModuleList字段是进程已加载模块的双向链表头,双向链表头是一个LIST_ENTRY结构,作为双向链表头的LIST_ENTRY结构的含义如下:

复制代码
typedef struct _LIST_ENTRY {
     struct _LIST_ENTRY* Flink;      // 指向链表中第一个节点的指针,如果链表为空则指向链表头
     struct _LIST_ENTRY* Blink;      // 指向链表中最后一个节点的指针,如果链表为空则指向链表头
} LIST_ENTRY, * PLIST_ENTRY, * RESTRICTED_POINTER PRLIST_ENTRY;

计算第一个节点的代码如下:

复制代码
PLIST_ENTRY pListEntry = pbi.PebBaseAddress->Ldr->InMemoryOrderModuleList.Flink;

双向链表的每个节点是一个LIST_ENTRY结构,作为节点的LIST_ENTRY结构的含义如下:

复制代码
typedef struct _LIST_ENTRY {
     struct _LIST_ENTRY* Flink;     // 指向链表中的下一个节点,如果是最后一个节点则指向链表头
     struct _LIST_ENTRY* Blink;     // 指向链表中的上一个节点,如果是第一个节点则指向链表头
} LIST_ENTRY, * PLIST_ENTRY, * RESTRICTED_POINTER PRLIST_ENTRY;

双向链表的每个节点是一个LIST_ENTRY结构,但是通过这样的LIST_ENTRY结构无法获取模块的具体信息,事实上每个节点都是指向一个LDR_DATA_TABLE_ENTRY结构的InMemoryOrderLinks字段的指针,LDR_DATA_TABLE_ENTRY结构包含模块的基地址、文件路径、校验和和时间戳等字段,该结构在winternl.h头文件中定义如下:

复制代码
typedef struct _LDR_DATA_TABLE_ENTRY {
     PVOID                Reserved1[2];
     LIST_ENTRY          InMemoryOrderLinks;    // LIST_ENTRY结构
     PVOID               Reserved2[2];
     PVOID               DllBase;                 // 模块基地址
     PVOID               Reserved3[2];    
     UNICODE_STRING      FullDllName;             // 模块文件完整路径的UNICODE_STRING结构
     BYTE                Reserved4[8];
     PVOID               Reserved5[3];
     union {
          ULONG           CheckSum;                 // 校验和
          PVOID           Reserved6;
     } DUMMYUNIONNAME;
     ULONG               TimeDateStamp;            // 时间戳
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;

LIST_ENTRY节点的地址减8是LDR_DATA_TABLE_ENTRY结构的地址,但是考虑兼容性不应该直接减8,微软公司提供了CONTAINING_RECORD宏用于通过一个字段的地址计算结构的基地址:

复制代码
#define CONTAINING_RECORD(address, type, field) ((type *)((PCHAR)(address) - \
                                                  (ULONG_PTR)(&((type *)0)->field)))

ANSI C标准允许将值为0的常量强制转换为任意一种类型的指针,转换结果是一个NULL指针,因此(type )0的结果是一个类型为type 的NULL指针。利用NULL指针访问type结构的成员变量是非法的,但是&((type *)0)→field的意图仅仅是计算field字段的地址,这种情况下编译器不会生成访问type结构的代码,type的基地址为0,因此计算出来的field字段的地址是该字段相对于type结构基地址的偏移。

我们也可以自行定义一个计算指定字段在结构中的偏移的宏:

复制代码
#define FIELD_OFFSET(type, field)  ((ULONG_PTR)(&((type *)0)->field))

UNICODE_STRING结构的定义如下所示:

复制代码
typedef struct _UNICODE_STRING {
     USHORT Length;            // 字符串缓冲区的长度(以字节为单位),不包括终止空字符
     USHORT MaximumLength;     // 字符串缓冲区的长度(以字节为单位),包括终止空字符
     PWSTR  Buffer;              // 宽字符串缓冲区
} UNICODE_STRING;

枚举指定进程已加载模块信息的代码如下:

复制代码
PROCESS_BASIC_INFORMATION pbi = { 0 };
PLIST_ENTRY pListEntry = NULL;
PLDR_DATA_TABLE_ENTRY pDataTableEntry = NULL;

// 获取指定进程的PROCESS_BASIC_INFORMATION结构
NtQueryInformationProcess(GetCurrentProcess(), ProcessBasicInformation,
     &pbi, sizeof(PROCESS_BASIC_INFORMATION), NULL);

// 进程已加载模块的双向链表的第一个节点
pListEntry = pbi.PebBaseAddress->Ldr->InMemoryOrderModuleList.Flink;

// 如果链表为空,则第一个节点指向链表头
if (pListEntry != &(pbi.PebBaseAddress->Ldr->InMemoryOrderModuleList))
{
     // 最后一个节点的Flink指向链表头
     while (pListEntry != &(pbi.PebBaseAddress->Ldr->InMemoryOrderModuleList))
     {
          // 处理每一个节点
          pDataTableEntry = CONTAINING_RECORD(pListEntry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

          // pListEntry指向下一个节点
          pListEntry = pListEntry->Flink;
     }
}
2.ProcessParameters字段

ProcessParameters字段是包含进程文件路径和命令行参数的RTL_USER_PROCESS_PARAMETERS结构:

复制代码
typedef struct _RTL_USER_PROCESS_PARAMETERS {
     BYTE Reserved1[16];
     PVOID Reserved2[10];
     UNICODE_STRING ImagePathName;       // 进程文件完整路径
     UNICODE_STRING CommandLine;         // 传递给进程的命令行参数
} RTL_USER_PROCESS_PARAMETERS, * PRTL_USER_PROCESS_PARAMETERS;

我们可以修改任意进程的进程环境块PEB中的ProcessParameters字段指向的RTL_USER_PROCESS_PARAMETERS结构,以修改进程文件完整路径和传递给进程的命令行参数,但是这种方法并不是很有效,通过调用GetModuleFileNameEx、QueryFullProcessImageName或GetProcessImageFileName等函数依然可以获取被修改进程的正确路径。在Windows 10 64位系统中,程序必须编译为64位才可以修改指定进程的RTL_USER_PROCESS_PARAMETERS结构。在Chapter4\Explorer项目编译为64位的情况下,通过Process Explorer进程查看工具查看到的是修改后的进程文件路径和命令行参数信息,但是在任务管理器和我们自行编写的ProcessList程序中,进程伪装失败。

4.6 进程调试

4.6.1 读写其他进程的地址空间

当以合适的权限获取到一个进程的句柄后,可以通过调用ReadProcessMemory和WriteProcessMemory函数读写该进程的地址空间,并且能够对其他进程的地址空间进行读写。

ReadProcessMemory函数用于读取指定进程地址空间地址处的内存数据,WriteProcessMemory函数用于向指定进程地址空间的地址处写入数据:

复制代码
BOOL WINAPI ReadProcessMemory(
     _In_            HANDLE      hProcess,        // 进程句柄,需要PROCESS_VM_READ访问权限
     _In_            LPCVOID     lpBaseAddress,    // hProcess进程中的一个基地址,从此处开始读取数据
     _Out_           LPVOID      lpBuffer,       // 返回hProcess进程中lpBaseAddress地址开始的nSize  
                                           // 字节的数据
     _In_            SIZE_T      nSize,          // 要读取的字节数
     _Out_opt_    SIZE_T*     lpNumberOfBytesRead); // 返回实际读取的字节数,可以设置为NULL
BOOL WINAPI WriteProcessMemory(
     _In_          HANDLE      hProcess,      // 进程句柄,需要PROCESS_VM_WRITE和PROCESS_VM_   
                                         // OPERATION访问权限
     _In_            LPVOID      lpBaseAddress,// hProcess进程中的一个基地址,从此处开始写入数据
     _In_            LPCVOID     lpBuffer,     // 要写入的数据
     _In_            SIZE_T      nSize,         // 要写入的字节数
     _Out_opt_       SIZE_T*    lpNumberOfBytesWritten);  // 返回实际写入的字节数,可以设置为NULL

需要两个程序演示读写其他进程地址空间函数ReadProcessMemory和WriteProcessMemory的用法。目标程序Test的源文件内容如下:

复制代码
#include <Windows.h>

BOOL g_bLegalCopy = FALSE;

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     // 判断软件是否为正版的代码,如果是则设置全局变量g_bLegalCopy为TRUE

     if (g_bLegalCopy)
          MessageBox(NULL, TEXT("正版软件"), TEXT("欢迎"), MB_OK);
     else
          MessageBox(NULL, TEXT("盗版软件"), TEXT("鄙视"), MB_OK);

     return 0;
}

可以通过序列号或者其他手段来判断Test程序是否为正版软件。如果是正版软件,则设置全局变量g_bLegalCopy为TRUE,然后通过判断g_bLegalCopy的值设置是否弹出正版软件或盗版软件的消息框。就本例来说,会始终弹出盗版软件消息框。

我们的目的是使Test.exe程序总是弹出正版软件消息框。在目标软件加载到内存中时,我们可以修改目标进程关键地址处的内存数据以达到破解的目的,这称为内存补丁。要实施内存补丁,前期需要通过OllyDBG(OD)等调试器对目标软件进行调试跟踪,找到关键代码和关键地址。使用OllyDBG打开Test.exe程序,关键的反汇编代码如原书的图4.7所示。

注意,当使用OllyDBG载入Test.exe程序时,0x009E1000并不是程序的入口点(Entry Point)。在编译C/C++程序时会添加一些C/C++初始化代码,运行程序时会首先执行这些初始化代码,然后转去执行WinMain函数。

单击OllyDBG的查看菜单→内存命令,可以打开内存窗口,内存窗口中展示了Test.exe进程中整个虚拟地址空间的模块分布情况,Test.exe主程序模块的内存分布如表4.11所示。

表4.11

地址 大小 属主 区段 包含 类型 访问 初始访问
009E0000 00001000 Test PE文件头 Imag R RWE
009E1000 0000C000 Test .text SFX,代码 Imag R RWE
009ED000 00006000 Test .rdata 数据,输入表 Imag R RWE
009F3000 00002000 Test .data Imag R RWE
009F5000 00001000 Test .rsrc 资源 Imag R RWE
009F6000 00001000 Test .reloc Imag R RWE

在表4.11的第2行0x009E1000中,0x0000C000字节大小的内存空间是Test.exe的.text区段,该区段存放的是程序的可执行代码,0x009E1000是WinMain函数的地址。第1行0x009E0000是可执行模块的基地址,从0x009E0000开始的0x00001000字节大小的内存空间是PE文件头。从区段一列中可以看到.rdata、.data、.rsrc和.reloc,这些区段分别用来存放只读数据、数据、资源和重定位信息,不同的区段具有不同的内存保护属性,后面会详细介绍有关区段和PE文件格式的内容。

我们可以修改反汇编代码第一行DS:[g_bLegalCopy]内存地址处的数据为1,这样一来009E1009行的JE指令就不会跳转,程序会弹出正版软件消息框;也可以把009E1009行的JE指令使用NOP指令填充,NOP是空操作指令,该指令不会产生任何结果,仅在消耗几个时钟周期的时间后继续执行后续指令,NOP指令的机器码是0x90,而此处的JE指令是2字节0x1774,因此可以使用0x9090替换009E1009行的JE指令,无论全局变量g_bLegalCopy的值为TRUE还是FALSE,永远只会弹出正版软件消息框。

009E1009行的JE指令的机器码(Hex数据列)是0x1774,字节顺序是数据的长度跨越多字节时数据被存储的顺序,CPU对字节顺序的处理方式有两种:大尾方式(Big Endian)和小尾方式(Little Endian)。在大尾方式中,数据的高字节被放置在连续存储区域的首位,例如一个32位的十六进制数0x12345678在内存中的存放方式是0x12,0x34,0x56,0x78;而在小尾方式中,数据的低字节被放置在连续存储区域的首位,上述数据在内存中的存放方式为0x78,0x56,0x34,0x12。Intel系列处理器使用的是小尾方式(所以我们常常看到内存中的多字节数倒过来放置),而某些RISC架构的处理器例如IBM的Power-PC则使用大尾方式。字节顺序指的是跨越多字节的数据,单字节数据无论采用哪种存储方式都是相同的顺序,例如下面的小尾方式内存空间:

0x00DAA03C地址处的DWORD数据为0x12345678,0x00DAA03C地址处的WORD数据为0x5678,0x00DAA03C地址处的BYTE数据为0x78。

在找到目标软件的关键地址后,接下来需要使用加载器程序加载目标软件,在目标软件执行前修改其关键代码。LoadTest程序的源文件内容如下:

复制代码
#include <windows.h>
#include "resource.h"

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     TCHAR szCommandLine[MAX_PATH] = TEXT("Test.exe");
     STARTUPINFO si = { sizeof(STARTUPINFO) };
     PROCESS_INFORMATION pi = { 0 };
     LPVOID lpBaseAddress = (LPVOID)0x009E1009;
     WORD wCodeOld, wCodeNew = 0x9090;

     switch (uMsg)
     {
     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_LOADTEST:
               GetStartupInfo(&si);
               if (CreateProcess(NULL, szCommandLine, NULL, NULL, FALSE, CREATE_ SUSPENDED,
                    NULL, NULL, &si, &pi))
               {
                    if (ReadProcessMemory(pi.hProcess, lpBaseAddress, &wCodeOld, sizeof(WORD), NULL))
                    {
                         // 目标进程lpBaseAddress地址处的数据内容是否为0x1774,如果是,则替换
                         if (wCodeOld == 0x1774)
                         {
                              // 改写机器码
                              WriteProcessMemory(pi.hProcess, lpBaseAddress, &wCodeNew,
                                  sizeof(WORD), NULL);
                              ResumeThread(pi.hThread);
                         }
                         else
                         {
                              MessageBox(hwndDlg, TEXT("目标软件版本错误"), TEXT("错误提示"), MB_OK);
                              TerminateProcess(pi.hProcess, 0);
                         }
                    }

                    CloseHandle(pi.hThread);
                    CloseHandle(pi.hProcess);
               }
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

在Windows Vista及以上版本的系统中,PE文件(可执行文件)支持动态基地址,在VS中可以用鼠标右键单击项目名称,然后选择属性→配置属性→链接器→高级→随机基址和固定基址命令,可以看到默认情况下已经设置了随机基址。该技术称为地址空间布局随机化(Address Space Layout Randomization,ASLR)。为了增强系统安全性,可执行文件每次加载到的内存基地址都会随机变化,程序中用到的DLL文件加载到的内存基地址也会随机变化,并且进程的栈以及堆的基地址也会随机变化。如果不采用ASLR,可执行文件加载的默认基地址为0x00400000,DLL文件加载的默认基地址为0x10000000,利用ASLR技术增加了恶意用户编写漏洞代码的难度。在上述反汇编代码中,Test程序的虚拟基地址为0x009E0000,代码段通常位于偏移0x1000的位置,即0x009E1000,JE指令位于代码段偏移9字节的地址处,即0x009E1009,但是在Test程序每次运行时,可执行文件加载到的基地址可能都会发生变化,而LoadTest程序假设Test程序的基地址始终是0x009E0000,这是存在严重错误的。

ReadProcessMemory(需要PROCESS_VM_READ访问权限)和WriteProcessMemory(需要PROCESS_VM_WRITE和PROCESS_VM_OPERATION访问权限)函数可以读写其他进程的地址空间,由CreateProcess函数返回的进程句柄具有对子进程的PROCESS_ALL_ACCESS所有可能的访问权限,可以自由读写子进程的地址空间,但是如果是使用OpenProcess函数获取的进程句柄,需要指定相关访问权限,否则会读写失败。

4.6.2 获取一个以暂停模式启动的进程模块基地址

对于一个运行中的进程,可以通过调用EnumProcessModules/EnumProcessModulesEx函数获取该进程中每个模块的句柄:

复制代码
BOOL WINAPI EnumProcessModules(
     _In_  HANDLE    hProcess,       // 进程句柄
     _Out_ HMODULE* lphModule,       // 接收模块句柄列表的数组
     _In_  DWORD     cb,              // lphModule数组的大小,以字节为单位
     _Out_ LPDWORD   lpcbNeeded);  // 返回所需的字节数,*lpcbNeeded / sizeof(HMODULE)是模块个数

类似于EnumProcesses函数,我们很难预测一个进程中有多少个模块,因此可以指定一个比较大的 HMODULE 数组以接收模块句柄列表,或者可以两次调用 EnumProcessModules 函数,通过lpcbNeeded参数返回的字节数分配合理大小的数组,然后再次调用EnumProcessModules函数。

枚举到的第一个模块句柄是主程序模块的句柄,因此如果只需要获取主程序模块的基地址可以按如下方式调用EnumProcessModules函数,不需要再调用Module32Next函数继续枚举。

复制代码
HMODULE hModule;
DWORD dwNeeded;
EnumProcessModules(hProcess, &hModule, sizeof(HMODULE), &dwNeeded);

还可以通过调用CreateToolhelp32Snapshot函数枚举一个正在运行进程中的所有模块,例如:

复制代码
HANDLE hSnapshot;
MODULEENTRY32 me = { sizeof(MODULEENTRY32) };
BOOL bRet;

hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pi.dwProcessId);
if (hSnapshot == INVALID_HANDLE_VALUE)
     return FALSE;

bRet = Module32First(hSnapshot, &me);
while (bRet)
{
     // 对枚举到的模块进行操作

     bRet = Module32Next(hSnapshot, &me);
}

CloseHandle(hSnapshot);

EnumProcessModules / EnumProcessModulesEx函数或CreateToolhelp32Snapshot函数只能枚举一个正在运行中进程的所有模块。在LoadTest程序中,调用CreateProcess函数创建Test子进程,为了能够在Test程序执行前有机会调用ReadProcessMemory或WriteProcessMemory函数读写其地址空间,我们把CreateProcess函数的dwCreationFlags参数设置为CREATE_SUSPENDED暂停状态,这时候子进程还没有初始化完毕,因此调用相关模块枚举函数不会成功,可以使用下面将要介绍的调试API解决这个问题。

其实,对于通过调用CreateProcess函数并把dwCreationFlags参数设置为CREATE_SUSPENDED创建的进程,也可以通过其他一些方法获取到主程序模块的基地址。

  • 第一种方法是通过调用GetThreadContext函数获取目标进程主线程环境,context.Eax寄存器的值是程序入口点地址,有了程序入口点地址,可以通过调用VirtualQueryEx函数查询进程虚拟地址空间中的页面信息,其中MEMORY_BASIC_INFORMATION.AllocationBase是空间区域的基地址,也是可执行模块的基地址。

  • 第二种方法,虽然是以暂停模式启动的目标进程,但是可执行模块本身已经在内存中映射,只是程序中需要使用的一些其他DLL模块还没有映射,因此可以通过调用GetSystemInfo函数查询进程地址空间的最小内存地址、最大内存地址和页面大小,从最小内存地址lpMinAppAddress开始递增查找第一个具有MEM_IMAGE类型的页面(页面状态为已提交),这个具有MEM_IMAGE类型的页面属于可执行模块的空间区域。同样,MEMORY_BASIC_INFORMATION. AllocationBase是空间区域的基地址,也是可执行模块的基地址。

GetSuspendProcessBase程序实现了这两种方法,GetSuspendProcessBase.cpp源文件的内容如下:

复制代码
#include <Windows.h>
#include <Psapi.h>
#include <tchar.h>

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     TCHAR szCommandLine[MAX_PATH] = TEXT("ThreeThousandYears.exe"); // 目标程序
     MEMORY_BASIC_INFORMATION mbi = { 0 };    // VirtualQueryEx参数
     SIZE_T nBufSize;                            // VirtualQueryEx返回值
     TCHAR szImageFile[MAX_PATH] = { 0 };     // 目标程序完整路径
     TCHAR szBuf[MAX_PATH * 2] = { 0 };       // 缓冲区

     // 方法1
     STARTUPINFO si = { sizeof(STARTUPINFO) };
     PROCESS_INFORMATION pi = { 0 };
     CONTEXT context = { 0 };

     // 创建一个挂起的进程
     GetStartupInfo(&si);
     CreateProcess(NULL, szCommandLine, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);

     // 获取目标进程主线程环境
     context.ContextFlags = CONTEXT_ALL;
     GetThreadContext(pi.hThread, &context);

     // context.Eax是程序入口点地址
     nBufSize = VirtualQueryEx(pi.hProcess, (LPVOID)context.Eax, &mbi, sizeof(mbi));
     if (nBufSize > 0)
     {
          GetMappedFileName(pi.hProcess, (LPVOID)context.Eax, szImageFile, _countof (szImageFile));
          wsprintf(szBuf, TEXT("%s 基地址:0x%p"), szImageFile, mbi.AllocationBase);
          MessageBox(NULL, szBuf, TEXT("提示"), MB_OK);
     }

     // 方法2
     SYSTEM_INFO systemInfo = { 0 };
     LPVOID lpMinAppAddress = NULL;

     // 获取进程地址空间的最小、最大内存地址和页面大小
     GetSystemInfo(&systemInfo);
     lpMinAppAddress = systemInfo.lpMinimumApplicationAddress;

     // 从最小内存地址开始查找第一个具有MEM_IMAGE类型的页面(页面状态为已提交)
     while (lpMinAppAddress < systemInfo.lpMaximumApplicationAddress)
     {
          ZeroMemory(&mbi, sizeof(MEMORY_BASIC_INFORMATION));
          nBufSize = VirtualQueryEx(pi.hProcess, lpMinAppAddress, &mbi, sizeof(mbi));
          if (nBufSize == 0)
          {
               lpMinAppAddress = (LPBYTE)lpMinAppAddress + systemInfo.dwPageSize;
               continue;
          }

          switch (mbi.State)
          {
          case MEM_RESERVE:
          case MEM_FREE:
               lpMinAppAddress = (LPBYTE)(mbi.BaseAddress) + mbi.RegionSize;
               break;

          case MEM_COMMIT:
               if (mbi.Type == MEM_IMAGE)
               {
                    GetMappedFileName(pi.hProcess, lpMinAppAddress, szImageFile, _countof (szImageFile));
                    wsprintf(szBuf, TEXT("%s 基地址:0x%p"), szImageFile, mbi. AllocationBase);
                    MessageBox(NULL, szBuf, TEXT("提示"), MB_OK);
                    break;
               }

               lpMinAppAddress = (LPBYTE)(mbi.BaseAddress) + mbi.RegionSize;
               break;
          }

          // 找到后退出循环
          if (mbi.Type == MEM_IMAGE)
               break;
     }

     ResumeThread(pi.hThread);
     CloseHandle(pi.hThread);
     CloseHandle(pi.hProcess);

     return 0;
}

如何证明获取到的可执行模块基地址是否正确?ThreeThousandYears程序正常运行后,打开WinHex,工具菜单项→打开内存,找到ThreeThousandYears进程即可看到可执行模块ThreeThousandYears.exe的基地址。选择ThreeThousandYears进程的"整个内存",单击"确定"按钮,可以看到进程整个地址空间的内存数据,如原书的图4.8所示。

本程序中实际上无须调用 GetMappedFileName 函数获取模块文件的完整路径,这里只是为了额外介绍一个函数。GetMappedFileName函数用于检查指定的内存地址是否位于指定进程的地址空间中,如果是,则返回进程所对应可执行文件的名称:

复制代码
DWORD GetMappedFileName(
   _In_  HANDLE hProcess,      // 进程句柄,必须具有PROCESS_
                          // QUERY_INFORMATION和  
                          // PROCESS_VM_READ访问权限
   _In_  LPVOID lpv,           // 内存地址
   _Out_ LPTSTR lpFilename,    // 返回可执行文件名称的缓冲区
   _In_  DWORD  nSize);        // 缓冲区的大小,以字符为单位

如果函数执行成功,则返回值是复制到缓冲区中的字符串长度;如果函数执行失败,则返回值为0。

注意,返回的文件名称路径是\Device\HarddiskVolume2\形式的本机系统路径格式,如果本书的源代码文件在F盘,那么路径是HarddiskVolume4。就本程序而言,不建议使用GetMappedFileName函数,而是使用GetModuleFileNameEx或QueryFullProcessImageName函数,如下所示:

复制代码
GetModuleFileNameEx(pi.hProcess, NULL, szImageFile, _countof(szImageFile));
// 返回F:\Source\Windows\Chapter4\GetSuspendProcessBase\Debug\ThreeThousandYears.exe

// 或者
DWORD dwLen = _countof(szImageFile);
QueryFullProcessImageName(pi.hProcess, 0, szImageFile, &dwLen);
// 返回F:\Source\Windows\Chapter4\GetSuspendProcessBase\Debug\ThreeThousandYears.exe

对于Test这样的简单程序,在通过OllyDBG调试器找到关键代码后,可以直接修改Test文件的关键代码以达到破解的目的,这称为文件补丁(静态补丁)。使用WinHex打开Test.exe,搜索→查找十六进制数值,输入7417,单击"确定"按钮,定位到原书的图4.9所示的地址处。

把图中的7417修改为9090,保存文件即可实现破解目的。

4.6.3 调试API

调试一个程序需要在调用CreateProcess函数创建子进程时将dwCreationFlags参数指定为DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS标志;创建子进程即被调试进程后,当被调试进程中发生调试事件时,Windows会暂停被调试进程中的所有线程并向父进程即调试器发送一个调试事件通知,调试器需要循环调用WaitForDebugEvent函数等待调试事件;在处理完一个调试事件后,需要调用ContinueDebugEvent函数恢复被调试进程的执行并等待下一个调试事件的发生。

对于正在运行中的进程,可以通过调用DebugActiveProcess函数使该进程进入被调试状态。通过OllyDBG文件菜单下的附加菜单项可以选择要附加的进程,把一个正在运行中的进程附加到OllyDBG中进行调试,使用的就是该函数:

复制代码
BOOL WINAPI DebugActiveProcess(_In_ DWORD dwProcessId);     // 进程ID

要停止对指定进程的调试可以调用DebugActiveProcessStop函数:

复制代码
BOOL DebugActiveProcessStop(_In_ DWORD dwProcessId);        // 进程ID

调试一个程序使用的是CreateProcess函数,调用CreateProcess函数的进程称为调试器,CreateProcess函数创建的进程称为被调试进程,很明显被调试进程是调试器的子进程,当调试器进程结束时被调试进程也会结束,但是在非调试场合即CreateProcess函数的dwCreationFlags参数没有指定DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS标志的情况下,父进程退出并不会影响子进程。运行先前版本的Armadillo(穿山甲)加密壳加密过的程序时通过CreateProcess函数创建被调试进程,被调试进程才是真正的原程序(也被加密过),使用OllyDBG调试工具调试Armadillo加密壳加密过的程序实际上调试的是一个调试器,没有实际意义,这时候可以通过调用DebugActiveProcessStop函数使Armadillo调试器停止对被调试进程的调试,父子进程脱离关系,该过程称为剥离进程,然后可以附加Armadillo的被调试进程继续进行调试。

WaitForDebugEvent函数用于等待正在调试的进程发生调试事件:

复制代码
BOOL WINAPI WaitForDebugEvent(
     _Out_ LPDEBUG_EVENT lpDebugEvent,        // 返回有关调试事件信息的DEBUG_EVENT结构
     _In_  DWORD           dwMilliseconds);    // 等待调试事件发生的毫秒数

(1)lpDebugEvent参数返回有关调试事件信息的DEBUG_EVENT结构,该结构在minwinbase.h头文件中定义如下:

复制代码
typedef struct _DEBUG_EVENT {
     DWORD dwDebugEventCode;         // 调试事件类型
     DWORD dwProcessId;              // 发生调试事件的进程的ID
     DWORD dwThreadId;               // 发生调试事件的线程的ID
     union {                               // 联合体:
          EXCEPTION_DEBUG_INFO Exception;          // EXCEPTION_DEBUG_EVENT
          CREATE_THREAD_DEBUG_INFO CreateThread;     // CREATE_THREAD_DEBUG_EVENT
          CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;// CREATE_PROCESS_DEBUG_EVENT
          EXIT_THREAD_DEBUG_INFO ExitThread;           // EXIT_THREAD_DEBUG_EVENT
          EXIT_PROCESS_DEBUG_INFO ExitProcess;      // EXIT_PROCESS_DEBUG_EVENT
          LOAD_DLL_DEBUG_INFO LoadDll;             // LOAD_DLL_DEBUG_EVENT
          UNLOAD_DLL_DEBUG_INFO UnloadDll;         // UNLOAD_DLL_DEBUG_EVENT
          OUTPUT_DEBUG_STRING_INFO DebugString;     // OUTPUT_DEBUG_STRING_EVENT
          RIP_INFO RipInfo;                           // RIP_EVENT
     } u;
} DEBUG_EVENT, * LPDEBUG_EVENT;

dwDebugEventCode字段表示调试事件类型,可以是表4.12所示的值之一。

表4.12

调试事件类型 含义
EXCEPTION_DEBUG_EVENT 1 被调试进程中发生异常事件,被调试进程开始执行第一条指令时会发生本事件,后续在发生调试中断(遇到int3或者单步中断)以及发生异常时也会发生本事件。u.Exception字段是一个EXCEPTION_DEBUG_INFO结构
CREATE_THREAD_DEBUG_EVENT 2 被调试进程中创建了一个新线程(被调试进程的主线程被创建时不会发生本事件)。u.CreateThread字段是一个CREATE_THREAD_DEBUG_INFO结构
CREATE_PROCESS_DEBUG_EVENT 3 进程被创建,当调用CreateProcess函数创建被调试进程(还未开始运行),或者正在运行中的进程被DebugActiveProcess函数附加到调试器中时会发生本事件。u.CreateProcessInfo字段是一个CREATE_PROCESS_DEBUG_INFO结构
EXIT_THREAD_DEBUG_EVENT 4 被调试进程中某个线程结束。u.ExitThread字段是一个EXIT_THREAD_DEBUG_INFO结构
EXIT_PROCESS_DEBUG_EVENT 5 被调试进程退出。u.ExitProcess字段是一个EXIT_PROCESS_DEBUG_INFO结构
LOAD_DLL_DEBUG_EVENT 6 被调试进程加载一个DLL时发生本事件,当系统根据可执行文件文件头中的导入表加载DLL时会发生本事件,被调试进程调用LoadLibrary函数加载DLL时也会发生本事件。u.LoadDll字段是一个LOAD_DLL_DEBUG_INFO结构
UNLOAD_DLL_DEBUG_EVENT 7 当一个DLL从被调试进程中卸载时发生本事件。u.UnloadDll字段是一个UNLOAD_DLL_DEBUG_INFO结构
OUTPUT_DEBUG_STRING_EVENT 8 当被调试进程调用DebugOutputString函数时发生本事件,被调试进程可以通过这种方法向调试器发送消息字符串。u.DebugString字段是一个OUTPUT_DEBUG_STRING_INFO结构
RIP_EVENT 9 调试发生错误。u.RipInfo字段是一个RIP_INFO结构

关于发生不同调试事件时所用的结构的具体含义,后续再进行详细介绍。

dwProcessId字段是发生调试事件的进程的ID,dwThreadId字段是发生调试事件的线程的ID,调用CreateProcess函数创建被调试进程时,PROCESS_INFORMATION结构参数可以返回子进程的进程ID和线程ID。如果调用CreateProcess函数时将dwCreationFlags参数指定为DEBUG_PROCESS,则调试事件可能发生在孙进程中,这时dwProcessId和dwThreadId字段表示孙进程的ID。

(2)dwMilliseconds参数指定等待调试事件发生的毫秒数,如果设置为0,函数将测试调试事件并立即返回;如果设置为INFINITE,则函数会一直等待直到发生调试事件。

当调试器使用WaitForDebugEvent函数获取到一个调试事件并进行处理后,被调试进程还处于暂停状态,要恢复被调试进程的运行可以调用ContinueDebugEvent函数:

复制代码
BOOL WINAPI ContinueDebugEvent(
     _In_ DWORD dwProcessId,        // 被恢复运行的进程ID,使用DEBUG_EVENT结构中返回的同名字段即可
     _In_ DWORD dwThreadId,         // 被恢复运行的线程ID,使用DEBUG_EVENT结构中返回的同名字段即可
     _In_ DWORD dwContinueStatus);    // 继续执行选项,通常指定为DBG_CONTINUE

dwContinueStatus参数表示继续执行选项,可以是表4.13所示的值之一。

表4.13

常量 含义
DBG_CONTINUE 如果dwThreadId参数指定的线程发生了EXCEPTION_DEBUG_EVENT调试事件,则停止所有异常处理并继续执行该线程,然后将异常标记为已处理;对于任何其他调试事件,该标志只是继续执行线程
DBG_EXCEPTION_NOT_HANDLED 如果dwThreadId参数指定的线程发生了EXCEPTION_DEBUG_EVENT调试事件,则使用被调试进程的结构化异常处理程序处理;否则进程将终止。对于任何其他调试事件,该标志只是继续执行线程。通常用于加壳程序,把异常交给加壳程序的异常处理程序
DBG_REPLY_LATER 用于Windows 10 1507或更高版本,继续执行dwThreadId参数指定的线程,再次触发相同的异常

调试器通常可以按照如下方式处理调试事件:

复制代码
DEBUG_EVENT debugEvent;

while (TRUE)
{
     // 等待调试事件发生
     WaitForDebugEvent(&debugEvent, INFINITE);

     // 处理调试事件,也可以使用switch语句
     if (debugEvent.dwDebugEventCode == EXCEPTION_DEBUG_EVENT)
     {
          // 被调试进程中发生异常事件
          switch (debugEvent.u.Exception.ExceptionRecord.ExceptionCode)
          {
          case EXCEPTION_BREAKPOINT:          // 断点中断
               break;

          case EXCEPTION_SINGLE_STEP:         // 单步中断
               break;

          case EXCEPTION_ACCESS_VIOLATION:    // 访问违规
               break;

          default:
               break;
          }
     }

     else if (debugEvent.dwDebugEventCode == CREATE_THREAD_DEBUG_EVENT)
     {
          // 被调试进程中创建了一个新线程
          // 可以调用GetThreadContext、SetThreadContext函数获取、修改线程的寄存器,
          // 可以调用SuspendThread、ResumeThread函数挂起、恢复线程执行
     }

     else if (debugEvent.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT)
     {
          // 进程被创建
          // 可以调用GetThreadContext、SetThreadContext函数获取、修改线程的寄存器
          // 可以调用SuspendThread、ResumeThread函数挂起、恢复线程执行
          // 可以调用ReadProcessMemory、WriteProcessMemory函数读取、写入进程的虚拟内存
          CloseHandle(debugEvent.u.CreateProcessInfo.hFile);
     }

     else if (debugEvent.dwDebugEventCode == EXIT_THREAD_DEBUG_EVENT)
     {
          // 被调试进程中某个线程结束
     }

     else if (debugEvent.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT)
     {
          // 被调试进程退出
          break;
     }

     else if (debugEvent.dwDebugEventCode == LOAD_DLL_DEBUG_EVENT)
     {
          // 被调试进程加载一个DLL
          CloseHandle(debugEvent.u.LoadDll.hFile);
     }

     else if (debugEvent.dwDebugEventCode == UNLOAD_DLL_DEBUG_EVENT)
     {
          // 一个DLL从被调试进程中卸载
     }

     else if (debugEvent.dwDebugEventCode == OUTPUT_DEBUG_STRING_EVENT)
     {
          // 被调试进程调用DebugOutputString函数
     }

     else if (debugEvent.dwDebugEventCode == RIP_EVENT)
     {
          // 调试发生错误
     }

     // 调试器处理WaitForDebugEvent函数返回的调试事件时,Windows将暂停被调试进程中的所有线程
     // 处理完一个调试事件后调用ContinueDebugEvent函数恢复线程执行并继续等待下一个调试事件
     ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);
}

我们通过前面介绍的调试API技术改写LoadTest程序,Chapter4\LoadTest2\LoadTest\LoadTest.cpp源文件的部分内容如下:

复制代码
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    TCHAR szCommandLine[MAX_PATH] = TEXT("Test.exe");
    STARTUPINFO si = { sizeof(STARTUPINFO) };
    PROCESS_INFORMATION pi = { 0 };
    LPVOID lpBaseAddr;
    WORD wCodeOld, wCodeNew = 0x9090;

    switch (uMsg)
    {
    case WM_COMMAND:
        switch (LOWORD(wParam))
        {
        case IDC_BTN_LOADTEST:
            GetStartupInfo(&si);
            if (!CreateProcess(NULL, szCommandLine, NULL, NULL, FALSE, DEBUG_ONLY_ THIS_PROCESS,
                NULL, NULL, &si, &pi))
                break;

            DEBUG_EVENT debugEvent;
            while (TRUE)
            {
                // 等待调试事件发生
                WaitForDebugEventEx(&debugEvent, INFINITE);

                // 处理调试事件
                if (debugEvent.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT)
                {
                    lpBaseAddr = (LPBYTE)(debugEvent.u.CreateProcessInfo. lpBaseOf Image) + 0x1009;
                    if (ReadProcessMemory(pi.hProcess, lpBaseAddr, &wCodeOld, sizeof (WORD), NULL))
                    {
                        // 目标进程lpBaseAddr地址处的数据内容是否为0x1774,如果是,则替换
                        if (wCodeOld == 0x1774)
                        {
                            WriteProcessMemory(pi.hProcess, lpBaseAddr, &wCodeNew, sizeof (WORD), NULL);
                       }
                       else
                       {
                           MessageBox(hwndDlg, TEXT("目标软件版本错误"), TEXT("提示"), MB_OK);
                           TerminateProcess(pi.hProcess, 0);
                       }
                   }

                   CloseHandle(debugEvent.u.CreateProcessInfo.hFile);
               }

               else if (debugEvent.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT)
               {
                   MessageBox(hwndDlg, TEXT("被调试进程退出"), TEXT("提示"), MB_OK);
                   break;
               }

               // 处理完一个调试事件后调用ContinueDebugEvent函数恢复线程执行并继续等待下一个调试事件
               ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);
            }

            CloseHandle(pi.hThread);
            CloseHandle(pi.hProcess);
            break;

        case IDCANCEL:
            EndDialog(hwndDlg, 0);
            break;
        }
        return TRUE;
    }

    return FALSE;
}

编译运行程序,单击"加载Test程序"按钮,弹出正版软件消息框。处理调试事件用到了while循环,因此最好创建一个新线程负责处理调试事件。完整代码参见Chapter4\LoadTest2项目。

调试事件CREATE_PROCESS_DEBUG_EVENT与CREATE_PROCESS_DEBUG_INFO结构

当调用CreateProcess函数创建被调试进程(还未开始运行),或者正在运行中的进程被DebugActiveProcess函数附加到调试器中时,会发生CREATE_PROCESS_DEBUG_EVENT事件,u.CreateProcessInfo字段是一个CREATE_PROCESS_DEBUG_INFO结构,该结构包含被调试进程的一些信息:

复制代码
typedef struct _CREATE_PROCESS_DEBUG_INFO {
     HANDLE hFile;                          // 进程的可执行映像文件的句柄,调试器可以使用该句柄读  
                                             // 写映像文件
     HANDLE hProcess;                         // 进程句柄,调试器可以使用该句柄读写进程的内存
     HANDLE hThread;                        // 初始线程的句柄,调试器可以读写线程的寄存器,还可以  
                                             // 暂停、恢复线程
     LPVOID lpBaseOfImage;                    // 可执行文件加载到的基地址
     DWORD  dwDebugInfoFileOffset;            // 可执行文件中调试信息的偏移量
     DWORD   nDebugInfoSize;                 // 文件中调试信息的大小,以字节为单位,如果该值为0则  
                                             // 没有调试信息
     LPVOID lpThreadLocalBase;              // 与线程本地存储有关
     LPTHREAD_START_ROUTINE lpStartAddress;    // 指向线程起始地址的指针
     LPVOID                    lpImageName;         // 可执行文件名称
     WORD                      fUnicode;        // 如果为非零值,表示lpImageName是Unicode,零值  
                                        // 表示ANSI
} CREATE_PROCESS_DEBUG_INFO, * LPCREATE_PROCESS_DEBUG_INFO;

4.6.4 内存补丁

接触过加密/解密的读者一定听说过加壳这个概念,加壳是指将可执行文件的代码和数据经过各种加密手段转换变形后得到加密文件,并添加一段用于还原加密文件的代码(解密代码),这样在程序执行时,解密代码会还原加密文件为原可执行文件,用户并不会感觉到程序被改动过,这段用于解密还原的代码就像是一层壳用于保护原可执行文件。

加壳方案有两个:压缩和加密。压缩方案可以将可执行文件的内容压缩存储,减少文件占用的磁盘空间,这时壳代码是解压缩代码;而加密方案则是为了保证可执行文件的内容不被随意修改(如前所述,使用十六进制编辑器修改关键代码),这时壳代码就是解密代码,一般解密代码中会同时包含有反调试、跟踪模块。在被加壳的文件中,原可执行文件的代码和数据已经面目全非,使用十六进制编辑器很难找到特征码,所以无法采用修改文件的方法制作静态补丁。

要对加过壳的软件进行修改可以首先将它脱壳还原为原可执行文件并保存,具体来说就是调试跟踪解密代码,等解密代码将加密文件还原后立刻保存内存中的原可执行文件数据。但是除一些加密强度不高的壳可以通过调试、跟踪、脱壳完全恢复原来的文件外,在大多数情况下的脱壳效果并不令人满意。在这种情况下,动态内存补丁技术会派上用场,而且加载器程序应该具有调试器的功能,可以模拟手工使用OllyDBG等调试器进行跟踪的过程,一直跟踪到壳代码执行完毕,原可执行文件的代码被恢复后再打内存补丁,前面讲过的调试API可以帮助我们实现这一点。

下面使用压缩壳UPX为Test程序加壳,加载器程序使用调试API进行调试、跟踪加壳后的Test程序,等执行到关键地址时,实现内存补丁。Chapter4\Test\Release\Test_UPX.exe是加壳后的Test程序,OllyDBG载入Test_UPX程序,弹出消息框:"模块'Test_UPX'的快速统计报告表明其代码段可能被压缩, 加密, 或包含大量的嵌入数据. 代码分析将是非常不可靠或完全错误的. 您仍要继续分析吗?",单击"否(N)"按钮。反汇编代码如原书的图4.10所示。

可以看到,反汇编代码与加壳以前完全不同,程序入口点(壳的入口点)已经不是原Test程序的入口点,但是不管如何加壳变换,壳解密代码一定会解密原可执行文件的代码,并在解密完后执行原可执行文件的代码。问题的关键是如何在调试、跟踪解密代码解密完毕转去执行原可执行文件入口点代码时中断,这时加密文件已经被还原,我们可以对程序的代码打内存补丁。

寻找OEP(Original Entry Point,原程序入口点)是脱壳领域永恒的话题,pushad指令用于将8个通用寄存器的值压入栈,popad指令用于从栈中恢复8个通用寄存器的值。壳解密代码是一段子程序(函数),对UPX壳来说,在解密还原原程序前通过pushad指令保存通用寄存器的值,解密完毕通过popad指令恢复通用寄存器的值,因此通常在执行popad指令后,离OEP就不再遥远。用鼠标右键单击反汇编代码区域,然后选择查找→命令,弹出查找命令对话框(不要勾选整个块),输入popad,单击"查找"按钮,如原书的图4.11所示。

用鼠标右键单击00908C4E这一行,然后选择断点→运行到选定位置(快捷键F4),此时我们可以通过F8单步执行逐步跟踪。在单步执行时,如果遇到循环指令往上跳转,则通常我们应该在该指令的下一条指令上F4,如原书的图4.12所示。

单步执行到00908C57这一行,有一条向上的红色跳转线。如果是红色,则表示跳转会实现;如果是灰色,则表示跳转不会实现,这时应该选中00908C59这一行,继续按F4键。00908C5C jmp 008F128B这一行就是跳转到OEP,单步到达OEP。到达OEP后继续按F8键,如原书的图4.13所示。

008F1202 call 008F1000这一行就是call进WinMain函数,按F7键单步步入,到达WinMain函数,如原书的图4.14所示。

按F7键单步步入与按F8键单步步过的区别是:在遇到call指令时,按F7键会进入call调用内部,而按F8键则不会。这是我们熟悉的反汇编代码,用鼠标右键单击008F1009这一行,然后选择二进制→用NOP填充,按F9键运行程序,弹出正版软件消息框。

注意,每次运行Test_UPX程序的入口点地址可能都是不同的,OEP和WinMain的地址也可能是不固定的。关于OEP和WinMain:在使用VS编译程序时,编译器会在WinMain函数前面添加一些C/C++启动代码,执行完C/C++启动代码后再调用WinMain函数,因此一个程序的入口点地址并不是WinMain。

注意,对于UPX壳,查找OEP使用的是栈平衡原理,该方法对于其他加壳程序不一定有效。

调试事件EXCEPTION_DEBUG_EVENT与EXCEPTION_DEBUG_INFO结构

被调试进程开始执行第一条指令时会发生EXCEPTION_DEBUG_EVENT调试事件,后续在发生调试中断(遇到 int3 或者单步中断)和发生异常时也会发生本事件。u.Exception 字段是一个 EXCEPTION_DEBUG_INFO结构,该结构包含异常信息:

复制代码
typedef struct _EXCEPTION_DEBUG_INFO {
     EXCEPTION_RECORD ExceptionRecord;    // EXCEPTION_RECORD结构,包含异常信息
     DWORD              dwFirstChance;       // 是否是第一次发生异常
} EXCEPTION_DEBUG_INFO, * LPEXCEPTION_DEBUG_INFO;

其中,ExceptionRecord字段是一个EXCEPTION_RECORD结构:

复制代码
typedef struct _EXCEPTION_RECORD {
     DWORD                        ExceptionCode;         // 异常代码
     DWORD                        ExceptionFlags;        // 异常标志
     struct _EXCEPTION_RECORD*    ExceptionRecord;       
     PVOID                        ExceptionAddress;      // 发生异常的地址
     DWORD                        NumberParameters;
     ULONG_PTR                    ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, * PEXCEPTION_RECORD;

ExceptionCode字段表示异常代码,常用的异常代码如表4.14所示。

表4.14

异常代码 含义
EXCEPTION_BREAKPOINT 遇到断点,例如int3断点
以OllyDBG为例,用鼠标右键单击反汇编代码中的一行,然后选择断点→切换(快捷键F2),就是下了一个int3断点,int3是最常用的普通断点,其原理是把指令的第1字节修改为0xCC,当运行到该条指令发现第 1 字节是 0xCC 时就会触发一个异常并暂停,即 debugEvent.u.Exception. ExceptionRecord.ExceptionCode等于EXCEPTION_BREAKPOINT,然后调试器会把该指令的第一字节修改回原来的字节指令,并把指令指针寄存器EIP的值减1,以重新执行该指令,int3断点也称为CC断点、CC指令
EXCEPTION_SINGLE_STEP 跟踪陷阱或单步中断。OllyDBG调试菜单中的单步步入(快捷键F7)就是单步中断。单步中断的原理是当执行到一条指令时,如果发现标志寄存器的TF位为1,就会触发一个异常并暂停,即debugEvent.u.Exception. ExceptionRecord.ExceptionCode等于EXCEPTION_SINGLE_STEP,触发异常后系统会自动把标志寄存器的TF位置0,因此如果需要每执行完一条指令后都触发单步中断,在处理完调试事件后应该把标志寄存器的TF位重新置1。标志寄存器前16位的部分含义如下。第0位:CF。        第8位:TF。第2位:PF。        第9位:IF。第4位:AF。        第10位:DF。第6位:ZF。        第11位:OF。第7位:SF。
EXCEPTION_ACCESS_VIOLATION 线程试图读取或写入对其没有适当访问权限的虚拟地址

再次使用OllyDBG调试Test_UPX,Test_UPX程序的入口点变为0x00278AA0,如原书的图4.15所示。

popad指令的地址变为0x00278C4E,如原书的图4.16所示。

WinMain变为0x00261000,如原书的图4.17所示。

打开OllyDBG的查看菜单→内存,如原书的图4.18所示。

Test_UPX程序加载到的基地址为0x00260000,UPX0区段的基地址为0x00261000,这正是WinMain函数的地址,Test_UPX程序的入口点地址为0x00278AA0,可以看到这个地址属于UPX1区段的范围。对UPX壳来说,解密代码解密加密文件的数据到UPX0区段,解密完毕跳转到UPX0区段执行。

Test_UPX程序的popad指令的地址为0x00278C4E,Test_UPX程序加载到的基地址为0x00260000,0x00278C4E−0x00260000等于0x00018C4E,这个差值应该是不变的。通过Test_UPX程序加载到的基地址加上差值很容易得到popad指令的地址,在该地址处设置一个int3断点,当程序执行到popad指令时中断,这时解密代码已经执行完毕,可以对Test_UPX程序加载到的基地址 + 0x1000 + 0x9地址处打内存补丁。LoadTest_UPX.cpp源文件的部分内容如下:

复制代码
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    TCHAR szCommandLine[MAX_PATH] = TEXT("Test_UPX.exe");
    STARTUPINFO si = { sizeof(STARTUPINFO) };
    PROCESS_INFORMATION pi = { 0 };
    static LPVOID lpPopad, lpPatch;    // popad地址(基地址+0x18C4E),补丁地址(基地址+0x1000+0x9)
    BYTE bInt3 = 0xCC;                 // popad指令地址处写入int3指令的机器码0xCC
    BYTE bOld = 0x61;                  // 恢复popad指令的机器码
    WORD wCodeNew = 0x9090;
    DEBUG_EVENT debugEvent;
    CONTEXT context;

    switch (uMsg)
    {
    case WM_COMMAND:
        switch (LOWORD(wParam))
        {
        case IDC_BTN_LOADTEST:
            GetStartupInfo(&si);
            if (!CreateProcess(NULL, szCommandLine, NULL, NULL, FALSE, DEBUG_ONLY_ THIS_PROCESS,
                NULL, NULL, &si, &pi))
                break;

            while (TRUE)
            {
                // 等待调试事件发生
                WaitForDebugEvent(&debugEvent, INFINITE);

                // 进程被创建
                if (debugEvent.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT)
                {
                    // lpPopad, lpPatch
                    lpPopad = (LPBYTE)(debugEvent.u.CreateProcessInfo.lpBaseOfImage) + 0x18C4E;
                    lpPatch = (LPBYTE)(debugEvent.u.CreateProcessInfo.lpBaseOfImage) + 0x1000 + 0x9;
                    // popad指令处下int3断点
                    WriteProcessMemory(pi.hProcess, lpPopad, &bInt3, 1, NULL);

                    CloseHandle(debugEvent.u.CreateProcessInfo.hFile);
                }

                // 被调试进程中发生异常事件
                else if (debugEvent.dwDebugEventCode == EXCEPTION_DEBUG_EVENT)
                {
                    switch (debugEvent.u.Exception.ExceptionRecord.ExceptionCode)
                    {
                    case EXCEPTION_BREAKPOINT:          // 断点中断
                        context.ContextFlags = CONTEXT_CONTROL;
                        GetThreadContext(pi.hThread, &context);
                        // 执行int3指令后才会发生异常,这时eip已经指向了下一条指令
                        if (context.Eip == (DWORD)((LPBYTE)lpPopad + 1))
                        {
                            // popad指令处的int3断点改回原popad指令
                            WriteProcessMemory(pi.hProcess, lpPopad, &bOld, 1, NULL);
                            // 内存补丁,JE指令修改为两个NOP指令
                            WriteProcessMemory(pi.hProcess, lpPatch, &wCodeNew, 2, NULL);

                            // 重新执行popad指令
                            context.Eip -= 1;
                            SetThreadContext(pi.hThread, &context);
                        }
                        break;

                    case EXCEPTION_SINGLE_STEP:         // 单步中断
                        break;
                    }

                }

                // 被调试进程退出
                else if (debugEvent.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT)
                {
                    break;
                }

                // 处理完一个调试事件后调用ContinueDebugEvent函数恢复线程执行并继续等待下个事件
                ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);
            }

            CloseHandle(pi.hThread);
            CloseHandle(pi.hProcess);
            break;

        case IDCANCEL:
            EndDialog(hwndDlg, 0);
            break;
        }
        return TRUE;
    }

    return FALSE;
}

完整代码参见Chapter4\LoadTest_UPX项目。

4.6.5 线程环境

每个线程都有属于自己的一组CPU寄存器,称为线程环境。线程环境反映了线程上一次执行时其CPU寄存器的状态。Windows为系统中的所有线程循环分配时间片,当挂起一个线程时,为了以后能够将它恢复执行,系统必须先将线程的运行环境保存下来。当线程在下一个时间片被恢复执行时,再将运行环境恢复原样。对一个线程来说,只要所有的寄存器值没有改变,线程环境就没有改变。线程的CPU寄存器全部保存在一个CONTEXT结构(在winnt.h头文件中定义)中,CONTEXT结构本身保存在线程内核对象中,Windows使用CONTEXT结构保存线程的状态,以便线程在下一次获得CPU时间片时,从上次停止的地址处继续执行。

CONTEXT结构是Windows系统中唯一与硬件平台相关的结构,因为Windows系统可以在不同的硬件平台上运行,只是寄存器的名称有所区别,CONTEXT结构的定义也相应改变。对Win32程序来说,CONTEXT结构的字段包括Eax、Ebx、Ecx和Edx等:

复制代码
typedef struct DECLSPEC_NOINITALL _CONTEXT {
     // 线程环境标志
     DWORD ContextFlags;

     // ContextFlags指定为CONTEXT_DEBUG_REGISTERS标志,则获取、设置调试寄存器的值
     DWORD   Dr0;
     DWORD   Dr1;
     DWORD   Dr2;
     DWORD   Dr3;
     DWORD   Dr6;
     DWORD   Dr7;

     // ContextFlags指定为CONTEXT_FLOATING_POINT标志,则获取、设置浮点寄存器的值
     FLOATING_SAVE_AREA FloatSave;

     // ContextFlags指定为CONTEXT_SEGMENTS标志,则获取、设置段寄存器的值
     DWORD   SegGs;
     DWORD   SegFs;
     DWORD   SegEs;
     DWORD   SegDs;

     // ContextFlags指定为CONTEXT_INTEGER标志,则获取、设置整数寄存器的值
     DWORD   Edi;
     DWORD   Esi;
     DWORD   Ebx;
     DWORD   Edx;
     DWORD   Ecx;
     DWORD   Eax;

     // ContextFlags指定为CONTEXT_CONTROL标志,则获取、设置控制寄存器的值
     DWORD   Ebp;
     DWORD   Eip;
     DWORD   SegCs;                      // MUST BE SANITIZED
     DWORD   EFlags;                     // MUST BE SANITIZED
     DWORD   Esp;
     DWORD   SegSs;

     // ContextFlags指定为CONTEXT_EXTENDED_REGISTERS标志,则获取、设置扩展寄存器的值
     BYTE    ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;

typedef CONTEXT* PCONTEXT;

用于获取和设置线程环境的函数分别是GetThreadContext和SetThreadContext函数:

复制代码
BOOL WINAPI GetThreadContext(
    _In_        HANDLE     hThread,         // 线程句柄
    _Inout_    LPCONTEXT  lpContext);      // CONTEXT结构的指针
BOOL WINAPI SetThreadContext(
    _In_        HANDLE     hThread,        // 线程句柄
    _In_ const LPCONTEXT lpContext);      // CONTEXT结构的指针

调用GetThreadContext和SetThreadContext函数前,CONTEXT.ContextFlags应该设置为需要获取或设置的寄存器标志,例如 ContextFlags 指定为 CONTEXT_DEBUG_REGISTERS 标志表示获取、设置调试寄存器的值,指定为 CONTEXT_FLOATING_POINT 标志表示获取、设置浮点寄存器的值,指定为 CONTEXT_SEGMENTS标志表示获取、设置段寄存器的值,指定为CONTEXT_INTEGER标志表示获取、设置整数寄存器的值,指定为CONTEXT_CONTROL标志表示获取、设置控制寄存器的值,指定为CONTEXT_EXTENDED_REGISTERS标志表示获取、设置扩展寄存器的值。另外,如果需要获取、设置多个类别寄存器的值,还有如下定义:

复制代码
#define CONTEXT_FULL (CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS)
#define CONTEXT_ALL  (CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS | \
    CONTEXT_FLOATING_POINT | CONTEXT_DEBUG_REGISTERS | CONTEXT_EXTENDED_REGISTERS)

此外,调用GetThreadContext和SetThreadContext函数前,应该暂停线程的执行,在获取、设置相关寄存器的值后再恢复线程的执行,防止函数执行到一半时被Windows切换,但是在调试事件中没有这个必要,因为在调用ContinueDebugEvent函数前,目标线程不会恢复执行。

这两个函数出现最多的场合就是设置指令指针寄存器Eip的值,使线程转去另一个地址处执行。调用这两个函数,必须具有对目标线程的THREAD_GET_CONTEXT或THREAD_SET_CONTEXT访问权限,当然调试器具有对被调试进程线程的所有访问权限。

对于LoadTest_UPX示例程序,相信读者很容易结合注释进行理解,在popad指令地址处设置一个int3普通断点即可解决问题,非常简单。但是不可以一开始直接在WinMain地址处下断,因为在执行到popad指令前,解密代码还没有执行完毕,开始时UPX0区段中的数据全为0。

ReadProcessMemory和WriteProcessMemory函数可以随意读写指定进程的地址空间,GetThreadContext和SetThreadContext函数可以随意读写指定线程的线程环境。这一切都建立在对目标程序充分了解的基础上,前期需要通过OllyDBG一类的调试器跟踪调试,完全明白程序的执行流程。

调用SetThreadContext函数时应该谨慎,例如下面的代码:

复制代码
CONTEXT Context;
Context.Eip = 0;
SetThreadContext(hThread, &Context);
ResumeThread(hThread);

在以合适的权限获取到一个线程的句柄后,我们可以读写该线程的线程环境,上述代码会导致目标线程访问违规,目标线程所属进程会终止并退出。

4.7 窗口间谍

接下来实现一个窗口间谍实用程序WindowSearch,程序界面如原书的图4.19所示。

图像静态控件中的图像在正常状态下如原书的图4.20(1)所示,当用户拖动图像静态控件中的靶心部分时,图像静态控件中的图像如原书的图4.20(2)所示,鼠标光标如原书的图4.20(3)所示。图像静态控件中的图像正常状态下是Normal.ico,当用户按下鼠标左键拖动的时候变为Drag.ico(向图像静态控件发送STM_SETIMAGE消息),并且鼠标光标变为Drag.cur(调用SetCursor函数),给用户的感觉好像是Normal.ico图像中的靶心部分被拖动出来一样。

当用户拖动时,程序启动一个200ms的计时器,程序处理WM_TIMER消息,实时获取鼠标光标所处位置的窗口信息,包括窗口句柄、窗口类名、窗口过程、窗口标题、进程ID和父进程ID,如果是子窗口控件还会显示控件ID,另外为了提示用户鼠标光标所处位置是哪个窗口,程序在窗口周围绘制一个闪动的矩形。请读者自行测试本程序。

WindowSearch.cpp源文件的内容如下:

复制代码
#include <windows.h>
#include <TlHelp32.h>
#include "resource.h"

#pragma comment(linker,"\"/manifestdependency:type='win32' \
     name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
     processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

// 全局变量
HINSTANCE g_hInstance;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 获取目标窗口句柄
HWND SmallestWindowFromPoint(POINT pt);
// 获取父进程ID
DWORD GetParentProcessIDByID(DWORD dwProcessId);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
    g_hInstance = hInstance;

    DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
    return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    static HCURSOR hCursorDrag;     // 拖动时的光标句柄
    static HICON hIconNormal;       // 正常情况下图像静态控件所用的图标句柄
    static HICON hIconDrag;             // 拖动时的图像静态控件所用的图标句柄
    static HWND hwndTarget;            // 目标窗口句柄
    static HDC hdcDesk;                 // 桌面设备环境句柄,用于在目标窗口周围绘制闪动矩形
    RECT rect;
    POINT pt;
    DWORD dwProcessID, dwParentProcessID, dwCtrlID;
    TCHAR szBuf[128] = { 0 };
    LPTSTR lpBuf = NULL;
    int nLen;

    switch (uMsg)
    {
    case WM_INITDIALOG:
        // 为对话框程序左上角设置一个图标
        SendMessage(hwndDlg, WM_SETICON, ICON_SMALL, (LPARAM)LoadIcon(g_hInstance,
            MAKEINTRESOURCE(IDI_ICON_MAIN)));

        // 拖动时的光标句柄,正常情况下和拖动时的图像静态控件所用的图标句柄
        hCursorDrag = LoadCursor(g_hInstance, MAKEINTRESOURCE(IDC_CURSOR_DRAG));
        hIconNormal = LoadIcon(g_hInstance, MAKEINTRESOURCE(IDI_ICON_NORMAL));
        hIconDrag = LoadIcon(g_hInstance, MAKEINTRESOURCE(IDI_ICON_DRAG));

        // 桌面设备环境,用于在目标窗口周围绘制闪动矩形
        hdcDesk = CreateDC(TEXT("DISPLAY"), NULL, NULL, NULL);
        SelectObject(hdcDesk, CreatePen(PS_SOLID, 2, RGB(255, 0, 255)));
        SetROP2(hdcDesk, R2_NOTXORPEN);
        return TRUE;

    case WM_COMMAND:
        switch (LOWORD(wParam))
        {
        case IDC_CHK_TOPMOST:
            // 窗口置顶
            if (IsDlgButtonChecked(hwndDlg, IDC_CHK_TOPMOST) == BST_CHECKED)
                SetWindowPos(hwndDlg, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
            else
                SetWindowPos(hwndDlg, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
            break;

        case IDC_BTN_MODIFYTITLE:
            // 修改标题
            nLen = SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_WINDOWTITLE), WM_ GETTEXTLENGTH, 0, 0);
            lpBuf = new TCHAR[nLen + 1];
            SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_WINDOWTITLE), WM_GETTEXT,
                (nLen + 1), (LPARAM)lpBuf);
            SendMessage(hwndTarget, WM_SETTEXT, 0, (LPARAM)lpBuf);
            delete[] lpBuf;
            break;

        case IDCANCEL:
            DeleteDC(hdcDesk);
            EndDialog(hwndDlg, 0);
            break;
        }
        return TRUE;

    case WM_LBUTTONDOWN:
        // 开始拖动
        GetWindowRect(GetDlgItem(hwndDlg, IDC_STATIC_ICON), &rect);
        GetCursorPos(&pt);
        if (PtInRect(&rect, pt))
        {
            SetCapture(hwndDlg);
            SetCursor(hCursorDrag);
            SendMessage(GetDlgItem(hwndDlg, IDC_STATIC_ICON), STM_SETIMAGE,
                IMAGE_ICON, (LPARAM)hIconDrag);
            SetTimer(hwndDlg, 1, 200, NULL);
        }
        return TRUE;

    case WM_LBUTTONUP:
        // 停止拖动
        ReleaseCapture();
        SendMessage(GetDlgItem(hwndDlg, IDC_STATIC_ICON), STM_SETIMAGE,
            IMAGE_ICON, (LPARAM)hIconNormal);
        KillTimer(hwndDlg, 1);
        return TRUE;

    case WM_TIMER:
        GetCursorPos(&pt);
        hwndTarget = SmallestWindowFromPoint(pt);
        // 显示窗口句柄
        wsprintf(szBuf, TEXT("0x%08X"), (UINT_PTR)hwndTarget);
        SetDlgItemText(hwndDlg, IDC_EDIT_WINDOWHANDLE, szBuf);

        // 显示窗口类名
        GetClassName(hwndTarget, szBuf, _countof(szBuf));
        SetDlgItemText(hwndDlg, IDC_EDIT_CLASSNAME, szBuf);

        // 显示窗口过程
        wsprintf(szBuf, TEXT("0x%08X"), (ULONG_PTR)GetClassLongPtr(hwndTarget, GCLP_WNDPROC));
        SetDlgItemText(hwndDlg, IDC_EDIT_WNDPROC, szBuf);

        // 如果是子窗口控件,显示ID
        if (dwCtrlID = GetDlgCtrlID(hwndTarget))
        {
            wsprintf(szBuf, TEXT("%d"), dwCtrlID);
            SetDlgItemText(hwndDlg, IDC_EDIT_CONTROLID, szBuf);
        }
        else
        {
            SetDlgItemText(hwndDlg, IDC_EDIT_CONTROLID, TEXT(""));
        }

        // 显示窗口标题
        nLen = SendMessage(hwndTarget, WM_GETTEXTLENGTH, 0, 0);
        if (nLen > 0)
        {
            lpBuf = new TCHAR[nLen + 1];
            SendMessage(hwndTarget, WM_GETTEXT, (nLen + 1), (LPARAM)lpBuf);
            SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_WINDOWTITLE), WM_SETTEXT, 0, (LPARAM)lpBuf);
            delete[] lpBuf;
        }
        else
        {
            SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_WINDOWTITLE), WM_SETTEXT, 0, (LPARAM) TEXT(""));
        }

        // 显示进程ID
        GetWindowThreadProcessId(hwndTarget, &dwProcessID);
        wsprintf(szBuf, TEXT("%d"), dwProcessID);
        SetDlgItemText(hwndDlg, IDC_EDIT_PROCESSID, szBuf);

        // 显示父进程ID
        if ((dwParentProcessID = GetParentProcessIDByID(dwProcessID)) >= 0)
        {
            wsprintf(szBuf, TEXT("%d"), dwParentProcessID);
            SetDlgItemText(hwndDlg, IDC_EDIT_PARENTPROCESSID, szBuf);
        }
        else
        {
            SetDlgItemText(hwndDlg, IDC_EDIT_PARENTPROCESSID, TEXT(""));
        }

        // 目标窗口周围矩形闪动
        GetWindowRect(hwndTarget, &rect);
        if (rect.left < 0) rect.left = 0;
        if (rect.top < 0) rect.top = 0;
        Rectangle(hdcDesk, rect.left, rect.top, rect.right, rect.bottom);     
        // 绘制洋红色矩形
        Sleep(200);
        Rectangle(hdcDesk, rect.left, rect.top, rect.right, rect.bottom);     
        // 擦除洋红色矩形
        return TRUE;
    }

    return FALSE;
}

HWND SmallestWindowFromPoint(POINT pt)
{
    RECT rect, rcTemp;
    HWND hwnd, hwndParent, hwndTemp;

    hwnd = WindowFromPoint(pt);
    if (hwnd != NULL)
    {
        GetWindowRect(hwnd, &rect);
        hwndParent = GetParent(hwnd);

        // 如果hwnd窗口具有父窗口
        if (hwndParent != NULL)
        {
            // 查找和hwnd同一级别的下一个Z顺序窗口
            hwndTemp = hwnd;
            do
            {
                hwndTemp = GetWindow(hwndTemp, GW_HWNDNEXT);

                // 如果与hwnd同一级别的下一个Z顺序窗口包含指定的坐标点pt并且可见
                GetWindowRect(hwndTemp, &rcTemp);
                if (PtInRect(&rcTemp, pt) && IsWindowVisible(hwndTemp))
                {
                    // 找到的窗口是不是比hwnd窗口更小
                    if (((rcTemp.right - rcTemp.left) * (rcTemp.bottom - rcTemp.top)) <
                        ((rect.right - rect.left) * (rect.bottom - rect.top)))
                    {
                        hwnd = hwndTemp;
                        GetWindowRect(hwnd, &rect);
                    }
                }
            } while (hwndTemp != NULL);
        }
    }

    return hwnd;
}

DWORD GetParentProcessIDByID(DWORD dwProcessId)
{
    HANDLE hSnapshot;
    PROCESSENTRY32 pe = { sizeof(PROCESSENTRY32) };
    BOOL bRet;

    hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnapshot == INVALID_HANDLE_VALUE)
        return -1;

    bRet = Process32First(hSnapshot, &pe);
    while (bRet)
    {
        if (pe.th32ProcessID == dwProcessId)
            return pe.th32ParentProcessID;

        bRet = Process32Next(hSnapshot, &pe);
    }

    CloseHandle(hSnapshot);
    return -1;
}

自定义函数SmallestWindowFromPoint用于根据鼠标光标位置获取鼠标光标下的窗口句柄,单纯使用WindowFromPoint函数可以获取指定坐标处的窗口的窗口句柄,但是可能不准确,例如当鼠标光标位于一个程序的子窗口控件上时,我们希望获取到的是这个子窗口控件的窗口句柄,而不是主程序的窗口句柄。与WindowFromPoint函数不同,GetParent函数用于获取指定窗口的父窗口句柄:

复制代码
HWND WINAPI WindowFromPoint(_In_ POINT Point);    // 指定坐标
HWND WINAPI GetParent(_In_ HWND hWnd);              // 指定窗口

GetWindow函数用于获取与指定窗口具有指定关系的窗口的句柄:

复制代码
HWND WINAPI GetWindow(
     _In_ HWND hWnd,         // 指定窗口
     _In_ UINT uCmd);       // 指定关系

uCmd参数用于指定要获取的窗口句柄与hWnd窗口的关系,可以是表4.15所示的值之一。

表4.15

常量 含义
GW_HWNDFIRST 0 同一级别中Z顺序最高的窗口
GW_HWNDLAST 1 同一级别中Z顺序最低的窗口
GW_HWNDNEXT 2 同一级别中下一个Z顺序窗口
GW_HWNDPREV 3 同一级别中上一个Z顺序窗口
GW_CHILD 5 Z顺序最高的子窗口,也就是第一个子窗口

有了窗口句柄,可以通过调用GetWindowThreadProcessId函数获取创建该窗口的进程ID和线程ID:

复制代码
DWORD WINAPI GetWindowThreadProcessId(
     _In_        HWND    hWnd,                 // 窗口句柄
     _Out_opt_ LPDWORD lpdwProcessId);     // 返回创建该窗口的进程ID

如果函数执行成功,则返回值是创建该窗口的线程的ID。

4.8 示例:一个程序退出时删除自身

要实现程序退出时删除自身,方法有很多,下面我们采取调用CreateProcess函数创建进程启动cmd.exe执行批处理文件的方式来实现。DeleteProgramSelf.cpp源文件内容如下:

复制代码
#include <windows.h>
#include <tchar.h>
#include <strsafe.h>
#include "resource.h"

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     CHAR szApplicationName[MAX_PATH] = { 0 };              // 本程序文件路径
     TCHAR szCmdPath[MAX_PATH] = { 0 };                      // cmd.exe文件路径
     TCHAR szBatFilePath[MAX_PATH];                            // .bat文件路径
     TCHAR szBatFileName[MAX_PATH] = TEXT("删除程序.bat");    // .bat文件名称

     CHAR szBatFileContent[MAX_PATH * 3] = { 0 };           // .bat文件内容
     CHAR szBatFileContentFormat[MAX_PATH] = 
              { "@ping 127.0.0.1 -n 5 >nul\r\ndel \"%s\"\r\ndel %%0" };
     HANDLE hFile;

     TCHAR szCommandLine[MAX_PATH] = TEXT("/c ");// CreateProcess的lpCommandLine参数
     STARTUPINFO si = { sizeof(STARTUPINFO) };
     si.dwFlags = STARTF_USESHOWWINDOW;
     si.wShowWindow = SW_HIDE;
     PROCESS_INFORMATION pi = { 0 };

     switch (uMsg)
     {
     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDCANCEL:
               // 本程序文件路径
               GetModuleFileNameA(NULL, szApplicationName, _countof(szApplication Name));

               // cmd.exe文件路径
               GetEnvironmentVariable(TEXT("ComSpec"), szCmdPath, _countof(szCmd Path));

               // .bat文件路径,放到系统临时目录
               GetTempPath(_countof(szBatFilePath), szBatFilePath);
               if (szBatFilePath[_tcslen(szBatFilePath) - 1] != TEXT('\\'))
                   StringCchCat(szBatFilePath, _countof(szBatFilePath), TEXT("\\"));
               StringCchCat(szBatFilePath, _countof(szBatFilePath), szBatFileName);

               // 创建.bat文件,写入内容
               hFile = CreateFile(szBatFilePath, GENERIC_READ | GENERIC_WRITE,
                    FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
               wsprintfA(szBatFileContent, szBatFileContentFormat, szApplicationName);
               WriteFile(hFile, szBatFileContent, strlen(szBatFileContent), NULL, NULL);
               CloseHandle(hFile);

               // 创建进程,执行.bat批处理文件
               StringCchCat(szCommandLine, _countof(szCommandLine), szBatFilePath);
               if (CreateProcess(szCmdPath, szCommandLine, NULL, NULL, FALSE,
                    CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi))
               {
                    CloseHandle(pi.hThread);
                    CloseHandle(pi.hProcess);
               }

               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

批处理文件的内容如下:

复制代码
@ping 127.0.0.1 -n 5 >nul
del "F:\Source\Windows\Chapter4\DeleteProgramSelf\Debug\DeleteProgramSelf.exe"
del %0

上述3行内容中每一行的含义如下。

  • 第1行是通过ping本地计算机命令使批处理文件延迟5秒再继续执行,目的是等待目标进程退出销毁。

  • 第2行是删除目标文件,加""是防止目标文件路径中存在空格。

  • 第3行是删除批处理文件自身。

完整代码参见Chapter4\DeleteProgramSelf项目。

相关推荐
今儿敲了吗2 小时前
03停用词过滤
笔记·python
www.023 小时前
Codex额度用完怎么办?Windows定时自动继续VS Code Codex任务实战
人工智能·windows·vscode·自动化·openai·codex·autohotkey
秦哈哈4 小时前
【Hello Agents】学习笔记(二)
笔记·学习·microsoft
风早爽太5 小时前
Flutter 开发笔记:share_plus 分享插件
笔记·flutter
宵时待雨5 小时前
linux笔记归纳19:网络层协议IP
linux·网络·笔记·网络协议·tcp/ip
javgo.cn6 小时前
Markune:一个开源的 Local-first Markdown 工作区
笔记·编辑器
逆境不可逃6 小时前
Pi Agent 学习笔记:不同模型如何接入同一套工具循环
笔记·学习
StarDream-Online7 小时前
Windows 下 uv 安装与配置完全指南(含环境变量设置)
windows·uv
♪飙♪7 小时前
电气笔记——表的认识
笔记