在NT系列操作系统里让自己“消失”
创建时间:2004-03-06
文章属性:原创
文章提交:SoBeIt (kinsephi_at_hotmail.com)
===================[ 在NT系列操作系统里让自己“消失”]==================
SoBeIt
作者:Holy_Father <holy_father@phreaker.net>
版本:1.2 english
日期:05.08.2003
=====[ 1. 内容 ]============================================
1. 内容
2. 介绍
3. 文件
3.1 NtQueryDirectoryFile
3.2 NtVdmControl
4. 进程
5. 注册表
5.1 NtEnumerateKey
5.2 NtEnumerateValueKey
6. 系统服务和驱动
7. 挂钩和扩展
7.1 权限
7.2 全局挂钩
7.3 新进程
7.4 DLL
8. 内存
9. 句柄
9.1 命名句柄并获得类型
10. 端口
10.1 Netstat, OpPorts和FPortWinXP下
10.2 OpPorts在Win2k和NT4下, FPort在Win2k下
11. 结束
=====[ 2. 介绍 ]==================================================
这篇文档是在Windows NT操作系统下隐藏对象、文件、服务、进程等的技术。这种方法是基于Windows API函数的挂钩。
这篇文章中所描述的技术都是从我写rootkit的研究成果,所以它能写rootkit更有效果并且更简单。这里也同样包括了我的实践。
在这篇文档中隐藏对象意味着改变某些用来命名这些对象的系统函数,使它们将忽略这些对象的名字。这样一来我们改动的那些函数的返回值表示这些对象根本就不存在。
最基本的方法(除去少数不同的)是我们用原始的参数调用原始的函数,然后我们改变它们的输出。
在这篇文章里将描述隐藏文件、进程、注册表键和键值、系统服务和驱动、分配的内存还有句柄。
=====[ 3. 文件 ]========================================
在有很多种隐藏文件使系统无法发现的可能。我们只使用改变API的方法,而没使用那些比如涉及到文件系统的技术。这样会更容易些因为我们无法知道文件系统工作的独特性。
=====[ 3.1 NtQueryDirectoryFile ]=============================
在WINNT里在某些目录中寻找某个文件的方法是枚举它里面所有的文件和它的子目录下的所有文件。文件的枚举是使用NtQueryDirectoryFile函数。
NTSTATUS NtQueryDirectoryFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
OUT PVOID FileInformation,
IN ULONG FileInformationLength,
IN FILE_INFORMATION_CLASS FileInformationClass,
IN BOOLEAN ReturnSingleEntry,
IN PUNICODE_STRING FileName OPTIONAL,
IN BOOLEAN RestartScan
);
对我们来说重要的参数是FileHandle,FileInformation和FileInformationClass。FileHandle是从NtOpenFile获得的目录对象句柄。FileInformation是一个指针,指向函数要写入需要的数据的已分配内存。FileInformationClass决定写入FileImformation的记录的类型。
FileInformationClass是一个变化的枚举类型,我们只需要其中4个值来枚举目录内容:
#define FileDirectoryInformation 1
#define FileFullDirectoryInformation 2
#define FileBothDirectoryInformation 3
#define FileNamesInformation 12
要写入FileInformation的FileDirecoryInformation记录的结构:
typedef struct _FILE_DIRECTORY_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
LARGE_INTEGER CreationTime;
LARGE_INTEGER LastAccessTime;
LARGE_INTEGER LastWriteTime;
LARGE_INTEGER ChangeTime;
LARGE_INTEGER EndOfFile;
LARGE_INTEGER AllocationSize;
ULONG FileAttributes;
ULONG FileNameLength;
WCHAR FileName[1];
} FILE_DIRECTORY_INFORMATION, *PFILE_DIRECTORY_INFORMATION;
FileFullDirectoryInformation:
typedef struct _FILE_FULL_DIRECTORY_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
LARGE_INTEGER CreationTime;
LARGE_INTEGER LastAccessTime;
LARGE_INTEGER LastWriteTime;
LARGE_INTEGER ChangeTime;
LARGE_INTEGER EndOfFile;
LARGE_INTEGER AllocationSize;
ULONG FileAttributes;
ULONG FileNameLength;
ULONG EaInformationLength;
WCHAR FileName[1];
} FILE_FULL_DIRECTORY_INFORMATION, *PFILE_FULL_DIRECTORY_INFORMATION;
FileBothDirectoryInformation:
typedef struct _FILE_BOTH_DIRECTORY_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
LARGE_INTEGER CreationTime;
LARGE_INTEGER LastAccessTime;
LARGE_INTEGER LastWriteTime;
LARGE_INTEGER ChangeTime;
LARGE_INTEGER EndOfFile;
LARGE_INTEGER AllocationSize;
ULONG FileAttributes;
ULONG FileNameLength;
ULONG EaInformationLength;
UCHAR AlternateNameLength;
WCHAR AlternateName[12];
WCHAR FileName[1];
} FILE_BOTH_DIRECTORY_INFORMATION, *PFILE_BOTH_DIRECTORY_INFORMATION;
FileNamesInformation:
typedef struct _FILE_NAMES_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
ULONG FileNameLength;
WCHAR FileName[1];
} FILE_NAMES_INFORMATION, *PFILE_NAMES_INFORMATION;
这个函数在FileInformation中写入这些结构的一个列表。对我们来说在这些结构类型中只有3个变量是重要的。
NextEntryOffset是这个列表中项的偏移地址。第一个项在地址FileInformation+0处,所以第二个项在地址是FileInformation+第一个项的NextEntryOffset。最后一个项的NextEntryOffset是0。
FileName是文件全名。
FileNameLength是文件名长度。
如果我们想要隐藏一个文件,我们需要分别通知这4种类型,对每种类型的返回记录我们需要和我们打算隐藏的文件比较名字。如果我们打算隐藏第一个记录,我们可以把后面的结构向前移动,移动长度为第一个结构的长度,这样会导致第一个记录被改写。如果我们想要隐藏其它任何一个,只需要很容易的改变上一个记录的NextEntryOffset的值就行。如果我们要隐藏最后一个记录就把它的NextEntryOffset改为0,否则NextEntryOffset的值应为我们想要隐藏的那个记录和前一个的NextEntryOffset值的和。然后修改前一个记录的Unknown变量的值,它是下一次搜索的索引。把要隐藏的记录之前一个记录的Unknown变量的值改为我们要隐藏的那个记录的Unkown变量的值即可。
如果没有原本应该可见的记录被找到,我们就返回STATUS_NO_SUCH_FILE。
#define STATUS_NO_SUCH_FILE 0xC000000F
=====[ 3.2 NtVdmControl ]========================================
不知什么原因DOS的枚举NTVDM能够通过函数NtVdmControl也能获得文件的列表。
NTSTATUS NtVdmControl(
IN ULONG ControlCode,
IN PVOID ControlData
);
ConcrolCode标明了在缓冲区ControlData中申请数据的子函数。如果ControlCode为VdmDiretoryFile那么这个函数的功能将和FileInformation设置为FileBothDirectoryInformation的函数NtQueryDirectoryFile功能一样。
#define VdmDirectoryFile 6
这时的ControlData的用法就和FileInformation一样。这里唯一的不同就是我们不知道缓冲区的长度。所以我们需要手动来计算它的长度。我们把所有记录的NextEntryOffset和最后一个记录的FileNameLength还有0X5E(最后一个记录除去文件名的长度)。隐藏的方法和前面提到的使用NtQueryDirectoryFile的方法一样。
=====[ 4. 进程 ]========================================
各种进程信息是通过NtQuerySystemInformation获取的。
NTSTATUS NtQuerySystemInformation(
IN SYSTEM_INFORMATION_CLASS SystemInformationClass,
IN OUT PVOID SystemInformation,
IN ULONG SystemInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
SystemInformationClass标明了我们想要获得的信息的类别,SystemInformation是一个指向函数输出缓冲区的指针,SystemInformationLength是这个缓冲区的长度,ReturnLength是写入字节的数目。
对于正在运行的进程的枚举我们使用设置为SystemProcessesAndThreadsInformation的SystemInformationClass。
#define SystemInformationClass 5
在SystemInformation的缓冲区中返回的数据结构是:
typedef struct _SYSTEM_PROCESSES {
ULONG NextEntryDelta;
ULONG ThreadCount;
ULONG Reserved1[6];
LARGE_INTEGER CreateTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER KernelTime;
UNICODE_STRING ProcessName;
KPRIORITY BasePriority;
ULONG ProcessId;
ULONG InheritedFromProcessId;
ULONG HandleCount;
ULONG Reserved2[2];
VM_COUNTERS VmCounters;
IO_COUNTERS IoCounters; // Windows 2000特有的
SYSTEM_THREADS Threads[1];
} SYSTEM_PROCESSES, *PSYSTEM_PROCESSES;
隐藏进程和隐藏文件方法基本一样,就是改动我们需要隐藏的记录的前一个记录的NextEntryDelta。通常我们不用隐藏第一个记录,因为它是空闲进程(Idle process)。
=====[ 5. 注册表 ]========================================
Windows的注册表是一个很大的树形数据结构,对我们来说里面有两种重要的记录类型需要隐藏。一种类型是注册表键,另一种是键值。因为注册表的结构,隐藏注册表键不象隐藏文件或进程那么麻烦。
=====[ 5.1 NtEnumerateKey ]===============================
因为注册表的结构我们不能请求某个指定部分所有键的列表。我们只能在注册表某个部分通过查询指定键的索引以获得它的信息。这里提供了NtEnumerateKey。
NTSTATUS NtEnumerateKey(
IN HANDLE KeyHandle,
IN ULONG Index,
IN KEY_INFORMATION_CLASS KeyInformationClass,
OUT PVOID KeyInformation,
IN ULONG KeyInformationLength,
OUT PULONG ResultLength
);
KeyHandle是已经用索引标明我们想要从中获取信息的子键的句柄。KeyInformationClass标明了返回信息类型。数据最后写入KeyInformaiton缓冲区,缓冲区长度为KeyInformationLength。写入的字节数由ResultLength返回。
我们需要意识到的最重要的东西是如果我们隐藏了某个键,在这个键之后的所有键的索引都会改变。因为我们是通过高位的索引来获取键的信息,并通过低位的索引来请求这个键。所以我们必须记录之前有多少个记录被隐藏,然后返回正确的值。
让我们来看个例子。假设我们在注册表中有一些键名字是A,B,C,D,E和F。它们的索引从0开始,也就是说索引4对应键E。现在我们如果想要隐藏键B,被挂钩过的应用程序用索引4调用NtEnumerateKey时我们应该返回F键的信息因为有一个索引改变了。现在问题是我们不知道是否会有索引被改变。如果我们不注意索引的改变而对于索引4的请求仍然返回键E而不是键F的话,很有可能在我们用索引1请求时什么都返回不了或者返回键C。这两种情况都会导致错误。这就是为什么我们要注意索引的改变。
现在如果我们通过用索引0到Index重新调用函数来记录转移我们可能会等待一段时间(在1GHz处理器上普通的注册表就得等10秒种那么长的时间)。所以我们不得不想出一种更加巧妙的方法。
我们知道键是按字母排序的(除了引用外)。如果我们忽略引用(我们不需要隐藏)我们能使用以下方法记录改变。我们通过字母排序列出我们想要隐藏的键名的列表(使用RtlCompareUnicodeString),然后当应用程序调用NtEnumerateKey时我们不需要用不可变的变量重新调用它,而能够找到用索引标明的记录的名字。
NTSTATUS RtlCompareUnicodeString(
IN PUNICODE_STRING String1,
IN PUNICODE_STRING String2,
IN BOOLEAN CaseInSensitive
);
String1和String2是将要比较的字符串,CaseInSensitive在不忽略大小写时被设置为True。
函数结果描述String1和String2的关系:
result > 0: String1 > String2
result = 0: String1 = String2
result < 0: String1 < String2
现在我们需要找到一个边缘项。我们在列表中对用索引标明的键按字母比较名字。边缘项是在我们列表中最后一个较短的名字。我们知道转移最多是我们列表中边缘项的数量。但并不是所有我们列表中的项都是注册表中有效的键。所以我们不得不请求我们列表中达到边缘项的所有的在注册表中这个部分的项。这些通过调用NtOpenKey来完成。
NTSTATUS NtOpenKey(
OUT PHANDLE KeyHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes
);
KeyHandle是高位的键的句柄,我们使用NtEnumerateKey的这个值。DesaireAccess是访问权力。KEY_ENUMERATE_SUB_KEYS是它的正确的值。ObjectAttributes描述了我们要打开的子键(包括了它的名字)。
#define KEY_ENUMERATE_SUB_KEYS 8
如果NtOpenKey返回0表示打开成功,意味着这个来自我们列表中的键是存在的。被打开的键通过NtClose来关闭。
NTSTATUS NtClose(
IN HANDLE Handle
);
对每次NtEnumareteKey的调用我们要计算的改变,数量上等同于我们列表中存在于注册表指定部分的键的数量。然后我们把改变的数量加到变量Index,最后调用原始的NtEnumerateKey。
我们使用KeyInformationClass的KeyBasicInformation来获得用索引标明的键的名字。
#define KeyBasicInformation 0
NtEnumerateKey在KeyInformation缓冲区中返回这个结构:
typedef struct _KEY_BASIC_INFORMATION {
LARGE_INTEGER LastWriteTime;
ULONG TitleIndex;
ULONG NameLength;
WCHAR Name[1];
} KEY_BASIC_INFORMATION, *PKEY_BASIC_INFORMATION;
这里我们只需要的东西是Name和它的长度NameLength。
如果没有被转移的索引的记载我们就返回错误STATUS_EA_LIST_INCONSISTENT。
#define STATUS_EA_LIST_INCONSISTENT 0x80000014
=====[ 5.2 NtEnumerateValueKey ]============================
注册表键值不是按字母分类的。幸运的是在一个键里键值的数目比较少,所以我们可以通过重调的方法来获得改变的数目。用来获取一个键值信息的API是NtEnumerateValueKey。
NTSTATUS NtEnumerateValueKey(
IN HANDLE KeyHandle,
IN ULONG Index,
IN KEY_VALUE_INFORMATION_CLASS KeyValueInformationClass,
OUT PVOID KeyValueInformation,
IN ULONG KeyValueInformationLength,
OUT PULONG ResultLength
);
KeyHandle也是等级高的键的句柄。Index是所给键中键值的索引。KeyValueInformationClass描述信息的类型,保存在KeyValueInformation缓冲区中,缓冲区以字节为大小为KeyValueInformationLength。写入字节的数量返回在ResultLength中。
我们通过用0到Index的所有索引重调函数计算转移。键值的名字通过把KeyValueInformationClass设置为KeyValueBasicInformation来获取。
#define KeyValueBasicInformation 0
然后我们获取在KeyValueInformation缓冲区中接下来的数据结构:
typedef struct _KEY_VALUE_BASIC_INFORMATION {
ULONG TitleIndex;
ULONG Type;
ULONG NameLength;
WCHAR Name[1];
} KEY_VALUE_BASIC_INFORMATION, *PKEY_VALUE_BASIC_INFORMATION;
这里我们只对Name和NameLength感兴趣。
如果这里没有被转移的索引记载我们就返回错误STATUS_NO_MORE_ENTRIES。
#define STATUS_NO_MORE_ENTRIES 0x8000001A
=====[ 6. 系统服务和驱动 ]====================================
系统服务和驱动是通过4个独立的API函数枚举的。它们在每个Windows版本中的联系都不一样。所以我们必须挂钩所有4个函数。
BOOL EnumServicesStatusA(
SC_HANDLE hSCManager,
DWORD dwServiceType,
DWORD dwServiceState,
LPENUM_SERVICE_STATUS lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle
);
BOOL EnumServiceGroupW(
SC_HANDLE hSCManager,
DWORD dwServiceType,
DWORD dwServiceState,
LPBYTE lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle,
DWORD dwUnknown
);
BOOL EnumServicesStatusExA(
SC_HANDLE hSCManager,
SC_ENUM_TYPE InfoLevel,
DWORD dwServiceType,
DWORD dwServiceState,
LPBYTE lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle,
LPCTSTR pszGroupName
);
BOOL EnumServicesStatusExW(
SC_HANDLE hSCManager,
SC_ENUM_TYPE InfoLevel,
DWORD dwServiceType,
DWORD dwServiceState,
LPBYTE lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle,
LPCTSTR pszGroupName
);
这里最重要的是lpService,它指向保存服务列表的缓冲区。而指向结果中记录个数的lpServicesReturned也很重要。输出缓冲区中的数据结构取决于函数类型。函数EnumServicesStatusA和
EnumServicesGroupW返回这个结构:
typedef struct _ENUM_SERVICE_STATUS {
LPTSTR lpServiceName;
LPTSTR lpDisplayName;
SERVICE_STATUS ServiceStatus;
} ENUM_SERVICE_STATUS, *LPENUM_SERVICE_STATUS;
typedef struct _SERVICE_STATUS {
DWORD dwServiceType;
DWORD dwCurrentState;
DWORD dwControlsAccepted;
DWORD dwWin32ExitCode;
DWORD dwServiceSpecificExitCode;
DWORD dwCheckPoint;
DWORD dwWaitHint;
} SERVICE_STATUS, *LPSERVICE_STATUS;
函数EnumServicesStatusExA和EnumServicesStatusExW返回这个:
typedef struct _ENUM_SERVICE_STATUS_PROCESS {
LPTSTR lpServiceName;
LPTSTR lpDisplayName;
SERVICE_STATUS_PROCESS ServiceStatusProcess;
} ENUM_SERVICE_STATUS_PROCESS, *LPENUM_SERVICE_STATUS_PROCESS;
typedef struct _SERVICE_STATUS_PROCESS {
DWORD dwServiceType;
DWORD dwCurrentState;
DWORD dwControlsAccepted;
DWORD dwWin32ExitCode;
DWORD dwServiceSpecificExitCode;
DWORD dwCheckPoint;
DWORD dwWaitHint;
DWORD dwProcessId;
DWORD dwServiceFlags;
} SERVICE_STATUS_PROCESS, *LPSERVICE_STATUS_PROCESS;
我们只对lpServiceName感兴趣因为它是系统服务的名字。所有记录都有静态的大小,所以我们想要隐藏一个的话就需要将之后所有记录向前移它的大小。这里我们必须区分SERVICE_STATUS和SERVICE_STATUS_PROCESS的大小。
=====[ 7. 动态挂钩和扩展 ]=====================================
为达到预想的效果我们需要挂钩所有正在运行的进程和所有将要被创建的进程。所有新进程都必须在它们运行第一条指令前被挂钩,否则它们就能够在被挂够前看到被隐藏的对象。
=====[ 7.1 权限 ]=============================================
首先我们得知道我们至少获得管理员administrator权限来获得进入所有正在运行的进程。最好的可能是将我们的进程当做系统服务来运行,因为它运行与SYSTEM用户权限下。为安装服务我们首先得获取特殊的权限。
获取SeDebugPrivilege的权限是很有用的,通过调用OpenProcessToken、LookupPrivilegeValue
和AdjustTokenPrivileges来完成。
BOOL OpenProcessToken(
HANDLE ProcessHandle,
DWORD DesiredAccess,
PHANDLE TokenHandle
);
BOOL LookupPrivilegeValue(
LPCTSTR lpSystemName,
LPCTSTR lpName,
PLUID lpLuid
);
BOOL AdjustTokenPrivileges(
HANDLE TokenHandle,
BOOL DisableAllPrivileges,
PTOKEN_PRIVILEGES NewState,
DWORD BufferLength,
PTOKEN_PRIVILEGES PreviousState,
PDWORD ReturnLength
);
代码如下:
#define SE_PRIVILEGE_ENABLED 0x0002
#define TOKEN_QUERY 0x0008
#define TOKEN_ADJUST_PRIVILEGES 0x0020
HANDLE hToken;
LUID DebugNameValue;
TOKEN_PRIVILEGES Privileges;
DWORD dwRet;
OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,hToken);
LookupPrivilegeValue(NULL,"SeDebugPrivilege",&DebugNameValue);
Privileges.PrivilegeCount=1;
Privileges.Privileges[0].Luid=DebugNameValue;
Privileges.Privileges[0].Attributes=SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken,FALSE,&Privileges,sizeof(Privileges),
NULL,&dwRet);
CloseHandle(hToken);
=====[ 7.2 全局挂钩 ]=======================================
枚举进程通过前面提到的API函数NtQuerySystemInformation来完成。因为系统中还有一些内部native进程,所以使用重写函数第一个指令的方法来挂钩。对每个正在运行的进程我们需要做的都一样。首先在目标进程里分配一部分内存用来写入我们用来挂钩函数的新代码,然后把每个函数开始的5个字节改为跳转指令(jmp),这个跳转会转为执行我们的代码。所以当被挂钩的函数被调用时跳转指令能立刻被执行。我们需要保存每个函数开始被改写的指令,需要它们来调用被挂钩函数的原始代码。保存指令的过程在"挂钩Windows API"的3.2.3节有描述。
首先通过NtOpenProcess打开目标进程并获取句柄。如果我们没有足够权限的话就会失败。
NTSTATUS NtOpenProcess(
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL
);
ProcessHandle是指向保存进程对象句柄的指针。DesiredAccess应该被设置为PROCESS_ALL_ACCESS。我们要在ClientId结构里设置UniqueProcess为目标进程的PID,UniqueThread应该为0。被打开的句柄可以通过NtClose关闭。
#define PROCESS_ALL_ACCESS 0x001F0FFF
现在我们为我们的代码分配部分内存。这通过NtAllocateVirtualMemory来完成。
NTSTATUS NtAllocateVirtualMemory(
IN HANDLE ProcessHandle,
IN OUT PVOID BaseAddress,
IN ULONG ZeroBits,
IN OUT PULONG AllocationSize,
IN ULONG AllocationType,
IN ULONG Protect
);
ProcessHandle是来自NtOpenProcess相同参数。BaseAddress是一个指针,指向被分配虚拟内存基地址的开始处,它的输入参数应该为NULL。AllocationSize指向我们要分配的字节数的变量,同样它也用来接受实际分配的字节数大小。最好把AllocationType在设置成MEM_COMMIT之外再加上MEM_TOP_DOWN因为内存要在接近DLL地址的尽可能高的地址分配。
#define MEM_COMMIT 0x00001000
#define MEM_TOP_DOWN 0x00100000
然后我们就可以通过调用NtWriteVirtualMemory来写入我们的代码。
NTSTATUS NtWriteVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
IN PVOID Buffer,
IN ULONG BufferLength,
OUT PULONG ReturnLength OPTIONAL
);
BaseAddress是NtAllocateVirtualMemory返回的地址。Buffer指向我们要写入的字节,BufferLength是我们要写入的字节数。
现在我们来挂钩单个进程。被加载入所有进程的动态链接库只有ntdll.dll。所以我们要检查被导入进程要挂钩的函数是否来自ntdll.dll。但是这些来自其它DLL的函数所在的内存可能已经被分配,这时重写它的代码会在目标进程里导致错误。这就是我们必须去检查我们要挂钩的函数来自的动态链接库是否被目标进程加载的原因。
我们需要通过NtQueryInformationProcess获取目标进程的PEB(进程环境块)。
NTSTATUS NtQueryInformationProcess(
IN HANDLE ProcessHandle,
IN PROCESSINFOCLASS ProcessInformationClass,
OUT PVOID ProcessInformation,
IN ULONG ProcessInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
我们把ProcessInformationClass设置为ProcessBasicInformation,然后PROCESS_BASIC_INFORMATION结构会返回到ProcessInformation缓冲区中,大小为给定的ProcessInformationLength。
#define ProcessBasicInformation 0
typedef struct _PROCESS_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PPEB PebBaseAddress;
KAFFINITY AffinityMask;
KPRIORITY BasePriority;
ULONG UniqueProcessId;
ULONG InheritedFromUniqueProcessId;
} PROCESS_BASIC_INFORMATION, *PPROCESS_BASIC_INFORMATION;
PebBaseAddress就是我们要寻找的东西。在PebBaseAddress+0C处是PPEB_LDR_DATA的地址。这些通过调用NtReadVirtualMemory来获得。
NTSTATUS NtReadVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
OUT PVOID Buffer,
IN ULONG BufferLength,
OUT PULONG ReturnLength OPTIONAL
);
变量和NtWriteVirtualMemory的很相似。
在PPEB_LDR_DATA+01C处是InInitializationOrderModuleList的地址。它是被加载进进程的动态链接库的列表。我们只对这个结构中的一些部分感兴趣。
typedef struct _IN_INITIALIZATION_ORDER_MODULE_LIST {
PVOID Next,
PVOID Prev,
DWORD ImageBase,
DWORD ImageEntry,
DWORD ImageSize,
...
);
Next是指向下一个记录的指针,Prev指向前一个,最后一个记录的会指向第一个。ImageBase是内存中模块的地址,ImageEntry是模快的入口点,ImageSize是它的大小。
对所有我们想要挂钩的库我们需要获得它们的ImageBase(比方调用GetModuleHandle或者LoadLibrary)。然后把这个ImageBase和InInitializationOrderModuleList的ImageBase比较。
现在我们已经为挂钩准备就绪。因为我们是挂钩正在运行的进程,所以可能我们正在改写代码的同时代码被执行,这时就会导致错误。所以首先我们就得停止目标进程里的所有线程。它的所有线程列表可以通过设置了SystemProcessAndThreadInformation的NtQuerySystemInformation来获得。有关这个函数的描述参考第4节。但是还得加入SYSTEM_THREADS结构的描述,用来保存线程的信息。
typedef struct _SYSTEM_THREADS {
LARGE_INTEGER KernelTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER CreateTime;
ULONG WaitTime;
PVOID StartAddress;
CLIENT_ID ClientId;
KPRIORITY Priority;
KPRIORITY BasePriority;
ULONG ContextSwitchCount;
THREAD_STATE State;
KWAIT_REASON WaitReason;
} SYSTEM_THREADS, *PSYSTEM_THREADS;
对每个线程调用NtOpenThread获取它们的句柄,通过使用ClientId。
NTSTATUS NtOpenThread(
OUT PHANDLE ThreadHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId
);
我们需要的句柄被保存在ThreadHandle。我们需要把DesiredAccess设置为THREAD_SUSPEND_RESUME。
#define THREAD_SUSPEND_RESUME 2
ThreadHandle用来调用NtSuspendThread。
NTSTATUS NtSuspendThread(
IN HANDLE ThreadHandle,
OUT PULONG PreviousSuspendCount OPTIONAL
);
被挂起的进程就可以被改写了。我们按照"挂钩Windows API"里3.2.2节里描述的方法处理。唯一的不同是使用其它进程的函数。
挂钩完后我们就可以调用NtResumeThread恢复所有线程的运行。
NTSTATUS NtResumeThread(
IN HANDLE ThreadHandle,
OUT PULONG PreviousSuspendCount OPTIONAL
);
=====[ 7.3 新进程 ]================================================
感染所有正在运行的进程并不能影响将要被运行的进程。我们可以每隔一定时间获取一次进程的列表,然后感染新的列表里的进程。但这种方法很不可靠。
更好的方法是挂钩新进程开始时肯定会调用的函数。因为所有系统中正在运行的进程都已经被挂钩,所以这种方法不会漏掉任何新的进程。我们可以挂钩NtCreateThread,但这不是最简单的方法。我们可以挂钩NtResumeThread,因为它也是每当新进程创建时被调用,它在NtCreateThread之后被调用。
唯一的问题在于,这个函数并不只在新进程被创建时调用。但我们能很容易解决这点。NtQueryInformationThread能给我们指定线程是属于哪个进程的信息。最后我们要做的就是检查进程是否已经被挂钩了。这通过读取我们要挂钩的函数的开始5个字节来完成。
NTSTATUS NtQueryInformationThread(
IN HANDLE ThreadHandle,
IN THREADINFOCLASS ThreadInformationClass,
OUT PVOID ThreadInformation,
IN ULONG ThreadInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
ThreadInformationClass是信息分类,在这里它被设置为ThreadBasicInformation。ThreadInformation是保存结果的缓冲区,大小按字节计算为ThreadInformationLength。
#define ThreadBasicInformation 0
对ThreadBasicInformation返回这个结构:
typedef struct _THREAD_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PNT_TIB TebBaseAddress;
CLIENT_ID ClientId;
KAFFINITY AffinityMask;
KPRIORITY Priority;
KPRIORITY BasePriority;
} THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
ClientId是线程所属进程的PID。
现在我们来感染新进程。问题就是新进程的地址空间中只有ntdll.dll,其他的模块在调用NtResumeThread之后被加载。有几种方法可以解决这个问题,比方说我们可以挂钩一个名为LdrInitializeThunk的API函数,它在进程初始化时被调用。
NTSTATUS LdrInitializeThunk(
DWORD Unknown1,
DWORD Unknown2,
DWORD Unknown3
);
首先我们先运行原始的代码,然后挂钩新进程里所有要挂钩的函数。但最好对LdrInitializeThunk解除挂钩,因为这个函数在之后要被调用很多次,我们并不需要重新再挂钩所有的函数。这时在程序执行第一个指令前所有工作已经完成。这就是为什么在我们挂钩它之前它没有机会调用任何一个被挂钩过的函数的原因。
对自己挂钩和动态挂钩正在运行的进程一样,只是这里我们不需要关心正在运行的线程。
=====[ 7.4 DLL ]================================================
系统中每个进程都是一份ntdll.dll拷贝。这意味着我们可以在进程初始化阶段挂钩这个模块里的任意一个函数。但是来自其它模块比如kernel32.dll或advapi32.dll的函数该怎么办呢?还有一些进程只有ntdll.dll,其他模块都是在进程被挂钩之后在运行过程中才被动态加载的。这就是我们还得挂钩加载新模块的函数LdrLoadDll的原因。
NTSTATUS LdrLoadDll(
PWSTR szcwPath,
PDWORD pdwLdrErr,
PUNICODE_STRING pUniModuleName,
PHINSTANCE pResultInstance
);
这里对我们来说最重要的是pUniModuleName,它保存模块名字。当调用成功后pResultInstance保存模块地址。
我们首先调用原始的LdrLoadDll然后挂钩被加载模块里所有函数。
=====[ 8. 内存 ]===========================================
当我们正在挂钩一个函数时我们会修改它开始的字节。通过调用NtReadVirtualMemory任何人都可以检测出函数被挂钩。所以我们还要挂钩NtReadVirtualMemory来防止检测。
NTSTATUS NtReadVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
OUT PVOID Buffer,
IN ULONG BufferLength,
OUT PULONG ReturnLength OPTIONAL
);
我们修改了我们挂钩的函数开始的字节并且为我们新的代码分配了内存。我们就需要检查时候有人读取了这些代码。如果我们的代码出现在BaseAddress到BaseAddress+BufferLength中我们就需要在缓冲区中改变它的一些字节。
如果有人在我们分配的内存中查询字节我们就返回空的缓冲区和错误STATUS_PARTIAL_COPY。这个值用来表示被请求的字节并没有完全被拷贝到缓冲区中,它也同样被用在当请求了未分配的内存时。这时ReturnLength应该被设为0。
#define STATUS_PARTIAL_COPY 0x8000000D
如果有人查询被挂钩的函数开始的字节我们就调用原始代码并拷贝原始代码里开始的那些字节到缓冲区中。
现在新进程已无法通过读取它的内存来检测是否被挂钩了。同样如果你调试被挂钩的进程调试器也会用问题,它会显示原始代码,但却执行我们的代码。
为了使隐藏更完美,我们还要挂钩NtQueryVirtualMemory。这个函数用来获取虚拟内存的信息。我们挂钩它来防止探测我们分配的虚逆内存。
NTSTATUS NtQueryVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
IN MEMORY_INFORMATION_CLASS MemoryInformationClass,
OUT PVOID MemoryInformation,
IN ULONG MemoryInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
MemoryInformationClass标明了返回数据的类别。我们对开始的2种类型感兴趣。
#define MemoryBasicInformation 0
#define MemoryWorkingSetList 1
对MemoryBasicInformation返回这个结构:
typedef struct _MEMORY_BASIC_INFORMATION {
PVOID BaseAddress;
PVOID AllocationBase;
ULONG AllocationProtect;
ULONG RegionSize;
ULONG State;
ULONG Protect;
ULONG Type;
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
每个区段都有它的大小RegionSize和它的类型Type。空闲内存的类型是MEM_FREE。(区段对象就是文件映射对象,是可被映射到一个进程的虚逆地址空间的对象)
#define MEM_FREE 0x10000
如果我们代码之前一个区段的类型是MEM_FREE我们就在它的RegionSize加上我们代码的区段的大小。如果我们代码之后的区段的类型也是MEM_FREE那么就在之前区段的RegionSize上再加上之后的空闲区段的大小。
如果我们代码之前的区段是其它类型,我们就对我们代码的区段返回MEM_FREE。它的大小根据之后的区段来计算。
对MemoryWorkingSetList返回这个结构:
typedef struct _MEMORY_WORKING_SET_LIST {
ULONG NumberOfPages;
ULONG WorkingSetList[1];
} MEMORY_WORKING_SET_LIST, *PMEMORY_WORKING_SET_LIST;
NumberOfPages是WorkingSetList中列项的数目。这个数字应该减少一些。我们在WorkingSetList中找到我们代码的区段然后把之后记录前移。WorkingSetList是按DWORD排列的数组,每个元素的高20位标明了区段地址,低12位是标志。
=====[ 9. 句柄 ]=========================================
用类SystemHandleInformation来调用NtQuerySystemInformation会在_SYSTEM_HANDLE_INFORMATION_EX结构中获取所有被打开的句柄的数组。
#define SystemHandleInformation 0x10
typedef struct _SYSTEM_HANDLE_INFORMATION {
ULONG ProcessId;
UCHAR ObjectTypeNumber;
UCHAR Flags;
USHORT Handle;
PVOID Object;
ACCESS_MASK GrantedAccess;
} SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION;
typedef struct _SYSTEM_HANDLE_INFORMATION_EX {
ULONG NumberOfHandles;
SYSTEM_HANDLE_INFORMATION Information[1];
} SYSTEM_HANDLE_INFORMATION_EX, *PSYSTEM_HANDLE_INFORMATION_EX;
ProcessId标明了拥有句柄的进程。ObjectTypeNumber是句柄类型。NumberOfHandles是Information数组中元素的数量。隐藏其中一项是很麻烦的,我们要去掉所有之后的元素并减少NumberOfHandles。去掉之后所有元素是必须的,因为数组中句柄是按ProcessId分组的。这意味着一个来自同一个进程中的所有句柄都在一块儿。对于一个进程变量Handle的数量是不断增加的。
现在回想一下这个函数(NtQuerySystemInformation)使用SystemProcessAndThreadsInformation类来调用时返回的结构_SYSTEM_PROCESSES。这里我们能够看到每个进程都有它自己的句柄的数量在HandleCount中。如果我们想要做得更完美我们就应该修改HandleCount,因为用SystemProcessesAndThreadsInformation类调用这个函数时隐藏了不少句柄。但校正是非常浪费时间的。在系统正常运行的一小段时间里就会有很多句柄正在打开或关上。所以在对这个函数两次紧挨着的调用句柄的数量被更改是很正常的,所以我们根本不需要改变HandleCount。
=====[ 9.1 命名句柄并获取类型 ]===================================
隐藏句柄很麻烦,但找出哪个句柄该被隐藏更困难一些。比方说我们要隐藏一个进程就要隐藏它的所有句柄并隐藏所有和它有联系的句柄。我们比较句柄的ProcessId参数和想要隐藏的进程的PID,如果它们相等就隐藏这个句柄。但是其它进程的句柄在我们能比较任何东西之前不得不先命名。系统中句柄的数量通常很庞大,所以最好在尝试命名之前先比较句柄类型。命名类型可以为我们不感兴趣的句柄省不少时间。
命名句柄和句柄类型通过调用NtQueryObject来完成。
NTSTATUS ZwQueryObject(
IN HANDLE ObjectHandle,
IN OBJECT_INFORMATION_CLASS ObjectInformationClass,
OUT PVOID ObjectInformation,
IN ULONG ObjectInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
ObjectHandle是我们想要获取有关信息的句柄,ObjectInformationClass是信息类型,保存在以字节计算长度为ObjectInformationLength的缓冲区ObjectInformation中。
我们对OBJECT_INFORMATION_CLASS使用的类是ObjectNameInformation和ObjectAllTypesInformation。ObjectNameInfromation类在缓冲区中返回OBJECT_NAME_INFORMATION结构,而ObjectAllTypesInformation类返回OBJECT_ALL_TYPES_INFORMATION结构。
#define ObjectNameInformation 1
#define ObjectAllTypesInformation 3
typedef struct _OBJECT_NAME_INFORMATION {
UNICODE_STRING Name;
} OBJECT_NAME_INFORMATION, *POBJECT_NAME_INFORMATION;
Name决定了句柄的名字。
typedef struct _OBJECT_TYPE_INFORMATION {
UNICODE_STRING Name;
ULONG ObjectCount;
ULONG HandleCount;
ULONG Reserved1[4];
ULONG PeakObjectCount;
ULONG PeakHandleCount;
ULONG Reserved2[4];
ULONG InvalidAttributes;
GENERIC_MAPPING GenericMapping;
ULONG ValidAccess;
UCHAR Unknown;
BOOLEAN MaintainHandleDatabase;
POOL_TYPE PoolType;
ULONG PagedPoolUsage;
ULONG NonPagedPoolUsage;
} OBJECT_TYPE_INFORMATION, *POBJECT_TYPE_INFORMATION;
typedef struct _OBJECT_ALL_TYPES_INFORMATION {
ULONG NumberOfTypes;
OBJECT_TYPE_INFORMATION TypeInformation;
} OBJECT_ALL_TYPES_INFORMATION, *POBJECT_ALL_TYPES_INFORMATION;
Name决定类型对象名字,类型对象紧跟在每个OBJECT_TYPE_INFORMATION结构后面。下一个OBJECT_TYPE_INFORMATION结构跟在这个Name后面,间隔4个字节。
SYSTEM_HANDLE_INFORMATION结构中的ObjectTypeNumber是TypeInformation数组中的索引。
比较困难的是获取其他进程中句柄的名字。这里有两种命名的可能性。一是通过调用NtDuplicateObject把句柄拷贝到我们的进程中然后命名它。这种方法对某些特殊类型的句柄会失败。但由于它失败的次数比较少,所以我们采用这种方法。
NtDuplicateObject(
IN HANDLE SourceProcessHandle,
IN HANDLE SourceHandle,
IN HANDLE TargetProcessHandle,
OUT PHANDLE TargetHandle OPTIONAL,
IN ACCESS_MASK DesiredAccess,
IN ULONG Attributes,
IN ULONG Options
);
SourceHandle是我们想要拷贝的句柄,SourceProcessHandle是拥有SourceHandle的进程的句柄。TargetProcessHandle是想要拷贝到的进程的句柄,在这里是我们进程的句柄。TargetHandle是指向保存原始句柄拷贝的指针。DesiredAccess应该被设为PROCESS_QUERY_INFORMATION,Attributes和Options设为0。
第二种命名方法对所有句柄都有效,就是使用系统驱动。源代码可以在http://rootkit.host.sk的OpHandle项目里找到。
=====[ 10. 端口 ]==========================================
枚举打开端口最简单的方法是调用AllocateAndGetTcpTableFromStack和AllocateAndGetUdpTableFromStack函数,或者AllocateAndGetTcpExTableFromStack和AllocateAndGetUdpExTableFromStack函数,它们都来自iphlpapi.dll。带Ex的函数从Windows XP才开始有效。
typedef struct _MIB_TCPROW {
DWORD dwState;
DWORD dwLocalAddr;
DWORD dwLocalPort;
DWORD dwRemoteAddr;
DWORD dwRemotePort;
} MIB_TCPROW, *PMIB_TCPROW;
typedef struct _MIB_TCPTABLE {
DWORD dwNumEntries;
MIB_TCPROW table[ANY_SIZE];
} MIB_TCPTABLE, *PMIB_TCPTABLE;
typedef struct _MIB_UDPROW {
DWORD dwLocalAddr;
DWORD dwLocalPort;
} MIB_UDPROW, *PMIB_UDPROW;
typedef struct _MIB_UDPTABLE {
DWORD dwNumEntries;
MIB_UDPROW table[ANY_SIZE];
} MIB_UDPTABLE, *PMIB_UDPTABLE;
typedef struct _MIB_TCPROW_EX
{
DWORD dwState;
DWORD dwLocalAddr;
DWORD dwLocalPort;
DWORD dwRemoteAddr;
DWORD dwRemotePort;
DWORD dwProcessId;
} MIB_TCPROW_EX, *PMIB_TCPROW_EX;
typedef struct _MIB_TCPTABLE_EX
{
DWORD dwNumEntries;
MIB_TCPROW_EX table[ANY_SIZE];
} MIB_TCPTABLE_EX, *PMIB_TCPTABLE_EX;
typedef struct _MIB_UDPROW_EX
{
DWORD dwLocalAddr;
DWORD dwLocalPort;
DWORD dwProcessId;
} MIB_UDPROW_EX, *PMIB_UDPROW_EX;
typedef struct _MIB_UDPTABLE_EX
{
DWORD dwNumEntries;
MIB_UDPROW_EX table[ANY_SIZE];
} MIB_UDPTABLE_EX, *PMIB_UDPTABLE_EX;
DWORD WINAPI AllocateAndGetTcpTableFromStack(
OUT PMIB_TCPTABLE *pTcpTable,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
DWORD WINAPI AllocateAndGetUdpTableFromStack(
OUT PMIB_UDPTABLE *pUdpTable,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
DWORD WINAPI AllocateAndGetTcpExTableFromStack(
OUT PMIB_TCPTABLE_EX *pTcpTableEx,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
DWORD WINAPI AllocateAndGetUdpExTableFromStack(
OUT PMIB_UDPTABLE_EX *pUdpTableEx,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
还有另外一种方法。当程序创建了一个套接字并开始监听时,它就会有一个为它和打开端口的打开句柄。我们在系统中枚举所有的打开句柄并通过NtDeviceIoControlFile把它们发送到一个特定的缓冲区中,来找出这个句柄是否是一个打开端口的。这样也能给我们有关端口的信息。因为打开句柄太多了,所以我们只检测类型是File并且名字是/Device/Tcp或/Device/Udp的。打开端口只有这种类型和名字。
如果你看一下iphlpapi.dll里函数的代码,就会发现这些函数同样调用NtDeviceIoControlFile并发送到一个特定缓冲区来获得系统中所有打开端口的列表。这意味着我们要想隐藏端口只需要挂钩NtDeviceIoControlFile函数。
NTSTATUS NtDeviceIoControlFile(
IN HANDLE FileHandle
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN ULONG IoControlCode,
IN PVOID InputBuffer OPTIONAL,
IN ULONG InputBufferLength,
OUT PVOID OutputBuffer OPTIONAL,
IN ULONG OutputBufferLength
);
我们感兴趣的成员变量有这几个:FileHandle标明了要通信的设备的句柄,IoStatusBlock指向接收最后完成状态和请求操作信息的变量,IoControlCode是指定要完成的特定的I/O控制操作的数字,InputBuffer包含了输入的数据,长度为按字节计算的InputBufferLength,相似的还有OutputBuffer和OutputBufferLength。
=====[ 10.1 WinXP下使用Netstat OpPorts FPort ]=========================
在Windoes XP获得所有打开端口的列表可以使用一些软件比方OpPorts、FPort和Netstat。
这里程序用IoControlCode0x000120003调用了NtDeviceIoControlFile两次。输出缓冲区在第二次调用时被填满。FileHandle的名字这里总是/Device/Tcp。InputBuffer因不同类型的调用而不同:
1) 为获得MIB_TCPROW数组InputBuffer看起来是这样:
第一次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
2) 为获得MIB_UDPROW数组:
第一次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
3) 为获得MIB_TCPROW_EX数组:
第一次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x02 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
4) 为获得MIB_UDPROW_EX数组:
第一次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x02 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
你可以看到缓冲区只有少数字节不同。我们现在比较清晰地简要说明一下:
我们感兴趣的调用是InputBuffer[1]为0x04且InputBuffer[17]为0x01。只有使用这些输入数据才能使OutputBuffer里为我们想要的表。如果我们想要获得TCP端口信息我们就把InputBuffer[0]设为0x00,想获得UDP端口信息就把它设为0x01。如果我们还需要额外的输出表(MIB_TCPROW_EX或MIB_UDPROW_EX)我们就在第二次调用里把InfputBufer[16]设为0x02。
如果我们发现使用了这几个参数的调用我们就修改输出缓冲区。获取输出缓冲区中ROW的数量可以很容易根据ROW的大小分开IoStatusBlock结构里的Infotmation变量。隐藏其中一个ROW就会变的容易,只需要用之后的ROW改写他并删掉最后一个ROW。不要忘了修改OutputBufferLength和IoStatusBlock。
=====[ 10.2 Win2k和NT4下使用OpPorts, Win2k下使用FPort ]==========================
我们用IoControlCode0x00210012调用NtDeviceIoControlFile来判断这个拥有类型File和名字/Device/Tcp或/Device/Udp是否是打开端口的句柄。
所以最先我们比较IoControlCode然后是类型和句柄名字。如果这些都符合就接着比较输入缓冲区长度,它应该和结构TDI_CONNECTION_IN长度一样,为0x18。输出缓冲区的结构是TDI_CONNECTION_OUT。
typedef struct _TDI_CONNETION_IN
{
ULONG UserDataLength,
PVOID UserData,
ULONG OptionsLength,
PVOID Options,
ULONG RemoteAddressLength,
PVOID RemoteAddress
} TDI_CONNETION_IN, *PTDI_CONNETION_IN;
typedef struct _TDI_CONNETION_OUT
{
ULONG State,
ULONG Event,
ULONG TransmittedTsdus,
ULONG ReceivedTsdus,
ULONG TransmissionErrors,
ULONG ReceiveErrors,
LARGE_INTEGER Throughput
LARGE_INTEGER Delay,
ULONG SendBufferSize,
ULONG ReceiveBufferSize,
ULONG Unreliable,
ULONG Unknown1[5],
USHORT Unknown2
} TDI_CONNETION_OUT, *PTDI_CONNETION_OUT;
具体判断句柄是不是一个打开端口的方法请参考OpPorts的源代码,在http://rookit.host.sk上可以找到。我们现在来隐藏指定端口。我们已经比较过了InputBufferLength和IoControlCode,现在来比较RemoteAddressLength,对打开端口来说它总是3或4。最后要做的是比较OutputBufferBuffer里的ReceiveTsdus,用网络上的端口和要隐藏的端口列表比较。区别TCP和UDP的做法是句柄的名字不一样。在删除了OutputBuffer、修改IoStatusBlock并返回STATUS_INVALID_ADDRESS后我们就已经隐藏了这个端口了。
=====[ 11. 结束语 ]===============================================
具体细节请参考Hacker defender rootkit version 1.0.0的源代码,在http://rootkit.host.sk和http://www.rootkit.com都可以找到。
在将来我还会加入更多有关的技术。这篇文档的更新版本会改进现有的方法和并加入新的思想。
特别感谢Ratter提供了很多完成这篇文档和Hacker defender代码所需要的技术。
===================================[ End ]==============================
后记:
其实只要我们对Windows的内核有一定程度的了解我们都知道单纯靠挂钩函数是不能真正做到隐藏的,这样做只不过是欺骗操作系统的使用者,却欺骗不了操作系统自己。线程要想被运行就必须获得时间片,将自己加入调度链表中,从而暴露自己。内核维护一组被称为调度程序数据库的数据结构来做出线程调度的决策。其中最重要的结构是调度程序就绪队列(KiDispatckerReadyListHead)。它里面有64个DWORD,分别对应于32个线程优先级的队列,队列包含处于就绪状态的线程,正在等待调度执行。还有两个队列KiWaitInListHead和KiWaitOutListHead保存着处于等待状态的线程。可以很简单的枚举这3个链表中的所有元素从而列出系统中的所有线程。因此要想彻底从Windows系统里“消失”就要从Windows的内核下手(Windows的内核只负责线程调度,其它功能由执行程序组件完成)。这个功能还有待完成。
水平有限,欢迎大家指出错漏之处。QQ:27324838 Email:kinvis@hotmail.com
文章属性:原创
文章提交:SoBeIt (kinsephi_at_hotmail.com)
===================[ 在NT系列操作系统里让自己“消失”]==================
SoBeIt
作者:Holy_Father <holy_father@phreaker.net>
版本:1.2 english
日期:05.08.2003
=====[ 1. 内容 ]============================================
1. 内容
2. 介绍
3. 文件
3.1 NtQueryDirectoryFile
3.2 NtVdmControl
4. 进程
5. 注册表
5.1 NtEnumerateKey
5.2 NtEnumerateValueKey
6. 系统服务和驱动
7. 挂钩和扩展
7.1 权限
7.2 全局挂钩
7.3 新进程
7.4 DLL
8. 内存
9. 句柄
9.1 命名句柄并获得类型
10. 端口
10.1 Netstat, OpPorts和FPortWinXP下
10.2 OpPorts在Win2k和NT4下, FPort在Win2k下
11. 结束
=====[ 2. 介绍 ]==================================================
这篇文档是在Windows NT操作系统下隐藏对象、文件、服务、进程等的技术。这种方法是基于Windows API函数的挂钩。
这篇文章中所描述的技术都是从我写rootkit的研究成果,所以它能写rootkit更有效果并且更简单。这里也同样包括了我的实践。
在这篇文档中隐藏对象意味着改变某些用来命名这些对象的系统函数,使它们将忽略这些对象的名字。这样一来我们改动的那些函数的返回值表示这些对象根本就不存在。
最基本的方法(除去少数不同的)是我们用原始的参数调用原始的函数,然后我们改变它们的输出。
在这篇文章里将描述隐藏文件、进程、注册表键和键值、系统服务和驱动、分配的内存还有句柄。
=====[ 3. 文件 ]========================================
在有很多种隐藏文件使系统无法发现的可能。我们只使用改变API的方法,而没使用那些比如涉及到文件系统的技术。这样会更容易些因为我们无法知道文件系统工作的独特性。
=====[ 3.1 NtQueryDirectoryFile ]=============================
在WINNT里在某些目录中寻找某个文件的方法是枚举它里面所有的文件和它的子目录下的所有文件。文件的枚举是使用NtQueryDirectoryFile函数。
NTSTATUS NtQueryDirectoryFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
OUT PVOID FileInformation,
IN ULONG FileInformationLength,
IN FILE_INFORMATION_CLASS FileInformationClass,
IN BOOLEAN ReturnSingleEntry,
IN PUNICODE_STRING FileName OPTIONAL,
IN BOOLEAN RestartScan
);
对我们来说重要的参数是FileHandle,FileInformation和FileInformationClass。FileHandle是从NtOpenFile获得的目录对象句柄。FileInformation是一个指针,指向函数要写入需要的数据的已分配内存。FileInformationClass决定写入FileImformation的记录的类型。
FileInformationClass是一个变化的枚举类型,我们只需要其中4个值来枚举目录内容:
#define FileDirectoryInformation 1
#define FileFullDirectoryInformation 2
#define FileBothDirectoryInformation 3
#define FileNamesInformation 12
要写入FileInformation的FileDirecoryInformation记录的结构:
typedef struct _FILE_DIRECTORY_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
LARGE_INTEGER CreationTime;
LARGE_INTEGER LastAccessTime;
LARGE_INTEGER LastWriteTime;
LARGE_INTEGER ChangeTime;
LARGE_INTEGER EndOfFile;
LARGE_INTEGER AllocationSize;
ULONG FileAttributes;
ULONG FileNameLength;
WCHAR FileName[1];
} FILE_DIRECTORY_INFORMATION, *PFILE_DIRECTORY_INFORMATION;
FileFullDirectoryInformation:
typedef struct _FILE_FULL_DIRECTORY_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
LARGE_INTEGER CreationTime;
LARGE_INTEGER LastAccessTime;
LARGE_INTEGER LastWriteTime;
LARGE_INTEGER ChangeTime;
LARGE_INTEGER EndOfFile;
LARGE_INTEGER AllocationSize;
ULONG FileAttributes;
ULONG FileNameLength;
ULONG EaInformationLength;
WCHAR FileName[1];
} FILE_FULL_DIRECTORY_INFORMATION, *PFILE_FULL_DIRECTORY_INFORMATION;
FileBothDirectoryInformation:
typedef struct _FILE_BOTH_DIRECTORY_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
LARGE_INTEGER CreationTime;
LARGE_INTEGER LastAccessTime;
LARGE_INTEGER LastWriteTime;
LARGE_INTEGER ChangeTime;
LARGE_INTEGER EndOfFile;
LARGE_INTEGER AllocationSize;
ULONG FileAttributes;
ULONG FileNameLength;
ULONG EaInformationLength;
UCHAR AlternateNameLength;
WCHAR AlternateName[12];
WCHAR FileName[1];
} FILE_BOTH_DIRECTORY_INFORMATION, *PFILE_BOTH_DIRECTORY_INFORMATION;
FileNamesInformation:
typedef struct _FILE_NAMES_INFORMATION {
ULONG NextEntryOffset;
ULONG Unknown;
ULONG FileNameLength;
WCHAR FileName[1];
} FILE_NAMES_INFORMATION, *PFILE_NAMES_INFORMATION;
这个函数在FileInformation中写入这些结构的一个列表。对我们来说在这些结构类型中只有3个变量是重要的。
NextEntryOffset是这个列表中项的偏移地址。第一个项在地址FileInformation+0处,所以第二个项在地址是FileInformation+第一个项的NextEntryOffset。最后一个项的NextEntryOffset是0。
FileName是文件全名。
FileNameLength是文件名长度。
如果我们想要隐藏一个文件,我们需要分别通知这4种类型,对每种类型的返回记录我们需要和我们打算隐藏的文件比较名字。如果我们打算隐藏第一个记录,我们可以把后面的结构向前移动,移动长度为第一个结构的长度,这样会导致第一个记录被改写。如果我们想要隐藏其它任何一个,只需要很容易的改变上一个记录的NextEntryOffset的值就行。如果我们要隐藏最后一个记录就把它的NextEntryOffset改为0,否则NextEntryOffset的值应为我们想要隐藏的那个记录和前一个的NextEntryOffset值的和。然后修改前一个记录的Unknown变量的值,它是下一次搜索的索引。把要隐藏的记录之前一个记录的Unknown变量的值改为我们要隐藏的那个记录的Unkown变量的值即可。
如果没有原本应该可见的记录被找到,我们就返回STATUS_NO_SUCH_FILE。
#define STATUS_NO_SUCH_FILE 0xC000000F
=====[ 3.2 NtVdmControl ]========================================
不知什么原因DOS的枚举NTVDM能够通过函数NtVdmControl也能获得文件的列表。
NTSTATUS NtVdmControl(
IN ULONG ControlCode,
IN PVOID ControlData
);
ConcrolCode标明了在缓冲区ControlData中申请数据的子函数。如果ControlCode为VdmDiretoryFile那么这个函数的功能将和FileInformation设置为FileBothDirectoryInformation的函数NtQueryDirectoryFile功能一样。
#define VdmDirectoryFile 6
这时的ControlData的用法就和FileInformation一样。这里唯一的不同就是我们不知道缓冲区的长度。所以我们需要手动来计算它的长度。我们把所有记录的NextEntryOffset和最后一个记录的FileNameLength还有0X5E(最后一个记录除去文件名的长度)。隐藏的方法和前面提到的使用NtQueryDirectoryFile的方法一样。
=====[ 4. 进程 ]========================================
各种进程信息是通过NtQuerySystemInformation获取的。
NTSTATUS NtQuerySystemInformation(
IN SYSTEM_INFORMATION_CLASS SystemInformationClass,
IN OUT PVOID SystemInformation,
IN ULONG SystemInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
SystemInformationClass标明了我们想要获得的信息的类别,SystemInformation是一个指向函数输出缓冲区的指针,SystemInformationLength是这个缓冲区的长度,ReturnLength是写入字节的数目。
对于正在运行的进程的枚举我们使用设置为SystemProcessesAndThreadsInformation的SystemInformationClass。
#define SystemInformationClass 5
在SystemInformation的缓冲区中返回的数据结构是:
typedef struct _SYSTEM_PROCESSES {
ULONG NextEntryDelta;
ULONG ThreadCount;
ULONG Reserved1[6];
LARGE_INTEGER CreateTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER KernelTime;
UNICODE_STRING ProcessName;
KPRIORITY BasePriority;
ULONG ProcessId;
ULONG InheritedFromProcessId;
ULONG HandleCount;
ULONG Reserved2[2];
VM_COUNTERS VmCounters;
IO_COUNTERS IoCounters; // Windows 2000特有的
SYSTEM_THREADS Threads[1];
} SYSTEM_PROCESSES, *PSYSTEM_PROCESSES;
隐藏进程和隐藏文件方法基本一样,就是改动我们需要隐藏的记录的前一个记录的NextEntryDelta。通常我们不用隐藏第一个记录,因为它是空闲进程(Idle process)。
=====[ 5. 注册表 ]========================================
Windows的注册表是一个很大的树形数据结构,对我们来说里面有两种重要的记录类型需要隐藏。一种类型是注册表键,另一种是键值。因为注册表的结构,隐藏注册表键不象隐藏文件或进程那么麻烦。
=====[ 5.1 NtEnumerateKey ]===============================
因为注册表的结构我们不能请求某个指定部分所有键的列表。我们只能在注册表某个部分通过查询指定键的索引以获得它的信息。这里提供了NtEnumerateKey。
NTSTATUS NtEnumerateKey(
IN HANDLE KeyHandle,
IN ULONG Index,
IN KEY_INFORMATION_CLASS KeyInformationClass,
OUT PVOID KeyInformation,
IN ULONG KeyInformationLength,
OUT PULONG ResultLength
);
KeyHandle是已经用索引标明我们想要从中获取信息的子键的句柄。KeyInformationClass标明了返回信息类型。数据最后写入KeyInformaiton缓冲区,缓冲区长度为KeyInformationLength。写入的字节数由ResultLength返回。
我们需要意识到的最重要的东西是如果我们隐藏了某个键,在这个键之后的所有键的索引都会改变。因为我们是通过高位的索引来获取键的信息,并通过低位的索引来请求这个键。所以我们必须记录之前有多少个记录被隐藏,然后返回正确的值。
让我们来看个例子。假设我们在注册表中有一些键名字是A,B,C,D,E和F。它们的索引从0开始,也就是说索引4对应键E。现在我们如果想要隐藏键B,被挂钩过的应用程序用索引4调用NtEnumerateKey时我们应该返回F键的信息因为有一个索引改变了。现在问题是我们不知道是否会有索引被改变。如果我们不注意索引的改变而对于索引4的请求仍然返回键E而不是键F的话,很有可能在我们用索引1请求时什么都返回不了或者返回键C。这两种情况都会导致错误。这就是为什么我们要注意索引的改变。
现在如果我们通过用索引0到Index重新调用函数来记录转移我们可能会等待一段时间(在1GHz处理器上普通的注册表就得等10秒种那么长的时间)。所以我们不得不想出一种更加巧妙的方法。
我们知道键是按字母排序的(除了引用外)。如果我们忽略引用(我们不需要隐藏)我们能使用以下方法记录改变。我们通过字母排序列出我们想要隐藏的键名的列表(使用RtlCompareUnicodeString),然后当应用程序调用NtEnumerateKey时我们不需要用不可变的变量重新调用它,而能够找到用索引标明的记录的名字。
NTSTATUS RtlCompareUnicodeString(
IN PUNICODE_STRING String1,
IN PUNICODE_STRING String2,
IN BOOLEAN CaseInSensitive
);
String1和String2是将要比较的字符串,CaseInSensitive在不忽略大小写时被设置为True。
函数结果描述String1和String2的关系:
result > 0: String1 > String2
result = 0: String1 = String2
result < 0: String1 < String2
现在我们需要找到一个边缘项。我们在列表中对用索引标明的键按字母比较名字。边缘项是在我们列表中最后一个较短的名字。我们知道转移最多是我们列表中边缘项的数量。但并不是所有我们列表中的项都是注册表中有效的键。所以我们不得不请求我们列表中达到边缘项的所有的在注册表中这个部分的项。这些通过调用NtOpenKey来完成。
NTSTATUS NtOpenKey(
OUT PHANDLE KeyHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes
);
KeyHandle是高位的键的句柄,我们使用NtEnumerateKey的这个值。DesaireAccess是访问权力。KEY_ENUMERATE_SUB_KEYS是它的正确的值。ObjectAttributes描述了我们要打开的子键(包括了它的名字)。
#define KEY_ENUMERATE_SUB_KEYS 8
如果NtOpenKey返回0表示打开成功,意味着这个来自我们列表中的键是存在的。被打开的键通过NtClose来关闭。
NTSTATUS NtClose(
IN HANDLE Handle
);
对每次NtEnumareteKey的调用我们要计算的改变,数量上等同于我们列表中存在于注册表指定部分的键的数量。然后我们把改变的数量加到变量Index,最后调用原始的NtEnumerateKey。
我们使用KeyInformationClass的KeyBasicInformation来获得用索引标明的键的名字。
#define KeyBasicInformation 0
NtEnumerateKey在KeyInformation缓冲区中返回这个结构:
typedef struct _KEY_BASIC_INFORMATION {
LARGE_INTEGER LastWriteTime;
ULONG TitleIndex;
ULONG NameLength;
WCHAR Name[1];
} KEY_BASIC_INFORMATION, *PKEY_BASIC_INFORMATION;
这里我们只需要的东西是Name和它的长度NameLength。
如果没有被转移的索引的记载我们就返回错误STATUS_EA_LIST_INCONSISTENT。
#define STATUS_EA_LIST_INCONSISTENT 0x80000014
=====[ 5.2 NtEnumerateValueKey ]============================
注册表键值不是按字母分类的。幸运的是在一个键里键值的数目比较少,所以我们可以通过重调的方法来获得改变的数目。用来获取一个键值信息的API是NtEnumerateValueKey。
NTSTATUS NtEnumerateValueKey(
IN HANDLE KeyHandle,
IN ULONG Index,
IN KEY_VALUE_INFORMATION_CLASS KeyValueInformationClass,
OUT PVOID KeyValueInformation,
IN ULONG KeyValueInformationLength,
OUT PULONG ResultLength
);
KeyHandle也是等级高的键的句柄。Index是所给键中键值的索引。KeyValueInformationClass描述信息的类型,保存在KeyValueInformation缓冲区中,缓冲区以字节为大小为KeyValueInformationLength。写入字节的数量返回在ResultLength中。
我们通过用0到Index的所有索引重调函数计算转移。键值的名字通过把KeyValueInformationClass设置为KeyValueBasicInformation来获取。
#define KeyValueBasicInformation 0
然后我们获取在KeyValueInformation缓冲区中接下来的数据结构:
typedef struct _KEY_VALUE_BASIC_INFORMATION {
ULONG TitleIndex;
ULONG Type;
ULONG NameLength;
WCHAR Name[1];
} KEY_VALUE_BASIC_INFORMATION, *PKEY_VALUE_BASIC_INFORMATION;
这里我们只对Name和NameLength感兴趣。
如果这里没有被转移的索引记载我们就返回错误STATUS_NO_MORE_ENTRIES。
#define STATUS_NO_MORE_ENTRIES 0x8000001A
=====[ 6. 系统服务和驱动 ]====================================
系统服务和驱动是通过4个独立的API函数枚举的。它们在每个Windows版本中的联系都不一样。所以我们必须挂钩所有4个函数。
BOOL EnumServicesStatusA(
SC_HANDLE hSCManager,
DWORD dwServiceType,
DWORD dwServiceState,
LPENUM_SERVICE_STATUS lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle
);
BOOL EnumServiceGroupW(
SC_HANDLE hSCManager,
DWORD dwServiceType,
DWORD dwServiceState,
LPBYTE lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle,
DWORD dwUnknown
);
BOOL EnumServicesStatusExA(
SC_HANDLE hSCManager,
SC_ENUM_TYPE InfoLevel,
DWORD dwServiceType,
DWORD dwServiceState,
LPBYTE lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle,
LPCTSTR pszGroupName
);
BOOL EnumServicesStatusExW(
SC_HANDLE hSCManager,
SC_ENUM_TYPE InfoLevel,
DWORD dwServiceType,
DWORD dwServiceState,
LPBYTE lpServices,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded,
LPDWORD lpServicesReturned,
LPDWORD lpResumeHandle,
LPCTSTR pszGroupName
);
这里最重要的是lpService,它指向保存服务列表的缓冲区。而指向结果中记录个数的lpServicesReturned也很重要。输出缓冲区中的数据结构取决于函数类型。函数EnumServicesStatusA和
EnumServicesGroupW返回这个结构:
typedef struct _ENUM_SERVICE_STATUS {
LPTSTR lpServiceName;
LPTSTR lpDisplayName;
SERVICE_STATUS ServiceStatus;
} ENUM_SERVICE_STATUS, *LPENUM_SERVICE_STATUS;
typedef struct _SERVICE_STATUS {
DWORD dwServiceType;
DWORD dwCurrentState;
DWORD dwControlsAccepted;
DWORD dwWin32ExitCode;
DWORD dwServiceSpecificExitCode;
DWORD dwCheckPoint;
DWORD dwWaitHint;
} SERVICE_STATUS, *LPSERVICE_STATUS;
函数EnumServicesStatusExA和EnumServicesStatusExW返回这个:
typedef struct _ENUM_SERVICE_STATUS_PROCESS {
LPTSTR lpServiceName;
LPTSTR lpDisplayName;
SERVICE_STATUS_PROCESS ServiceStatusProcess;
} ENUM_SERVICE_STATUS_PROCESS, *LPENUM_SERVICE_STATUS_PROCESS;
typedef struct _SERVICE_STATUS_PROCESS {
DWORD dwServiceType;
DWORD dwCurrentState;
DWORD dwControlsAccepted;
DWORD dwWin32ExitCode;
DWORD dwServiceSpecificExitCode;
DWORD dwCheckPoint;
DWORD dwWaitHint;
DWORD dwProcessId;
DWORD dwServiceFlags;
} SERVICE_STATUS_PROCESS, *LPSERVICE_STATUS_PROCESS;
我们只对lpServiceName感兴趣因为它是系统服务的名字。所有记录都有静态的大小,所以我们想要隐藏一个的话就需要将之后所有记录向前移它的大小。这里我们必须区分SERVICE_STATUS和SERVICE_STATUS_PROCESS的大小。
=====[ 7. 动态挂钩和扩展 ]=====================================
为达到预想的效果我们需要挂钩所有正在运行的进程和所有将要被创建的进程。所有新进程都必须在它们运行第一条指令前被挂钩,否则它们就能够在被挂够前看到被隐藏的对象。
=====[ 7.1 权限 ]=============================================
首先我们得知道我们至少获得管理员administrator权限来获得进入所有正在运行的进程。最好的可能是将我们的进程当做系统服务来运行,因为它运行与SYSTEM用户权限下。为安装服务我们首先得获取特殊的权限。
获取SeDebugPrivilege的权限是很有用的,通过调用OpenProcessToken、LookupPrivilegeValue
和AdjustTokenPrivileges来完成。
BOOL OpenProcessToken(
HANDLE ProcessHandle,
DWORD DesiredAccess,
PHANDLE TokenHandle
);
BOOL LookupPrivilegeValue(
LPCTSTR lpSystemName,
LPCTSTR lpName,
PLUID lpLuid
);
BOOL AdjustTokenPrivileges(
HANDLE TokenHandle,
BOOL DisableAllPrivileges,
PTOKEN_PRIVILEGES NewState,
DWORD BufferLength,
PTOKEN_PRIVILEGES PreviousState,
PDWORD ReturnLength
);
代码如下:
#define SE_PRIVILEGE_ENABLED 0x0002
#define TOKEN_QUERY 0x0008
#define TOKEN_ADJUST_PRIVILEGES 0x0020
HANDLE hToken;
LUID DebugNameValue;
TOKEN_PRIVILEGES Privileges;
DWORD dwRet;
OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,hToken);
LookupPrivilegeValue(NULL,"SeDebugPrivilege",&DebugNameValue);
Privileges.PrivilegeCount=1;
Privileges.Privileges[0].Luid=DebugNameValue;
Privileges.Privileges[0].Attributes=SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken,FALSE,&Privileges,sizeof(Privileges),
NULL,&dwRet);
CloseHandle(hToken);
=====[ 7.2 全局挂钩 ]=======================================
枚举进程通过前面提到的API函数NtQuerySystemInformation来完成。因为系统中还有一些内部native进程,所以使用重写函数第一个指令的方法来挂钩。对每个正在运行的进程我们需要做的都一样。首先在目标进程里分配一部分内存用来写入我们用来挂钩函数的新代码,然后把每个函数开始的5个字节改为跳转指令(jmp),这个跳转会转为执行我们的代码。所以当被挂钩的函数被调用时跳转指令能立刻被执行。我们需要保存每个函数开始被改写的指令,需要它们来调用被挂钩函数的原始代码。保存指令的过程在"挂钩Windows API"的3.2.3节有描述。
首先通过NtOpenProcess打开目标进程并获取句柄。如果我们没有足够权限的话就会失败。
NTSTATUS NtOpenProcess(
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL
);
ProcessHandle是指向保存进程对象句柄的指针。DesiredAccess应该被设置为PROCESS_ALL_ACCESS。我们要在ClientId结构里设置UniqueProcess为目标进程的PID,UniqueThread应该为0。被打开的句柄可以通过NtClose关闭。
#define PROCESS_ALL_ACCESS 0x001F0FFF
现在我们为我们的代码分配部分内存。这通过NtAllocateVirtualMemory来完成。
NTSTATUS NtAllocateVirtualMemory(
IN HANDLE ProcessHandle,
IN OUT PVOID BaseAddress,
IN ULONG ZeroBits,
IN OUT PULONG AllocationSize,
IN ULONG AllocationType,
IN ULONG Protect
);
ProcessHandle是来自NtOpenProcess相同参数。BaseAddress是一个指针,指向被分配虚拟内存基地址的开始处,它的输入参数应该为NULL。AllocationSize指向我们要分配的字节数的变量,同样它也用来接受实际分配的字节数大小。最好把AllocationType在设置成MEM_COMMIT之外再加上MEM_TOP_DOWN因为内存要在接近DLL地址的尽可能高的地址分配。
#define MEM_COMMIT 0x00001000
#define MEM_TOP_DOWN 0x00100000
然后我们就可以通过调用NtWriteVirtualMemory来写入我们的代码。
NTSTATUS NtWriteVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
IN PVOID Buffer,
IN ULONG BufferLength,
OUT PULONG ReturnLength OPTIONAL
);
BaseAddress是NtAllocateVirtualMemory返回的地址。Buffer指向我们要写入的字节,BufferLength是我们要写入的字节数。
现在我们来挂钩单个进程。被加载入所有进程的动态链接库只有ntdll.dll。所以我们要检查被导入进程要挂钩的函数是否来自ntdll.dll。但是这些来自其它DLL的函数所在的内存可能已经被分配,这时重写它的代码会在目标进程里导致错误。这就是我们必须去检查我们要挂钩的函数来自的动态链接库是否被目标进程加载的原因。
我们需要通过NtQueryInformationProcess获取目标进程的PEB(进程环境块)。
NTSTATUS NtQueryInformationProcess(
IN HANDLE ProcessHandle,
IN PROCESSINFOCLASS ProcessInformationClass,
OUT PVOID ProcessInformation,
IN ULONG ProcessInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
我们把ProcessInformationClass设置为ProcessBasicInformation,然后PROCESS_BASIC_INFORMATION结构会返回到ProcessInformation缓冲区中,大小为给定的ProcessInformationLength。
#define ProcessBasicInformation 0
typedef struct _PROCESS_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PPEB PebBaseAddress;
KAFFINITY AffinityMask;
KPRIORITY BasePriority;
ULONG UniqueProcessId;
ULONG InheritedFromUniqueProcessId;
} PROCESS_BASIC_INFORMATION, *PPROCESS_BASIC_INFORMATION;
PebBaseAddress就是我们要寻找的东西。在PebBaseAddress+0C处是PPEB_LDR_DATA的地址。这些通过调用NtReadVirtualMemory来获得。
NTSTATUS NtReadVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
OUT PVOID Buffer,
IN ULONG BufferLength,
OUT PULONG ReturnLength OPTIONAL
);
变量和NtWriteVirtualMemory的很相似。
在PPEB_LDR_DATA+01C处是InInitializationOrderModuleList的地址。它是被加载进进程的动态链接库的列表。我们只对这个结构中的一些部分感兴趣。
typedef struct _IN_INITIALIZATION_ORDER_MODULE_LIST {
PVOID Next,
PVOID Prev,
DWORD ImageBase,
DWORD ImageEntry,
DWORD ImageSize,
...
);
Next是指向下一个记录的指针,Prev指向前一个,最后一个记录的会指向第一个。ImageBase是内存中模块的地址,ImageEntry是模快的入口点,ImageSize是它的大小。
对所有我们想要挂钩的库我们需要获得它们的ImageBase(比方调用GetModuleHandle或者LoadLibrary)。然后把这个ImageBase和InInitializationOrderModuleList的ImageBase比较。
现在我们已经为挂钩准备就绪。因为我们是挂钩正在运行的进程,所以可能我们正在改写代码的同时代码被执行,这时就会导致错误。所以首先我们就得停止目标进程里的所有线程。它的所有线程列表可以通过设置了SystemProcessAndThreadInformation的NtQuerySystemInformation来获得。有关这个函数的描述参考第4节。但是还得加入SYSTEM_THREADS结构的描述,用来保存线程的信息。
typedef struct _SYSTEM_THREADS {
LARGE_INTEGER KernelTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER CreateTime;
ULONG WaitTime;
PVOID StartAddress;
CLIENT_ID ClientId;
KPRIORITY Priority;
KPRIORITY BasePriority;
ULONG ContextSwitchCount;
THREAD_STATE State;
KWAIT_REASON WaitReason;
} SYSTEM_THREADS, *PSYSTEM_THREADS;
对每个线程调用NtOpenThread获取它们的句柄,通过使用ClientId。
NTSTATUS NtOpenThread(
OUT PHANDLE ThreadHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId
);
我们需要的句柄被保存在ThreadHandle。我们需要把DesiredAccess设置为THREAD_SUSPEND_RESUME。
#define THREAD_SUSPEND_RESUME 2
ThreadHandle用来调用NtSuspendThread。
NTSTATUS NtSuspendThread(
IN HANDLE ThreadHandle,
OUT PULONG PreviousSuspendCount OPTIONAL
);
被挂起的进程就可以被改写了。我们按照"挂钩Windows API"里3.2.2节里描述的方法处理。唯一的不同是使用其它进程的函数。
挂钩完后我们就可以调用NtResumeThread恢复所有线程的运行。
NTSTATUS NtResumeThread(
IN HANDLE ThreadHandle,
OUT PULONG PreviousSuspendCount OPTIONAL
);
=====[ 7.3 新进程 ]================================================
感染所有正在运行的进程并不能影响将要被运行的进程。我们可以每隔一定时间获取一次进程的列表,然后感染新的列表里的进程。但这种方法很不可靠。
更好的方法是挂钩新进程开始时肯定会调用的函数。因为所有系统中正在运行的进程都已经被挂钩,所以这种方法不会漏掉任何新的进程。我们可以挂钩NtCreateThread,但这不是最简单的方法。我们可以挂钩NtResumeThread,因为它也是每当新进程创建时被调用,它在NtCreateThread之后被调用。
唯一的问题在于,这个函数并不只在新进程被创建时调用。但我们能很容易解决这点。NtQueryInformationThread能给我们指定线程是属于哪个进程的信息。最后我们要做的就是检查进程是否已经被挂钩了。这通过读取我们要挂钩的函数的开始5个字节来完成。
NTSTATUS NtQueryInformationThread(
IN HANDLE ThreadHandle,
IN THREADINFOCLASS ThreadInformationClass,
OUT PVOID ThreadInformation,
IN ULONG ThreadInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
ThreadInformationClass是信息分类,在这里它被设置为ThreadBasicInformation。ThreadInformation是保存结果的缓冲区,大小按字节计算为ThreadInformationLength。
#define ThreadBasicInformation 0
对ThreadBasicInformation返回这个结构:
typedef struct _THREAD_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PNT_TIB TebBaseAddress;
CLIENT_ID ClientId;
KAFFINITY AffinityMask;
KPRIORITY Priority;
KPRIORITY BasePriority;
} THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
ClientId是线程所属进程的PID。
现在我们来感染新进程。问题就是新进程的地址空间中只有ntdll.dll,其他的模块在调用NtResumeThread之后被加载。有几种方法可以解决这个问题,比方说我们可以挂钩一个名为LdrInitializeThunk的API函数,它在进程初始化时被调用。
NTSTATUS LdrInitializeThunk(
DWORD Unknown1,
DWORD Unknown2,
DWORD Unknown3
);
首先我们先运行原始的代码,然后挂钩新进程里所有要挂钩的函数。但最好对LdrInitializeThunk解除挂钩,因为这个函数在之后要被调用很多次,我们并不需要重新再挂钩所有的函数。这时在程序执行第一个指令前所有工作已经完成。这就是为什么在我们挂钩它之前它没有机会调用任何一个被挂钩过的函数的原因。
对自己挂钩和动态挂钩正在运行的进程一样,只是这里我们不需要关心正在运行的线程。
=====[ 7.4 DLL ]================================================
系统中每个进程都是一份ntdll.dll拷贝。这意味着我们可以在进程初始化阶段挂钩这个模块里的任意一个函数。但是来自其它模块比如kernel32.dll或advapi32.dll的函数该怎么办呢?还有一些进程只有ntdll.dll,其他模块都是在进程被挂钩之后在运行过程中才被动态加载的。这就是我们还得挂钩加载新模块的函数LdrLoadDll的原因。
NTSTATUS LdrLoadDll(
PWSTR szcwPath,
PDWORD pdwLdrErr,
PUNICODE_STRING pUniModuleName,
PHINSTANCE pResultInstance
);
这里对我们来说最重要的是pUniModuleName,它保存模块名字。当调用成功后pResultInstance保存模块地址。
我们首先调用原始的LdrLoadDll然后挂钩被加载模块里所有函数。
=====[ 8. 内存 ]===========================================
当我们正在挂钩一个函数时我们会修改它开始的字节。通过调用NtReadVirtualMemory任何人都可以检测出函数被挂钩。所以我们还要挂钩NtReadVirtualMemory来防止检测。
NTSTATUS NtReadVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
OUT PVOID Buffer,
IN ULONG BufferLength,
OUT PULONG ReturnLength OPTIONAL
);
我们修改了我们挂钩的函数开始的字节并且为我们新的代码分配了内存。我们就需要检查时候有人读取了这些代码。如果我们的代码出现在BaseAddress到BaseAddress+BufferLength中我们就需要在缓冲区中改变它的一些字节。
如果有人在我们分配的内存中查询字节我们就返回空的缓冲区和错误STATUS_PARTIAL_COPY。这个值用来表示被请求的字节并没有完全被拷贝到缓冲区中,它也同样被用在当请求了未分配的内存时。这时ReturnLength应该被设为0。
#define STATUS_PARTIAL_COPY 0x8000000D
如果有人查询被挂钩的函数开始的字节我们就调用原始代码并拷贝原始代码里开始的那些字节到缓冲区中。
现在新进程已无法通过读取它的内存来检测是否被挂钩了。同样如果你调试被挂钩的进程调试器也会用问题,它会显示原始代码,但却执行我们的代码。
为了使隐藏更完美,我们还要挂钩NtQueryVirtualMemory。这个函数用来获取虚拟内存的信息。我们挂钩它来防止探测我们分配的虚逆内存。
NTSTATUS NtQueryVirtualMemory(
IN HANDLE ProcessHandle,
IN PVOID BaseAddress,
IN MEMORY_INFORMATION_CLASS MemoryInformationClass,
OUT PVOID MemoryInformation,
IN ULONG MemoryInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
MemoryInformationClass标明了返回数据的类别。我们对开始的2种类型感兴趣。
#define MemoryBasicInformation 0
#define MemoryWorkingSetList 1
对MemoryBasicInformation返回这个结构:
typedef struct _MEMORY_BASIC_INFORMATION {
PVOID BaseAddress;
PVOID AllocationBase;
ULONG AllocationProtect;
ULONG RegionSize;
ULONG State;
ULONG Protect;
ULONG Type;
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
每个区段都有它的大小RegionSize和它的类型Type。空闲内存的类型是MEM_FREE。(区段对象就是文件映射对象,是可被映射到一个进程的虚逆地址空间的对象)
#define MEM_FREE 0x10000
如果我们代码之前一个区段的类型是MEM_FREE我们就在它的RegionSize加上我们代码的区段的大小。如果我们代码之后的区段的类型也是MEM_FREE那么就在之前区段的RegionSize上再加上之后的空闲区段的大小。
如果我们代码之前的区段是其它类型,我们就对我们代码的区段返回MEM_FREE。它的大小根据之后的区段来计算。
对MemoryWorkingSetList返回这个结构:
typedef struct _MEMORY_WORKING_SET_LIST {
ULONG NumberOfPages;
ULONG WorkingSetList[1];
} MEMORY_WORKING_SET_LIST, *PMEMORY_WORKING_SET_LIST;
NumberOfPages是WorkingSetList中列项的数目。这个数字应该减少一些。我们在WorkingSetList中找到我们代码的区段然后把之后记录前移。WorkingSetList是按DWORD排列的数组,每个元素的高20位标明了区段地址,低12位是标志。
=====[ 9. 句柄 ]=========================================
用类SystemHandleInformation来调用NtQuerySystemInformation会在_SYSTEM_HANDLE_INFORMATION_EX结构中获取所有被打开的句柄的数组。
#define SystemHandleInformation 0x10
typedef struct _SYSTEM_HANDLE_INFORMATION {
ULONG ProcessId;
UCHAR ObjectTypeNumber;
UCHAR Flags;
USHORT Handle;
PVOID Object;
ACCESS_MASK GrantedAccess;
} SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION;
typedef struct _SYSTEM_HANDLE_INFORMATION_EX {
ULONG NumberOfHandles;
SYSTEM_HANDLE_INFORMATION Information[1];
} SYSTEM_HANDLE_INFORMATION_EX, *PSYSTEM_HANDLE_INFORMATION_EX;
ProcessId标明了拥有句柄的进程。ObjectTypeNumber是句柄类型。NumberOfHandles是Information数组中元素的数量。隐藏其中一项是很麻烦的,我们要去掉所有之后的元素并减少NumberOfHandles。去掉之后所有元素是必须的,因为数组中句柄是按ProcessId分组的。这意味着一个来自同一个进程中的所有句柄都在一块儿。对于一个进程变量Handle的数量是不断增加的。
现在回想一下这个函数(NtQuerySystemInformation)使用SystemProcessAndThreadsInformation类来调用时返回的结构_SYSTEM_PROCESSES。这里我们能够看到每个进程都有它自己的句柄的数量在HandleCount中。如果我们想要做得更完美我们就应该修改HandleCount,因为用SystemProcessesAndThreadsInformation类调用这个函数时隐藏了不少句柄。但校正是非常浪费时间的。在系统正常运行的一小段时间里就会有很多句柄正在打开或关上。所以在对这个函数两次紧挨着的调用句柄的数量被更改是很正常的,所以我们根本不需要改变HandleCount。
=====[ 9.1 命名句柄并获取类型 ]===================================
隐藏句柄很麻烦,但找出哪个句柄该被隐藏更困难一些。比方说我们要隐藏一个进程就要隐藏它的所有句柄并隐藏所有和它有联系的句柄。我们比较句柄的ProcessId参数和想要隐藏的进程的PID,如果它们相等就隐藏这个句柄。但是其它进程的句柄在我们能比较任何东西之前不得不先命名。系统中句柄的数量通常很庞大,所以最好在尝试命名之前先比较句柄类型。命名类型可以为我们不感兴趣的句柄省不少时间。
命名句柄和句柄类型通过调用NtQueryObject来完成。
NTSTATUS ZwQueryObject(
IN HANDLE ObjectHandle,
IN OBJECT_INFORMATION_CLASS ObjectInformationClass,
OUT PVOID ObjectInformation,
IN ULONG ObjectInformationLength,
OUT PULONG ReturnLength OPTIONAL
);
ObjectHandle是我们想要获取有关信息的句柄,ObjectInformationClass是信息类型,保存在以字节计算长度为ObjectInformationLength的缓冲区ObjectInformation中。
我们对OBJECT_INFORMATION_CLASS使用的类是ObjectNameInformation和ObjectAllTypesInformation。ObjectNameInfromation类在缓冲区中返回OBJECT_NAME_INFORMATION结构,而ObjectAllTypesInformation类返回OBJECT_ALL_TYPES_INFORMATION结构。
#define ObjectNameInformation 1
#define ObjectAllTypesInformation 3
typedef struct _OBJECT_NAME_INFORMATION {
UNICODE_STRING Name;
} OBJECT_NAME_INFORMATION, *POBJECT_NAME_INFORMATION;
Name决定了句柄的名字。
typedef struct _OBJECT_TYPE_INFORMATION {
UNICODE_STRING Name;
ULONG ObjectCount;
ULONG HandleCount;
ULONG Reserved1[4];
ULONG PeakObjectCount;
ULONG PeakHandleCount;
ULONG Reserved2[4];
ULONG InvalidAttributes;
GENERIC_MAPPING GenericMapping;
ULONG ValidAccess;
UCHAR Unknown;
BOOLEAN MaintainHandleDatabase;
POOL_TYPE PoolType;
ULONG PagedPoolUsage;
ULONG NonPagedPoolUsage;
} OBJECT_TYPE_INFORMATION, *POBJECT_TYPE_INFORMATION;
typedef struct _OBJECT_ALL_TYPES_INFORMATION {
ULONG NumberOfTypes;
OBJECT_TYPE_INFORMATION TypeInformation;
} OBJECT_ALL_TYPES_INFORMATION, *POBJECT_ALL_TYPES_INFORMATION;
Name决定类型对象名字,类型对象紧跟在每个OBJECT_TYPE_INFORMATION结构后面。下一个OBJECT_TYPE_INFORMATION结构跟在这个Name后面,间隔4个字节。
SYSTEM_HANDLE_INFORMATION结构中的ObjectTypeNumber是TypeInformation数组中的索引。
比较困难的是获取其他进程中句柄的名字。这里有两种命名的可能性。一是通过调用NtDuplicateObject把句柄拷贝到我们的进程中然后命名它。这种方法对某些特殊类型的句柄会失败。但由于它失败的次数比较少,所以我们采用这种方法。
NtDuplicateObject(
IN HANDLE SourceProcessHandle,
IN HANDLE SourceHandle,
IN HANDLE TargetProcessHandle,
OUT PHANDLE TargetHandle OPTIONAL,
IN ACCESS_MASK DesiredAccess,
IN ULONG Attributes,
IN ULONG Options
);
SourceHandle是我们想要拷贝的句柄,SourceProcessHandle是拥有SourceHandle的进程的句柄。TargetProcessHandle是想要拷贝到的进程的句柄,在这里是我们进程的句柄。TargetHandle是指向保存原始句柄拷贝的指针。DesiredAccess应该被设为PROCESS_QUERY_INFORMATION,Attributes和Options设为0。
第二种命名方法对所有句柄都有效,就是使用系统驱动。源代码可以在http://rootkit.host.sk的OpHandle项目里找到。
=====[ 10. 端口 ]==========================================
枚举打开端口最简单的方法是调用AllocateAndGetTcpTableFromStack和AllocateAndGetUdpTableFromStack函数,或者AllocateAndGetTcpExTableFromStack和AllocateAndGetUdpExTableFromStack函数,它们都来自iphlpapi.dll。带Ex的函数从Windows XP才开始有效。
typedef struct _MIB_TCPROW {
DWORD dwState;
DWORD dwLocalAddr;
DWORD dwLocalPort;
DWORD dwRemoteAddr;
DWORD dwRemotePort;
} MIB_TCPROW, *PMIB_TCPROW;
typedef struct _MIB_TCPTABLE {
DWORD dwNumEntries;
MIB_TCPROW table[ANY_SIZE];
} MIB_TCPTABLE, *PMIB_TCPTABLE;
typedef struct _MIB_UDPROW {
DWORD dwLocalAddr;
DWORD dwLocalPort;
} MIB_UDPROW, *PMIB_UDPROW;
typedef struct _MIB_UDPTABLE {
DWORD dwNumEntries;
MIB_UDPROW table[ANY_SIZE];
} MIB_UDPTABLE, *PMIB_UDPTABLE;
typedef struct _MIB_TCPROW_EX
{
DWORD dwState;
DWORD dwLocalAddr;
DWORD dwLocalPort;
DWORD dwRemoteAddr;
DWORD dwRemotePort;
DWORD dwProcessId;
} MIB_TCPROW_EX, *PMIB_TCPROW_EX;
typedef struct _MIB_TCPTABLE_EX
{
DWORD dwNumEntries;
MIB_TCPROW_EX table[ANY_SIZE];
} MIB_TCPTABLE_EX, *PMIB_TCPTABLE_EX;
typedef struct _MIB_UDPROW_EX
{
DWORD dwLocalAddr;
DWORD dwLocalPort;
DWORD dwProcessId;
} MIB_UDPROW_EX, *PMIB_UDPROW_EX;
typedef struct _MIB_UDPTABLE_EX
{
DWORD dwNumEntries;
MIB_UDPROW_EX table[ANY_SIZE];
} MIB_UDPTABLE_EX, *PMIB_UDPTABLE_EX;
DWORD WINAPI AllocateAndGetTcpTableFromStack(
OUT PMIB_TCPTABLE *pTcpTable,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
DWORD WINAPI AllocateAndGetUdpTableFromStack(
OUT PMIB_UDPTABLE *pUdpTable,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
DWORD WINAPI AllocateAndGetTcpExTableFromStack(
OUT PMIB_TCPTABLE_EX *pTcpTableEx,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
DWORD WINAPI AllocateAndGetUdpExTableFromStack(
OUT PMIB_UDPTABLE_EX *pUdpTableEx,
IN BOOL bOrder,
IN HANDLE hAllocHeap,
IN DWORD dwAllocFlags,
IN DWORD dwProtocolVersion;
);
还有另外一种方法。当程序创建了一个套接字并开始监听时,它就会有一个为它和打开端口的打开句柄。我们在系统中枚举所有的打开句柄并通过NtDeviceIoControlFile把它们发送到一个特定的缓冲区中,来找出这个句柄是否是一个打开端口的。这样也能给我们有关端口的信息。因为打开句柄太多了,所以我们只检测类型是File并且名字是/Device/Tcp或/Device/Udp的。打开端口只有这种类型和名字。
如果你看一下iphlpapi.dll里函数的代码,就会发现这些函数同样调用NtDeviceIoControlFile并发送到一个特定缓冲区来获得系统中所有打开端口的列表。这意味着我们要想隐藏端口只需要挂钩NtDeviceIoControlFile函数。
NTSTATUS NtDeviceIoControlFile(
IN HANDLE FileHandle
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN ULONG IoControlCode,
IN PVOID InputBuffer OPTIONAL,
IN ULONG InputBufferLength,
OUT PVOID OutputBuffer OPTIONAL,
IN ULONG OutputBufferLength
);
我们感兴趣的成员变量有这几个:FileHandle标明了要通信的设备的句柄,IoStatusBlock指向接收最后完成状态和请求操作信息的变量,IoControlCode是指定要完成的特定的I/O控制操作的数字,InputBuffer包含了输入的数据,长度为按字节计算的InputBufferLength,相似的还有OutputBuffer和OutputBufferLength。
=====[ 10.1 WinXP下使用Netstat OpPorts FPort ]=========================
在Windoes XP获得所有打开端口的列表可以使用一些软件比方OpPorts、FPort和Netstat。
这里程序用IoControlCode0x000120003调用了NtDeviceIoControlFile两次。输出缓冲区在第二次调用时被填满。FileHandle的名字这里总是/Device/Tcp。InputBuffer因不同类型的调用而不同:
1) 为获得MIB_TCPROW数组InputBuffer看起来是这样:
第一次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
2) 为获得MIB_UDPROW数组:
第一次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
3) 为获得MIB_TCPROW_EX数组:
第一次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x02 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
4) 为获得MIB_UDPROW_EX数组:
第一次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
第二次调用:
0x01 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x01 0x00 0x00
0x02 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00
你可以看到缓冲区只有少数字节不同。我们现在比较清晰地简要说明一下:
我们感兴趣的调用是InputBuffer[1]为0x04且InputBuffer[17]为0x01。只有使用这些输入数据才能使OutputBuffer里为我们想要的表。如果我们想要获得TCP端口信息我们就把InputBuffer[0]设为0x00,想获得UDP端口信息就把它设为0x01。如果我们还需要额外的输出表(MIB_TCPROW_EX或MIB_UDPROW_EX)我们就在第二次调用里把InfputBufer[16]设为0x02。
如果我们发现使用了这几个参数的调用我们就修改输出缓冲区。获取输出缓冲区中ROW的数量可以很容易根据ROW的大小分开IoStatusBlock结构里的Infotmation变量。隐藏其中一个ROW就会变的容易,只需要用之后的ROW改写他并删掉最后一个ROW。不要忘了修改OutputBufferLength和IoStatusBlock。
=====[ 10.2 Win2k和NT4下使用OpPorts, Win2k下使用FPort ]==========================
我们用IoControlCode0x00210012调用NtDeviceIoControlFile来判断这个拥有类型File和名字/Device/Tcp或/Device/Udp是否是打开端口的句柄。
所以最先我们比较IoControlCode然后是类型和句柄名字。如果这些都符合就接着比较输入缓冲区长度,它应该和结构TDI_CONNECTION_IN长度一样,为0x18。输出缓冲区的结构是TDI_CONNECTION_OUT。
typedef struct _TDI_CONNETION_IN
{
ULONG UserDataLength,
PVOID UserData,
ULONG OptionsLength,
PVOID Options,
ULONG RemoteAddressLength,
PVOID RemoteAddress
} TDI_CONNETION_IN, *PTDI_CONNETION_IN;
typedef struct _TDI_CONNETION_OUT
{
ULONG State,
ULONG Event,
ULONG TransmittedTsdus,
ULONG ReceivedTsdus,
ULONG TransmissionErrors,
ULONG ReceiveErrors,
LARGE_INTEGER Throughput
LARGE_INTEGER Delay,
ULONG SendBufferSize,
ULONG ReceiveBufferSize,
ULONG Unreliable,
ULONG Unknown1[5],
USHORT Unknown2
} TDI_CONNETION_OUT, *PTDI_CONNETION_OUT;
具体判断句柄是不是一个打开端口的方法请参考OpPorts的源代码,在http://rookit.host.sk上可以找到。我们现在来隐藏指定端口。我们已经比较过了InputBufferLength和IoControlCode,现在来比较RemoteAddressLength,对打开端口来说它总是3或4。最后要做的是比较OutputBufferBuffer里的ReceiveTsdus,用网络上的端口和要隐藏的端口列表比较。区别TCP和UDP的做法是句柄的名字不一样。在删除了OutputBuffer、修改IoStatusBlock并返回STATUS_INVALID_ADDRESS后我们就已经隐藏了这个端口了。
=====[ 11. 结束语 ]===============================================
具体细节请参考Hacker defender rootkit version 1.0.0的源代码,在http://rootkit.host.sk和http://www.rootkit.com都可以找到。
在将来我还会加入更多有关的技术。这篇文档的更新版本会改进现有的方法和并加入新的思想。
特别感谢Ratter提供了很多完成这篇文档和Hacker defender代码所需要的技术。
===================================[ End ]==============================
后记:
其实只要我们对Windows的内核有一定程度的了解我们都知道单纯靠挂钩函数是不能真正做到隐藏的,这样做只不过是欺骗操作系统的使用者,却欺骗不了操作系统自己。线程要想被运行就必须获得时间片,将自己加入调度链表中,从而暴露自己。内核维护一组被称为调度程序数据库的数据结构来做出线程调度的决策。其中最重要的结构是调度程序就绪队列(KiDispatckerReadyListHead)。它里面有64个DWORD,分别对应于32个线程优先级的队列,队列包含处于就绪状态的线程,正在等待调度执行。还有两个队列KiWaitInListHead和KiWaitOutListHead保存着处于等待状态的线程。可以很简单的枚举这3个链表中的所有元素从而列出系统中的所有线程。因此要想彻底从Windows系统里“消失”就要从Windows的内核下手(Windows的内核只负责线程调度,其它功能由执行程序组件完成)。这个功能还有待完成。
水平有限,欢迎大家指出错漏之处。QQ:27324838 Email:kinvis@hotmail.com