Windows2000下用户模式的内存扫描

简述:
本文简要介绍了在Windows2000下实现内存扫描的基本理论和实现的办
法 。内存扫描是一项重要的技术,有相当广泛的应用范围:如病毒扫描、
游戏修改等 。Windows2000是一个完全保护的系统,且具有两种工作模式,
即用户态和核心态(User Model and Kernel Model) 。内存扫描也可分为
用户态的内存扫描与核心态的内存扫描 。本文主要讲述的是工作于用户态
的内存扫描 。
一.相关理论
早期在Dos坏境下进行内存扫描是一件相对简单的事情 。因为DOS工作在
CPU的实模式下,没有采用虚存技术也没有提供内存的保护机制,只要实实
在在的扫描完所有的物理内存,一切工作也就完成了,早期有一些防毒软
件就是用了这样的办法 。当然为了提高效率,我们并不用扫描所有的内存
区域,因为有些空间是没有被用到的,扫描这些地方也是只浪费时间 。这
可以通过遍历DOS系统的MCB(Memory Control Block)链,来得到实际内
存的使用区域,从而使扫描的效率大大提高 。相似的思路在Windows2000下
的内存扫描也是适用的 。
Windows2000则是一个完全保护的系统,工作于CPU的保护模式下,引入
了虚存技术 。每个进程拥有独立的4GB的地址空间,其中低的2GB为进程的私有空间,高的2GB为系统空间的映射(如果在Boot.ini文件中使用
“/3GB”的开关可以使进程的私有空间增大到3GB,系统空间1GB) 。对于
每个进程来讲其虚拟的地址空间是连续的,实际上它们是以页面为单位
离 散的存在于物理内存中,一些可能被交换到硬盘上的页面文件中,而
且还有大部分的空间是未提交(Uncommitted)的 。因此在Windows2000
中对进程的用户空间进行扫描必须依次对每个进程的空间进行扫描 。一
个进程的低2GB有空间的分布如下表:
范围
大小
作用
0x0~~ 0xFFFF
64 KB
不可访问区域,只是用来防止非法的指针访问,访问该范围的地址会导致访问违例 。
0x10000~~
0x7FFEFFFF
2 GB 减去至少 192 KB
进程的私有地址空间
0x7FFDE000~~
0x7FFDEFFF
4 KB
【Windows2000下用户模式的内存扫描】进程中第一个线程的线程环境块,即 TEB ( Thread environment block )
0x7FFDF000~~ 0x7FFDFFFF
4 KB
进程的进程环境块,即 PEB ( Process environment block )
0x7FFE0000~~
0x7FFE0FFF
4 KB
一个共享的只读用户数据块,该块映射到到系
统空间的一个数据块,其中存放的是一些系统
信息如系统时间、时钟的滴答数、系统版本号
等 。这样访问这些信息的时候系统就不用切换
到核心模式 。
0x7FFE1000~~
0x7FFEFFFF
60 KB
不可访问
0x7FFF0000~~ 0x7FFFFFFF
64 KB
不可访问,用于防止线程的缓冲跨越两种模式
空间的边界
表 1
二.实现 从上表可以看出,我们要扫描范围的起点和终点不是从 0~~2GB,而只是其中的一 部分 。要得到这个起点和终点可以使用API函数GetSystemInfo,函数的原型如下: VOID GetSystemInfo( LPSYSTEM_INFO lpSystemInfo // system information ); 而在结构SYSTEM_INFO中有两个域: lpMinimumApplicationAddress 和 lpMaximumApplicationAddress (类型都是 LPVOID) 中 ,我们就可以得到一个应用程序可用的最小和最大的地址空间 。这样我们就得到了要扫描的地址的起点和终点 。那么是不是这起点和终点间所有的地址都要扫描呢?并不是这样的,因为一般情况下一个进程是用不着这么大(接近2GB)的地址空间的 。因此一个进程的大部分地址空间都是未用(Free)或是保留(Reserved)的,真正用到的只是那些已提交(Committed)的内存而已 。内存页面可以有三种状态: 未用( Free)、保留(Reserved)和提交
(Committed) 。一个未用的页面是指该页面未被保留或是提交,对一个进
程来讲一个未用的页面是不可访问的,访问这样的页面将导致访问违例 。
进程可以要求系统保留一些页面以备后用,系统返回一段保留的地址给进
程,但是这些地址同样是不可访问的,进程若想使用这段地址空间,使用
必须先提交 。只有一个提交的页面才是一个真正可以访问的页面 。不过你
提交了一个页面,系统并不会马上分配物理页面,只有在该页面第一次被
访问到时,系统才会分配页面并初始化 。另外,这三个状态的两两之间都
是可以相互转化的 。相关的API函数有 VirtualAlloc 、 VirtualAllocEx 、
VirtualFree 、 VirtualFreeEx 等 .
这样我们的工作已大大减少了,只需要扫描那些提交的页面就好了 。接下来要做的就
是得到一个进程的已提交的页面范围 。这就要用到另外两个 API函数VirtualQuery和
VirtualQueryEx 。两个函数的功能相似,不同就是VirtualQuery只是查询本进程而
VirtualQueryEx可以查询指定进程的内存空间信息,后者正是我们所需要的,函数原
型如下:
DWord VirtualQueryEx(
HANDLE hProcess , // handle to process LPCVOID lpAddress , // address of region PMEMORY_BASIC_INFORMATION lpBuffer , // information buffer SIZE_T dwLength // size of buffer ); 第一个参数是进程的句柄;第二个参数是内存地址指针;第三个参数是指向 MEMORY_BASIC_INFORMATION 结构的指针,用于返回内存空间的信息;第四个参数是 lpBuffer 的长度 。再来看一下结构 MEMORY_BASIC_INFORMATION 的声明: typedef struct _MEMORY_BASIC_INFORMATION { PVOID BaseAddress ; PVOID AllocationBase ; DWORD AllocationProtect ; SIZE_T RegionSize ; DWORD State ; DWORD Protect ; DWORD Type ; } MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION; 第一个参数是查询内存块的基地址;第二个参数指的是用VirtualAlloc分配该内存时实际分配的基地址,可以小于 BaseAddress,也就是说 BaseAddress 一定包含在 AllocationBase 分配的范围内;第三个参数指的是分 配该页面时,页面的一些属性,如 PAGE_READWRITE、PAGE_EXECUTE 等(其它属性 可参考 Platform SDK );第四 个参数指的是从 BaseAddress 开始,具有相同属性的页面的大小 。第五参数指的是页面的状态,有三种可能值: MEM_COMMIT、MEM_FREE 和 MEM_RESERVE,这个参数对我们来说是最重要的了,从中我们便可知指定内存页面的状态了; 第六个参数指的是页面的属性,其可能的取值与 AllocationProtect 相同;最后一个参数指明了该内存块的类型,有三种可能值: MEM_IMAGE 、 MEM_MAPPED 和 MEM_PRIVATE。这样我们就可得到进程中需要扫描的地址范围了 。到这里剩下的问题就是要读取指定的进程的指定的地地址空间的内容了 。这里要用到的是用于调试程序和错误处理( Debugging and Error Handling )的 API函数 。在“ Platform SDK: Debugging and Error Handling” 章节中,介绍了一部分与程序调试和错误处理相关的 API函数,有许多是很有用,例如我们下面用到的 ReadProcessMemory 和 WriteProcessMemory,它们原型如下: BOOL ReadProcessMemory( HANDLE hProcess , // handle to the process LPCVOID lpBaseAddress , // base of memory area LPVOID lpBuffer , // data buffer SIZE_T nSize , // number of bytes to read SIZE_T * lpNumberOfBytesRead // number of bytes read ); BOOL WriteProcessMemory( HANDLE hProcess , // handle to process LPVOID lpBaseAddress , // base of memory area LPCVOID lpBuffer , // data buffer SIZE_T nSize , // count of bytes to write SIZE_T * lpNumberOfBytesWritten// count of bytes written ); 参数很简单从它们的名字都可以猜出其意义了,这里就不多做说明了 。要说明的是 要对一个进程进行 ReadProcessMemory操作,当前进程对要读的进程必须有PROCESS_VM_READ访问权 。要对一个进程进行WriteProcessMemory操作,当前进程对要写的进程必须有PROCESS_VM_WRITE 和PROCESS_VM_OPERATION访问权 。要获得一个进程的句柄和对这个进程的一些控制权可以使用API函数OpenProcess得到,其使用不做详细说明了,只给出其原型: HANDLE OpenProcess( DWORD dwDesiredAccess , // access flag BOOL bInheritHandle , // handle inheritance option DWORD dwProcessId // process identifIEr ); 这样对一个进程的用户地址空间内存扫描的流程基本就阐述清楚了 。
三 相关的问题:
在实际操作中会遇到一些问题 。如果我们指定了写相关的访问权(如
PROCESS_VM_WRITE 、 PROCESS_SET_INFORMATION 、 PROCESS_ALL_ACCESS 等 ),用
OpenProcess打开一些普通进程是没什么问题,但要是打开的是系统安全进程
(如Sy stem、Winlogon、smss、csRSS、services、lsass等)或是一些注册为
服务的进程时,就会遇到“访问拒绝”的错误,这是为了系统的安全而采取的保
护手段 。说明了当前的进程没有足够的权限来进行此操作 。在进程控制结构中
有一个“访问令牌”(Access tokens),里面包含有本进程的权限信息 。一些常
用的权限如表1所示(摘自Inside Windows2000,Third Edition) 。
权限名
权限含义
SeBackup
在备份的时候绕过安全检查
SeDebug
可对一个进程进行调试
SeShutdown
可关闭本地系统
SeTakeOwnerShip
在没有得到自由访问权的情况下得到一个对象的所有权
表 2
要对一个任意进程(包括系统安全进程和服务进程)进行 指定了写相 关的访问权的 OpenProcess操作,只要当前进程具有SeDeDebug权限就 可 以了 。要是一个用户是Administrator或是被给予了相应的权限,就可以具 有该权限 。可是,就算我们用Administrator帐号对一个系统安全进程执行 OpenProcess(PROCESS_ALL_ACCESS,FALSE, dwProcessID)还是会遇到 “访 问拒绝”的错误 。什么原因呢?原来在默认的情况下进程的一些访问权限 是没有被使能( Enabled)的,所以我们要做的首先是使能这些权限 。与此 相关的一些API函数有OpenProcessToken、 LookupPrivilegeValue 、 AdjustTokenPrivileges。我们要修改一个进程的访问令牌,首先要获得进 程访问令牌的句柄,这可以通过 OpenProcessToken得到,函数的原型如:
BOOL OpenProcessToken( HANDLE ProcessHandle , DWORD DesiredAccess , PHANDLE TokenHandle ); 第一参数是要修改访问权限的进程句柄;第三个参数就是返回的访问令牌指针;第二个参数指定你要进行的操作类型,如要修改令牌我们要指定第二个参数为 TOKEN_ADJUST_PRIVILEGES( 其它一些参数可参考 Platform SDK ) 。通过这个函数我们就可以得到当前进程的访问令牌的句柄(指定函数的第一个参数为 GetCurrentProcess()就可以了) 。接着我们可以调用AdjustTokenPrivileges对这个访问令牌进行修改 。AdjustTokenPrivileges的原型如下: BOOL AdjustTokenPrivileges( HANDLE TokenHandle , // handle to token BOOL DisableAllPrivileges , // disabling option PTOKEN_PRIVILEGES NewState , // privilege information DWORD BufferLength , // size of buffer PTOKEN_PRIVILEGES PreviousState , // original state buffer PDWORD ReturnLength // required buffer size ); 第一个参数是访问令牌的句柄;第二个参数决定是进行权限修改还是除能( Disable)所有权限;第三个参数指明要修改的权限,是一个指向 TOKEN_PRIVILEGES 结构的指针,该结构包含一个数组,数据组的每个项指明了权限的类型和要进行的操作 ; 第四个参数是结构 PreviousState 的长度,如果 PreviousState 为空,该参数应为 NULL ;第五个参数也是一个 指向 TOKEN_PRIVILEGES 结构的指针,存放修改前的访问权限的信息,可空;最后一个参数为实际 PreviousState 结构返回的大小 。在使用这个函数前再看一下 TOKEN_PRIVILEGES 这个结构,其声明如下:
typedef struct _TOKEN_PRIVILEGES { DWORD PrivilegeCount ; LUID_AND_ATTRIBUTES Privileges []; } TOKEN_PRIVILEGES, *PTOKEN_PRIVILEGES;PrivilegeCount 指的数组原素的个数,接着是一个 LUID_AND_ATTRIBUTES 类型的数组,再来看一下 LUID_AND_ATTRIBUTES 这个结构的内容,声明如下:
typedef struct _LUID_AND_ATTRIBUTES { LUID Luid ; DWORD Attributes ;} LUID_AND_ATTRIBUTES, *PLUID_AND_ATTRIBUTES
第二个参数就指明了我们要进行的操作类型,有三个可选项:
SE_PRIVILEGE_ENABLED 、 SE_PRIVILEGE_ENABLED_BY_DEFAULT 、
SE_PRIVILEGE_USED_FOR_ACCESS。要使能一个权限就指定 Attributes 为
SE_PRIVILEGE_ENABLED。第一个参数就是指权限的类型,是一个 LUID 的
值,LUID 就是指 locally unique identifier,我想 GUID 大家是比较熟
悉的,和 GUID 的要求保证全局唯一不同,LUID 只要保证局部唯一,就是
指在系统的每一次运行期间保证是唯一的就可以了 。另外和 GUID 相同的
一点,LUID 也是一个 64 位的值,相信大家都看过 GUID 那一大串的值,我
们要怎么样才能知道一个权限对应的 LUID 值是多少呢?这就要用到另外
一个 API 函数 LookupPrivilegeValue,其原形如下:
BOOL LookupPrivilegeValue( LPCTSTR lpSystemName , // system name LPCTSTR lpName , // privilege name PLUID lpLuid // locally unique identifier ); 第一个参数是系统的名称,如果是本地系统只要指明为 NULL 就可以了,
第三个参数就是返回 LUID 的指针,第二个参数就是指明了权限的名称,
如“ SeDebugPrivilege ” 。在 Winnt.h 中还定义了一些权限名称的宏,
如:
#define SE_BACKUP_NAME TEXT("SeBackupPrivilege")
#define SE_RESTORE_NAME TEXT("SeRestorePrivilege")
#define SE_SHUTDOWN_NAME TEXT("SeShutdownPrivilege")
#define SE_DEBUG_NAME TEXT("SeDebugPrivilege")
这样通过这三个函数的调用,我们就可以用 OpenProcess(PROCESS_ALL_ACCESS,FALSE, dwProcessID)来打获得任意进程的句柄,并
且指定了所有的访问权 。
四 总结
用户模式的内存扫描还是具有想当的局限性,它不能完全扫描
Windows2000的全部内存空间 。要对系统空间进行扫描,在Windows2000下,用户模式的应用程序是不能实现的 。要实现对系统空间的扫描,必须
通过工作于核心模式的程序—驱动程序来实现 。