背景
之前,自己写过一个进程内存分析的小程序,其中,就有一个功能是获取进程在内存中的加载基址。由于现在Windows系统引入了ASLR (Address Space Layout Randomization)机制,加载程序时候不再使用固定的基址加载。VS默认是开启基址随机化的,我们也可以设置它使用固定加载基址。至于什么是ASLR?或者ASLR有什么作用?本文就不深入探讨了,感兴趣的,可以自己私下了解了解。
本文就是开发这样的一个小程序,使用两种方法来获取获取指定进程的加载基址。一种是使用进程模块快照方式,然后遍历加载模块并获取基址,另一种是直接调用WIN32 API函数EnumProcessModules,遍历加载模块基址。这两种方法本质上都是一样的。现在,我就把分析过程和实现方式写成文档,分享给大家。
函数介绍
CreateToolhelp32Snapshot 函数
可以通过获取进程信息为指定的进程、进程使用的堆[HEAP]、模块[MODULE]、线程建立一个快照。
函数声明
HANDLE WINAPI CreateToolhelp32Snapshot(
DWORD dwFlags,
DWORD th32ProcessID
);
参数
- dwFlags
指定快照中包含的系统内容,这个参数能够使用下列数值(常量)中的一个或多个:
VALUE MEANING TH32CS_INHERIT 声明快照句柄是可继承的 TH32CS_SNAPALL 在快照中包含系统中所有的进程和线程 TH32CS_SNAPHEAPLIST 在快照中包含在th32ProcessID中指定的进程的所有的堆 TH32CS_SNAPMODULE 在快照中包含在th32ProcessID中指定的进程的所有的模块 TH32CS_SNAPPROCESS 在快照中包含系统中所有的进程 TH32CS_SNAPTHREAD 在快照中包含系统中所有的线程
- th32ProcessID
指定将要快照的进程ID。如果该参数为0表示快照当前进程。该参数只有在设置了TH32CS_SNAPHEAPLIST或者TH32CS_SNAPMODULE后才有效,在其他情况下该参数被忽略,所有的进程都会被快照。返回值
- 调用成功,返回快照的句柄;调用失败,返回INVALID_HANDLE_VALUE 。
Module32First 和 Module32Next 函数
当我们利用函数CreateToolhelp32Snapshot()获得指定进程的快照后,我们可以利用Module32First函数来获得进程第一个模块的句柄,Module32Next函数来获得进程下一个模块的句柄。
OpenProcess 函数
打开一个已存在的进程对象,并返回进程的句柄。
函数声明
HANDLE OpenProcess(
DWORD dwDesiredAccess, //渴望得到的访问权限(标志)
BOOL bInheritHandle, // 是否继承句柄
DWORD dwProcessId// 进程标示符
);
参数
- dwDesiredAccess [in]
访问进程对象。 此访问权限将针对进程的安全描述符进行检查。 此参数可以是一个或多个进程访问权限。如果调用者启用了SeDebugPrivilege权限,则无论安全描述符的内容如何,都会授予所请求的访问权限。- bInheritHandle [in]
如果此值为TRUE,则此进程创建的进程将继承该句柄。 否则,进程不会继承此句柄。- dwProcessId [in]
要打开的本地进程的标识符。返回值
- 如果函数成功,则返回值是指定进程的打开句柄。
- 如果函数失败,返回值为NULL。要获取扩展错误信息,请调用GetLastError。
EnumProcessModules 函数
在指定的进程中检索每个模块的句柄。要控制64位应用程序是否枚举32位模块,64位模块或两种类型的模块,请使用EnumProcessModulesEx函数。
函数声明
BOOL WINAPI EnumProcessModules(
_In_ HANDLE hProcess,
_Out_ HMODULE *lphModule,
_In_ DWORD cb,
_Out_ LPDWORD lpcbNeeded
);
参数
- hProcess [in]
过程的句柄。- lphModule [out]
接收模块句柄列表的数组。- cb [in]
lphModule数组的大小,以字节为单位。- lpcbNeeded [out]
将所有模块句柄存储在lphModule数组中所需的字节数。返回值
- 如果函数成功,返回值不为零。
- 如果函数失败,返回值为零。 要获取扩展错误信息,请调用GetLastError。
实现思路
该程序实现进程基址的主要原理是,遍历进程里的所有加载的模块,那么,第一个加载模块的加载基址就是该进程的加载基址。
那么,对于进程模块加载基址的遍历就有两种两种方法。一种是根据进程模块快照获取,另一种市直接调用EnumProcessModules函数获取。现在,对这两种方法的原理分别进行介绍。
使用进程模块快照的方式获取模块基址的原理是:
- 首先,使用CreateToolhelp32Snapshot 函数获取指定进程的所有模块快照。
- 然后,根据模块快照,使用Module32First 和 Module32Next 函数进行遍历快照,并获取快照信息。其中,就包括有模块的加载基址信息。第一个模块的加载基址便是该进程的加载基址。
- 最后,关闭上面获取的快照的句柄。
使用EnumProcessModules函数获取模块基址的原理:
首先,我们需要使用OpenProcess函数打开指定进程并获取进程的句柄
然后,根据进程句柄调用EnumProcessModules函数获取进程加载的所有模块的加载基址,并保存在数组中。那么,第一个模块的加载基址便是该进程的加载基址
关闭打开的进程句柄
编程实现
获取指定进程模块快照的方式遍历进程模块
PVOID GetProcessImageBase1(DWORD dwProcessId)
{
PVOID pProcessImageBase = NULL;
MODULEENTRY32 me32 = { 0 };
me32.dwSize = sizeof(MODULEENTRY32);
// 获取指定进程全部模块的快照
HANDLE hModuleSnap = ::CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwProcessId);
if (INVALID_HANDLE_VALUE == hModuleSnap)
{
ShowError("CreateToolhelp32Snapshot");
return pProcessImageBase;
}
// 获取快照中第一条信息
BOOL bRet = ::Module32First(hModuleSnap, &me32);
if (bRet)
{
// 获取加载基址
pProcessImageBase = (PVOID)me32.modBaseAddr;
}
// 关闭句柄
::CloseHandle(hModuleSnap);
return pProcessImageBase;
}
直接使用EnumProcessModules函数获取进程模块基址
PVOID GetProcessImageBase2(DWORD dwProcessId)
{
PVOID pProcessImageBase = NULL;
//打开进程, 获取进程句柄
HANDLE hProcess = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
if (NULL == hProcess)
{
ShowError("OpenProcess");
return pProcessImageBase;
}
// 遍历进程模块,
HMODULE hModule[100] = {0};
DWORD dwRet = 0;
BOOL bRet = ::EnumProcessModules(hProcess, (HMODULE *)(hModule), sizeof(hModule), &dwRet);
if (FALSE == bRet)
{
::CloseHandle(hProcess);
ShowError("EnumProcessModules");
return pProcessImageBase;
}
// 获取第一个模块加载基址
pProcessImageBase = hModule[0];
// 关闭句柄
::CloseHandle(hProcess);
return pProcessImageBase;
}
程序测试
我们在 main 函数中调用上述封装的函数进行测试,main 函数为:
int _tmain(int argc, _TCHAR* argv[])
{
PVOID pProcessImageBase1 = NULL;
PVOID pProcessImageBase2 = NULL;
pProcessImageBase1 = GetProcessImageBase1(4500);
pProcessImageBase2 = GetProcessImageBase2(4500);
printf("pProcessImageBase1=0x%p\npProcessImageBase2=0x%p\n",
pProcessImageBase1, pProcessImageBase2);
system("pause");
return 0;
}
测试结果
我们运行程序,程序执行成功,并显示两种方法获取的进程加载基址,而且获取结果都相同。
然后,我们使用 Process Explorer 软件查看PID为 4500 的进程的加载基址,程序基址获取正确,程序测试成功。
总结
这两种方式,本质上都是一样的,只是遍历进程加载模块所使用的方法不相同。那么,进程加载的第一个模块的加载基址,便是进程的加载基址。
参考
参考自《Windows黑客编程技术详解》一书