隐藏进程检测
User Mode下的隐藏进程检测
我们先来看一些简单的方法,这些方法可以用在ring3下,用不着驱动。检测的原理是每一个进程活动时都会暴露一些痕迹,可以通过这些痕迹检测到它们。这些痕迹包括打开的句柄、窗口和创建的系统对象。针对类似的检测方法来实现进程隐藏并不困难,但需要考虑进程工作时可能暴露出的所有迹象。目前还没有一个公开的rootkit做到了这一点(遗憾的是私有版本我也还没见到)。用户模式下的方法实现简单、使用安全,而且效果良好,所以不应轻视它们的作用。
首先先来定义由搜索函数所返回的数据的格式,我们使用一个链表:
type
PProcList = ^TProcList;
TProcList = packed record
NextItem: pointer;
ProcName: array [0..MAX_PATH] of Char;
ProcId: dword;
ParrentId: dword;
end;
使用ToolHelp API获取进程列表
我们先来定义获取进程列表的函数,我们将使用此函数的结果来与用其它方法取得的进程列表做比较:
{
使用ToolHelp API获取进程列表.
}
procedure GetToolHelpProcessList(var List: PListStruct);
var
Snap: dword;
Process: TPROCESSENTRY32;
NewItem: PProcessRecord;
begin
Snap := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if Snap <> INVALID_HANDLE_VALUE then
begin
Process.dwSize := SizeOf(TPROCESSENTRY32);
if Process32First(Snap, Process) then
repeat
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
NewItem^.ProcessId := Process.th32ProcessID;
NewItem^.ParrentPID := Process.th32ParentProcessID;
lstrcpy(@NewItem^.ProcessName, Process.szExeFile);
AddItem(List, NewItem);
until not Process32Next(Snap, Process);
CloseHandle(Snap);
end;
end;
很显然,在这个列表中是不会找到隐藏进程的,所以可以用这个函数来区分隐藏进程和非隐藏进程。
使用Native API获取进程列表
下一级的检测是用ZwQuerySystemInformation (Native API)来获取进程列表。在这一级也未必能找到什么,但毕竟检查一下还是值得的。
{
使用ZwQuerySystemInformation获取进程列表.
}
procedure GetNativeProcessList(var List: PListStruct);
var
Info: PSYSTEM_PROCESSES;
NewItem: PProcessRecord;
Mem: pointer;
begin
Info := GetInfoTable(SystemProcessesAndThreadsInformation);
Mem := Info;
if Info = nil then Exit;
repeat
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
lstrcpy(@NewItem^.ProcessName,
PChar(WideCharToString(Info^.ProcessName.Buffer)));
NewItem^.ProcessId := Info^.ProcessId;
NewItem^.ParrentPID := Info^.InheritedFromProcessId;
AddItem(List, NewItem);
Info := pointer(dword(info) + info^.NextEntryDelta);
until Info^.NextEntryDelta = 0;
VirtualFree(Mem, 0, MEM_RELEASE);
end;
通过打开句柄的句柄表来获取进程列表
许多进程隐藏程序,并不隐藏打开的句柄,所以我们可以使用ZwQuerySystemInformation来建立进程列表。
{
通过打开句柄的句柄表来获取进程列表.
只返回ProcessId.
}
procedure GetHandlesProcessList(var List: PListStruct);
var
Info: PSYSTEM_HANDLE_INFORMATION_EX;
NewItem: PProcessRecord;
r: dword;
OldPid: dword;
begin
OldPid := 0;
Info := GetInfoTable(SystemHandleInformation);
if Info = nil then Exit;
for r := 0 to Info^.NumberOfHandles do
if Info^.Information[r].ProcessId <> OldPid then
begin
OldPid := Info^.Information[r].ProcessId;
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
NewItem^.ProcessId := OldPid;
AddItem(List, NewItem);
end;
VirtualFree(Info, 0, MEM_RELEASE);
end;
到这一层已经可以检测到某些东西了,但是其结果依然不能完全信赖,因为隐藏进程打开的句柄一点也不比隐藏进程难,只不过是很多人忘了这一手而已。
通过进程打开的窗口获取进程列表
获取系统中注册的窗口并对每一个窗口调用GetWindowThreadProcessId可以建立拥有窗口的进程的列表。
{
通过进程打开的窗口获取进程列表.
只返回ProcessId.
}
procedure GetWindowsProcessList(var List: PListStruct);
function EnumWindowsProc(hwnd: dword; PList: PPListStruct): bool; stdcall;
var
ProcId: dword;
NewItem: PProcessRecord;
begin
GetWindowThreadProcessId(hwnd, ProcId);
if not IsPidAdded(PList^, ProcId) then
begin
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
NewItem^.ProcessId := ProcId;
AddItem(PList^, NewItem);
end;
Result := true;
end;
begin
EnumWindows(@EnumWindowsProc, dword(@List));
end;
几乎没有人会去隐藏窗口,所以用这种方法也可以找到点什么,但是也不能完全信赖。
直接使用系统调用获取进程列表
在User Mode下隐藏进程的常用办法是将自己的代码注入到其它的进程并截获ntdll.dll中的ZwQuerySystemInformation。ntdll函数实际上是相应内核系统函数的入口,并通过系统调用接口跳转(Windows 2000下的Int 2Eh或是XP下的sysenter),所以检测隐藏进程的最简单有效的办法就是直接使用系统调用接口。
ZwQuerySystemInformation的替代函数在Windows XP下形式如下:
{
Windows XP的系统调用ZwQuerySystemInformation.
}
Function XpZwQuerySystemInfoCall(ASystemInformationClass: dword;
ASystemInformation: Pointer;
ASystemInformationLength: dword;
AReturnLength: pdword): dword; stdcall;
asm
pop ebp
mov eax, $AD
call @SystemCall
ret $10
@SystemCall:
mov edx, esp
sysenter
end;
Windows 2000使用另一种系统调用接口,函数代码是另一种样子。
{
Windows 2000的系统调用ZwQuerySystemInformation.
}
Function Win2kZwQuerySystemInfoCall(ASystemInformationClass: dword;
ASystemInformation: Pointer;
ASystemInformationLength: dword;
AReturnLength: pdword): dword; stdcall;
asm
pop ebp
mov eax, $97
lea edx, [esp + $04]
int $2E
ret $10
end;
现在就不用再用ntdll.dll中的函数枚举进程了,只用我们定义的函数就行了。这里是其代码:
{
使用系统调用ZwQuerySystemInformation获取进程列表.
}
procedure GetSyscallProcessList(var List: PListStruct);
var
Info: PSYSTEM_PROCESSES;
NewItem: PProcessRecord;
mPtr: pointer;
mSize: dword;
St: NTStatus;
begin
mSize := $4000;
repeat
GetMem(mPtr, mSize);
St := ZwQuerySystemInfoCall(SystemProcessesAndThreadsInformation,
mPtr, mSize, nil);
if St = STATUS_INFO_LENGTH_MISMATCH then
begin
FreeMem(mPtr);
mSize := mSize * 2;
end;
until St <> STATUS_INFO_LENGTH_MISMATCH;
if St = STATUS_SUCCESS then
begin
Info := mPtr;
repeat
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
lstrcpy(@NewItem^.ProcessName,
PChar(WideCharToString(Info^.ProcessName.Buffer)));
NewItem^.ProcessId := Info^.ProcessId;
NewItem^.ParrentPID := Info^.InheritedFromProcessId;
Info := pointer(dword(info) + info^.NextEntryDelta);
AddItem(List, NewItem);
until Info^.NextEntryDelta = 0;
end;
FreeMem(mPtr);
end;
这种方法100%能检测到用户模式下的rootkit,比如所有的版本hxdef都能检测到。
分析进程相关句柄获取进程列表
我们还有一种办法,就是枚举句柄。。这些句柄可能是进程自己的,也有可能是其线程的。在取得进程句柄后,可以用ZwQueryInformationProcess确定其PID。对于线程,可以调用ZwQueryInformationThread并得到其进程的ID。系统中所有的进程都是由某个进程启动的,所以它们的父进程就会有它们的句柄(如果没有关闭的话),而且所有运行中进程的句柄在Win32子系统服务器(csrss.exe)中也有。再有,在Windows NT中有许多Job对象,这些Job对象将一些进程(比如某一用户拥有的所有进程或是某一服务拥有的所有进程)结合在一起,所以在找Job对象的句柄时,我们还需要取得Job中所有进程的ID,这一点不能忽视。取得这些进程的ID可以通过使用信息类JobObjectBasicProcessIdList调用QueryInformationJobObject来完成。全部代码形式如下:
{
检查其它进程中的句柄来获取进程列表
}
procedure GetProcessesFromHandles(var List: PListStruct; Processes, Jobs, Threads: boolean);
var
HandlesInfo: PSYSTEM_HANDLE_INFORMATION_EX;
ProcessInfo: PROCESS_BASIC_INFORMATION;
hProcess : dword;
tHandle: dword;
r, l : integer;
NewItem: PProcessRecord;
Info: PJOBOBJECT_BASIC_PROCESS_ID_LIST;
Size: dword;
THRInfo: THREAD_BASIC_INFORMATION;
begin
HandlesInfo := GetInfoTable(SystemHandleInformation);
if HandlesInfo <> nil then
for r := 0 to HandlesInfo^.NumberOfHandles do
if HandlesInfo^.Information[r].ObjectTypeNumber in [OB_TYPE_PROCESS, OB_TYPE_JOB, OB_TYPE_THREAD] then
begin
hProcess := OpenProcess(PROCESS_DUP_HANDLE, false,
HandlesInfo^.Information[r].ProcessId);
if DuplicateHandle(hProcess, HandlesInfo^.Information[r].Handle,
INVALID_HANDLE_VALUE, @tHandle, 0, false,
DUPLICATE_SAME_ACCESS) then
begin
case HandlesInfo^.Information[r].ObjectTypeNumber of
OB_TYPE_PROCESS : begin
if Processes and (HandlesInfo^.Information[r].ProcessId = CsrPid) then
if ZwQueryInformationProcess(tHandle, ProcessBasicInformation,
@ProcessInfo,
SizeOf(PROCESS_BASIC_INFORMATION),
nil) = STATUS_SUCCESS then
if not IsPidAdded(List, ProcessInfo.UniqueProcessId) then
begin
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
NewItem^.ProcessId := ProcessInfo.UniqueProcessId;
NewItem^.ParrentPID := ProcessInfo.InheritedFromUniqueProcessId;
AddItem(List, NewItem);
end;
end;
OB_TYPE_JOB : begin
if Jobs then
begin
Size := SizeOf(JOBOBJECT_BASIC_PROCESS_ID_LIST) + 4 * 1000;
GetMem(Info, Size);
Info^.NumberOfAssignedProcesses := 1000;
if QueryInformationJobObject(tHandle, JobObjectBasicProcessIdList,
Info, Size, nil) then
for l := 0 to Info^.NumberOfProcessIdsInList - 1 do
if not IsPidAdded(List, Info^.ProcessIdList[l]) then
begin
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
NewItem^.ProcessId := Info^.ProcessIdList[l];
AddItem(List, NewItem);
end;
FreeMem(Info);
end;
end;
OB_TYPE_THREAD : begin
if Threads then
if ZwQueryInformationThread(tHandle, THREAD_BASIC_INFO,
@THRInfo,
SizeOf(THREAD_BASIC_INFORMATION),
nil) = STATUS_SUCCESS then
if not IsPidAdded(List, THRInfo.ClientId.UniqueProcess) then
begin
GetMem(NewItem, SizeOf(TProcessRecord));
ZeroMemory(NewItem, SizeOf(TProcessRecord));
NewItem^.ProcessId := THRInfo.ClientId.UniqueProcess;
AddItem(List, NewItem);
end;
end;
end;
CloseHandle(tHandle);
end;
CloseHandle(hProcess);
end;
VirtualFree(HandlesInfo, 0, MEM_RELEASE);
end;
遗憾的是,前面的某些方法只能确定ProcessId,而不能确定进程的名字。所以,我们需要通过pid来取得进程名。对此显然不能用ToolHelp API来做,因为进程可能是隐藏的,所以我们要打开进程的内存并从其PEB中读取其名字。PEB在进程中的地址可以用ZwQueryInformationProcess函数来确定。下面是代码:
function GetNameByPid(Pid: dword): string;
var
hProcess, Bytes: dword;
Info: PROCESS_BASIC_INFORMATION;
ProcessParametres: pointer;
ImagePath: TUnicodeString;
ImgPath: array[0..MAX_PATH] of WideChar;
begin
Result := '';
ZeroMemory(@ImgPath, MAX_PATH * SizeOf(WideChar));
hProcess := OpenProcess(PROCESS_QUERY_INFORMATION or PROCESS_VM_READ, false, Pid);
if ZwQueryInformationProcess(hProcess, ProcessBasicInformation, @Info,
SizeOf(PROCESS_BASIC_INFORMATION), nil) = STATUS_SUCCESS then
begin
if ReadProcessMemory(hProcess, pointer(dword(Info.PebBaseAddress) + $10),
@ProcessParametres, SizeOf(pointer), Bytes) and
ReadProcessMemory(hProcess, pointer(dword(ProcessParametres) + $38),
@ImagePath, SizeOf(TUnicodeString), Bytes) and
ReadProcessMemory(hProcess, ImagePath.Buffer, @ImgPath,
ImagePath.Length, Bytes) then
begin
Result := ExtractFileName(WideCharToString(ImgPath));
end;
end;
CloseHandle(hProcess);
end;
自然,用户模式下的检测方法这里并未完全囊括。如果再动动脑子的话还可以想出其它一些新办法,但我们这里的方法目前已经够用了。它们的优点是编程容易,但是只能检测到在User Mode下拦截API以实现隐藏的进程或者是Kernel Mode下没隐藏好的进程。要想真正可靠的检测到隐藏进程,我们就需要编写驱动程序并与Windows内核数据结构打交道了。
Kernel Mode detection
现在我们来讨论在内核模式下检测隐藏进程。与用户模式下方法不同的是,我们现在不用通过API来获取进程列表,而是直接和内核数据结构打交道。想在这些手段下实现隐藏就更为困难了,因为要实现隐藏就需要从内核数据结构中清除进程所有的痕迹,这几乎是不可能的。
从内部来看进程到底是什么?每一个进程都有自己的地址空间,自己的句柄,线程等等。这些都与相应的内核数据结构密切相关。每一个进程都是由EPROCESS结构体来描述的,所有进程的结构体链接成一个环形的双向链表。隐藏进程的一种办法就是修改链表中的指针,使在枚举进程时能绕过要隐藏的进程。不管进程是否被枚举出来,EPROCESS总是应该存在的,对于进程的运行这个结构体是必须的。大多数Kernel Mode下的检测隐藏进程的方法都与检测这个结构体有关。
我们首先来定义所获取进程信息的保存格式。我们所用的格式应该能够方便的从驱动程序传递到应用程序。其结构如下:
typedef struct _ProcessRecord
{
ULONG Visibles;
ULONG SignalState;
BOOLEAN Present;
ULONG ProcessId;
ULONG ParrentPID;
PEPROCESS pEPROCESS;
CHAR ProcessName[256];
} TProcessRecord, *PProcessRecord;
这些结构体在内存中按顺序排列,最后一个结构体设置Present标志。
在内核中使用ZwQuerySystemInformation获得进程列表
我们先从简单的开始,使用ZwQuerySystemInformation获取进程列表:
PVOID GetNativeProcessList(ULONG *MemSize)
{
ULONG PsCount = 0;
PVOID Info = GetInfoTable(SystemProcessesAndThreadsInformation);
PSYSTEM_PROCESSES Proc;
PVOID Mem = NULL;
PProcessRecord Data;
if (!Info) return NULL; else Proc = Info;
do
{
Proc = (PSYSTEM_PROCESSES)((ULONG)Proc + Proc->NextEntryDelta);
PsCount++;
} while (Proc->NextEntryDelta);
*MemSize = (PsCount + 1) * sizeof(TProcessRecord);
Mem = ExAllocatePool(PagedPool, *MemSize);
if (!Mem) return NULL; else Data = Mem;
Proc = Info;
do
{
Proc = (PSYSTEM_PROCESSES)((ULONG)Proc + Proc->NextEntryDelta);
wcstombs(Data->ProcessName, Proc->ProcessName.Buffer, 255);
Data->Present = TRUE;
Data->ProcessId = Proc->ProcessId;
Data->ParrentPID = Proc->InheritedFromProcessId;
PsLookupProcessByProcessId((HANDLE)Proc->ProcessId, &Data->pEPROCESS);
ObDereferenceObject(Data->pEPROCESS);
Data++;
} while (Proc->NextEntryDelta);
Data->Present = FALSE;
ExFreePool(Info);
return Mem;
}
这个函数只是个样品,因为它检测不到任何使用Kernel Mode方法隐藏的进程。但是像hxdef这类的用户模式rootkit却可以检测到。
为取得信息,这里使用了函数GetInfoTable。为了避免大家有疑问,我这里给出其完整代码:
/*
Получение буфера с результатом ZwQuerySystemInformation.
*/
PVOID GetInfoTable(ULONG ATableType)
{
ULONG mSize = 0x4000;
PVOID mPtr = NULL;
NTSTATUS St;
do
{
mPtr = ExAllocatePool(PagedPool, mSize);
memset(mPtr, 0, mSize);
if (mPtr)
{
St = ZwQuerySystemInformation(ATableType, mPtr, mSize, NULL);
} else return NULL;
if (St == STATUS_INFO_LENGTH_MISMATCH)
{
ExFreePool(mPtr);
mSize = mSize * 2;
}
} while (St == STATUS_INFO_LENGTH_MISMATCH);
if (St == STATUS_SUCCESS) return mPtr;
ExFreePool(mPtr);
return NULL;
}
我想,这个函数的思想理解起来并不难。
从EPROCESS结构体双向链表中取得进程列表
我们接着往下说。下一步就是遍历EPROCESS结构体双向链表来获取进程列表。该链表的链表头为PsActiveProcessHead,所以要想正确的枚举进程,我们首先需要找到这个未导出的符号。最简单的办法就是利用System是进程表中的第一个进程这个特点。我们需要在DriverEntry中使用PsGetCurrentProcess来得到当前进程的指针(使用SC Manager API或是ZwLoadDriver加载的驱动总是位于System的上下文中),偏移ActiveProcessLinks处的Blink就指向PsActiveProcessHead。其形式如下:
PsActiveProcessHead = *(PVOID *)((PUCHAR)PsGetCurrentProcess + ActiveProcessLinksOffset + 4);
现在可以遍历双向链表并建立进程列表了:
PVOID GetEprocessProcessList(ULONG *MemSize)
{
PLIST_ENTRY Process;
ULONG PsCount = 0;
PVOID Mem = NULL;
PProcessRecord Data;
if (!PsActiveProcessHead) return NULL;
Process = PsActiveProcessHead->Flink;
while (Process != PsActiveProcessHead)
{
PsCount++;
Process = Process->Flink;
}
PsCount++;
*MemSize = PsCount * sizeof(TProcessRecord);
Mem = ExAllocatePool(PagedPool, *MemSize);
memset(Mem, 0, *MemSize);
if (!Mem) return NULL; else Data = Mem;
Process = PsActiveProcessHead->Flink;
while (Process != PsActiveProcessHead)
{
Data->Present = TRUE;
Data->ProcessId = *(PULONG)((ULONG)Process - ActPsLink + pIdOffset);
Data->ParrentPID = *(PULONG)((ULONG)Process - ActPsLink + ppIdOffset);
Data->SignalState = *(PULONG)((ULONG)Process - ActPsLink + 4);
Data->pEPROCESS = (PEPROCESS)((ULONG)Process - ActPsLink);
strncpy(Data->ProcessName, (PVOID)((ULONG)Process - ActPsLink + NameOffset), 16);
Data++;
Process = Process->Flink;
}
return Mem;
}
为了取得进程名、Process Id和ParrentProcessId,我们使用EPROCESS结构体中域的偏移(pIdOffset、ppIdOffset、NameOffset、ActPsLink)。这些偏移对不同版本的Windows是不一样的,所以我们用一个独立的函数来取得这些偏移,该函数可以在Process Hunter的源代码中找到。
这种方法可以找到所有利用API钩挂实现隐藏的进程。但如果进程使用了DKOM(Direct Kernel Object Manipulation)的方法来隐藏,则这种方法也就无能为力了,因为进程链表中已经找不到这个进程了。
通过调度线程链表获取进程链表
再有一种检测隐藏进程的方法就是通过调度线程链表来取得进程列表。在Windows 2000中有三个双向线程链表:KiWaitInListHead,KiWaitOutListHead和KiDispatcherReadyListHead。前两个链表包含的是等待某事件的线程,而第三个链表包含的是准备好执行的线程。遍历链表并减去ETHREAD结构体中的线程链表偏移,我们就得到了指向线程的ETHREAD的指针。这个结构体包含着几个指向该线程进程的指针,有struct _KPROCESS *Process (0x44, 0x150)和sruct _EPROCESS *ThreadsProcess (0x22C, Windows 2000下的偏移)。前两个指针并不影响线程的工作,所以很容易为实现隐藏而将其偷换。而第三个指针用在切换地址空间的调度中,所以不能够被替换。我们将用这个指针来确定拥有该线程的进程。
klister程序使用的就是这种检测方法,其主要的缺点就是只能在Windows 2000(有的service pack也不行)上运行。造成这个缺陷的原因是,Klister对线程链表的地址进行了严密保护,而这个线程链表的地址几乎在所有的service pack上都不一样。
在程序中保护链表的地址是一种很不好的方法,因为程序可能在后续版本的OS中不能工作,这就使得检测方法失效,所以链表的地址应该动态查找,这就要分析使用它们的函数的代码。
首先,我们试着来在Windows 2000中找到KiWaitItListHead和KiWaitOutListHead。这些链表的地址用在KeWaitForSingleObject函数中,函数代码如下:
.text:0042DE56 mov ecx, offset KiWaitInListHead
.text:0042DE5B test al, al
.text:0042DE5D jz short loc_42DE6E
.text:0042DE5F cmp byte ptr [esi+135h], 0
.text:0042DE66 jz short loc_42DE6E
.text:0042DE68 cmp byte ptr [esi+33h], 19h
.text:0042DE6C jl short loc_42DE73
.text:0042DE6E mov ecx, offset KiWaitOutListHead
要取得这些链表的地址需要在KeWaitForSingleObject中进行长指令的反汇编(我们将使用我的LDasm),且有时指针(pOpcode)会在指令
mov ecx, KiWaitInListHead里,则pOpcode + 5将指向test al, al,而pOpcode + 24指向mov ecx, KiWaitOutListHead。在这个地址之后KiWaitItListHead和KiWaitOutListHead分别由指针pOpcode + 1和pOpcode + 25取出。搜索这些地址的代码如下:
void Win2KGetKiWaitInOutListHeads()
{
PUCHAR cPtr, pOpcode;
ULONG Length;
for (cPtr = (PUCHAR)KeWaitForSingleObject;
cPtr < (PUCHAR)KeWaitForSingleObject + PAGE_SIZE;
cPtr += Length)
{
Length = SizeOfCode(cPtr, &pOpcode);
if (!Length) break;
if (*pOpcode == 0xB9 && *(pOpcode + 5) == 0x84 && *(pOpcode + 24) == 0xB9)
{
KiWaitInListHead = *(PLIST_ENTRY *)(pOpcode + 1);
KiWaitOutListHead = *(PLIST_ENTRY *)(pOpcode + 25);
break;
}
}
return;
}
在Windows 2000中找KiDispatcherReadyListHead用的也是同样的办法,这回在KeSetAffinityThread函数中找,形式如下:
.text:0042FAAA lea eax, KiDispatcherReadyListHead[ecx*8]
.text:0042FAB1 cmp [eax], eax
以下是搜索KiDispatcherReadyListHead的函数:
void Win2KGetKiDispatcherReadyListHead()
{
PUCHAR cPtr, pOpcode;
ULONG Length;
for (cPtr = (PUCHAR)KeSetAffinityThread;
cPtr < (PUCHAR)KeSetAffinityThread + PAGE_SIZE;
cPtr += Length)
{
Length = SizeOfCode(cPtr, &pOpcode);
if (!Length) break;
if (*(PUSHORT)pOpcode == 0x048D && *(pOpcode + 2) == 0xCD && *(pOpcode + 7) == 0x39)
{
KiDispatcherReadyListHead = *(PVOID *)(pOpcode + 3);
break;
}
}
return;
}
遗憾的是,Windows XP与Windows 2000的内核相差很大。XP中的调度用的不是三个,而是两个线程链表:KiWaitListHead和KiDispatcherReadyListHead。KiWaitListHead可以通过用以下代码扫描KeDelayExecutionThread函数得到:
.text:004055B5 mov dword ptr [ebx], offset KiWaitListHead
.text:004055BB mov [ebx+4], eax
查找代码如下:
void XPGetKiWaitListHead()
{
PUCHAR cPtr, pOpcode;
ULONG Length;
for (cPtr = (PUCHAR)KeDelayExecutionThread;
cPtr < (PUCHAR)KeDelayExecutionThread + PAGE_SIZE;
cPtr += Length)
{
Length = SizeOfCode(cPtr, &pOpcode);
if (!Length) break;
if (*(PUSHORT)cPtr == 0x03C7 && *(PUSHORT)(pOpcode + 6) == 0x4389)
{
KiWaitInListHead = *(PLIST_ENTRY *)(pOpcode + 2);
break;
}
}
return;
}
最难的是找KiDispatcherReadyListHead。问题在于,KiDispatcherReadyListHead的地址并不在任何导出的函数中,所以要取得它就要使用更复杂的查找算法。查找将从KiDispatchInterrupt函数开始,对于这个函数我们只对一个地方感兴趣,就是下面的代码:
.text:00404E72 mov byte ptr [edi+50h], 1
.text:00404E76 call sub_404C5A
.text:00404E7B mov cl, 1
.text:00404E7D call sub_404EB9
这段代码的第一个call指向对KiDispatcherReadyListHead进行引用的函数,但查找该地址的困难在于,其在函数中的位置在Winows XP SP1和SP2下是不一样的。在SP2中形式如下:
.text:00404CCD add eax, 60h
.text:00404CD0 test bl, bl
.text:00404CD2 lea edx, KiDispatcherReadyListHead[ecx*8]
.text:00404CD9 jnz loc_401F12
.text:00404CDF mov esi, [edx+4]
而在SP1中则为:
.text:004180FE add eax, 60h
.text:00418101 cmp [ebp+var_1], bl
.text:00418104 lea edx, KiDispatcherReadyListHead[ecx*8]
.text:0041810B jz loc_418760
.text:00418111 mov esi, [edx]
仅通过一个lea指令来找是不可靠的,所以我们还要用lea后的rel32偏移(LDasm中的IsRelativeCmd)来验证。查找KiDispatcherReadyListHead的完整代码如下:
void XPGetKiDispatcherReadyListHead()
{
PUCHAR cPtr, pOpcode;
PUCHAR CallAddr = NULL;
ULONG Length;
for (cPtr = (PUCHAR)KiDispatchInterrupt;
cPtr < (PUCHAR)KiDispatchInterrupt + PAGE_SIZE;
cPtr += Length)
{
Length = SizeOfCode(cPtr, &pOpcode);
if (!Length) return;
if (*pOpcode == 0xE8 && *(PUSHORT)(pOpcode + 5) == 0x01B1)
{
CallAddr = (PUCHAR)(*(PULONG)(pOpcode + 1) + (ULONG)cPtr + Length);
break;
}
}
if (!CallAddr || !MmIsAddressValid(CallAddr)) return;
for (cPtr = CallAddr; cPtr < CallAddr + PAGE_SIZE; cPtr += Length)
{
Length = SizeOfCode(cPtr, &pOpcode);
if (!Length) return;
if (*(PUSHORT)pOpcode == 0x148D && *(pOpcode + 2) == 0xCD && IsRelativeCmd(pOpcode + 7))
{
KiDispatcherReadyListHead = *(PLIST_ENTRY *)(pOpcode + 3);
break;
}
}
return;
}
在找到线程链表地址后,通过下面这个函数我们可以很容易的枚举出它们的进程:
void ProcessListHead(PLIST_ENTRY ListHead)
{
PLIST_ENTRY Item;
if (ListHead)
{
Item = ListHead->Flink;
while (Item != ListHead)
{
CollectProcess(*(PEPROCESS *)((ULONG)Item + WaitProcOffset));
Item = Item->Flink;
}
}
return;
}
CollectProcess是向链表中添加进程的函数,如果还没有把它添加到那里的话。
拦截系统调用获取进程列表
任何工作着的进程都通过API与系统协作,大多数这类请求都会通过系统调用接口转入内核。当然进程也可以不调用API,但那就干不了什么有益(或是有害)的事了。一般说来,此方法的思想是在转向系统调用接口时将其拦截,在处理程序中获取指向当前进程EPROCESS的指针。
windows 2000的系统调用使用中断2Eh,所以要拦截系统调用就需要修改idt中相应的描述符。为此需要首先使用sidt指令确定idt在内存中的位置。该指令返回以下结构体:
typedef struct _Idt
{
USHORT Size;
ULONG Base;
} TIdt;
修改2Eh中断描述符的代码如下:
void Set2kSyscallHook()
{
TIdt Idt;
__asm
{
pushad
cli
sidt [Idt]
mov esi, NewSyscall
mov ebx, Idt.Base
xchg [ebx + 0x170], si
rol esi, 0x10
xchg [ebx + 0x176], si
ror esi, 0x10
mov OldSyscall, esi
sti
popad
}
}
自然,在卸载驱动的时候需要进行恢复:
void Win2kSyscallUnhook()
{
TIdt Idt;
__asm
{
pushad
cli
sidt [Idt]
mov esi, OldSyscall
mov ebx, Idt.Base
mov [ebx + 0x170], si
rol esi, 0x10
mov [ebx + 0x176], si
sti
xor eax, eax
mov OldSyscall, eax
popad
}
}
Windows XP使用新指令sysenter/sysexit来实现系统调用接口,这些指令是从Pentium 2处理器开始有的。这些指令操纵的是MSR寄存器。系统调用处理程序的地址保存在MSR寄存器SYSENTER_EIP_MSR(第0x176号)中。读MSR寄存器要用rdmsr指令,在使用此指令之前在ECX中应加载所要读取的寄存器号,执行结果返回到EDX:EAX中。我们这里的SYSENTER_EIP_MSR是32位的,所以EDX为0,而EAX中则为系统调用处理程序的地址。与之类似,使用wrmsr指令可以写入MSR寄存器。但存在一个容易出错的地方:在写32位MSR寄存器时,EDX应该清零,否则会引起异常并且系统会立即崩溃。
综上所述,替换系统调用处理程序的代码如下:
void SetXpSyscallHook()
{
__asm
{
pushad
mov ecx, 0x176
rdmsr
mov OldSyscall, eax
mov eax, NewSyscall
xor edx, edx
wrmsr
popad
}
}
恢复旧处理程序的代码:
void XpSyscallUnhook()
{
__asm
{
pushad
mov ecx, 0x176
mov eax, OldSyscall
xor edx, edx
wrmsr
xor eax, eax
mov OldSyscall, eax
popad
}
}
Windows XP的特殊性在于,进行系统调用既可以通过sysenter也可以通过int 2Eh,所以我们需要替换两个处理程序。
新的系统调用处理程序应该取得指向当前进程EPROCESS的指针,如果是新进程的话,就将这个进程添加到列表中。
新系统调用处理程序形式如下:
void __declspec(naked) NewSyscall()
{
__asm
{
pushad
pushfd
push fs
mov di, 0x30
mov fs, di
mov eax, fs:[0x124]
mov eax, [eax + 0x44]
push eax
call CollectProcess
pop fs
popfd
popad
jmp OldSyscall
}
}
要想取得完整的进程列表,这段代码应该工作一段时间,而与之相应的就产生了以下的问题:如果位于列表中的进程之后被删除,则在后面扫描列表时就会得到不正确的指针,这样要么找到的是错误的隐藏进程,要么会发生BSOD。对这种情况的解决办法是注册一个PsSetCreateProcessNotifyRoutine类型的Callback函数,这个函数在进程创建和结束时都会被调用。进程结束时需要将其从列表中删除。此Callback函数原型如下:
VOID
(*PCREATE_PROCESS_NOTIFY_ROUTINE) (
IN HANDLE ParentId,
IN HANDLE ProcessId,
IN BOOLEAN Create
);
安装处理程序:
PsSetCreateProcessNotifyRoutine(NotifyRoutine, FALSE);
移除处理程序:
PsSetCreateProcessNotifyRoutine(NotifyRoutine, TRUE);
这里有一个需要注意的地方,Callback函数总是在结束的进程的上下文中调用,所以绝对不能直接进行进程的删除。对此需要使用system worker线程,首先使用IoAllocateWorkItem来为worker线程分配内存,之后使用IoQueueWorkItem将任务添加入队列。在处理程序中不仅要删除结束的进程,还要添加创建的进程。这里是处理程序的代码:
void WorkItemProc(PDEVICE_OBJECT DeviceObject, PWorkItemStruct Data)
{
KeWaitForSingleObject(Data->pEPROCESS, Executive, KernelMode, FALSE, NULL);
DelItem(&wLastItem, Data->pEPROCESS);
ObDereferenceObject(Data->pEPROCESS);
IoFreeWorkItem(Data->IoWorkItem);
ExFreePool(Data);
return;
}
void NotifyRoutine(IN HANDLE ParentId,
IN HANDLE ProcessId,
IN BOOLEAN Create)
{
PEPROCESS process;
PWorkItemStruct Data;
if (Create)
{
PsLookupProcessByProcessId(ProcessId, &process);
if (!IsAdded(wLastItem, process)) AddItem(&wLastItem, process);
ObDereferenceObject(process);
} else
{
process = PsGetCurrentProcess();
ObReferenceObject(process);
Data = ExAllocatePool(NonPagedPool, sizeof(TWorkItemStruct));
Data->IoWorkItem = IoAllocateWorkItem(deviceObject);
Data->pEPROCESS = process;
IoQueueWorkItem(Data->IoWorkItem, WorkItemProc, DelayedWorkQueue, Data);
}
return;
}
这种隐藏进程的检测方法十分可靠,因为没有进程能绕过系统调用,但是某些进程可能长时间处于等待状态并且不进行系统调用,这样的进程就无法检测到了。
绕过这种检测方法也不复杂,对此需要修改隐藏进程执行系统调用的方法(改用其它中断或是用GDT中的call gate)。这在Windows XP尤其容易实现,因为patch掉ntdll.dll中的KiFastSystemCall并为系统调用建立相应的入口就足够了。在Windows 2000中就复杂些了,因为对int 2E的调用分散在ntdll中,但找到并patch掉所有这些地方也并非十分困难,所以检测的结果也是不能全信的。
扫描句柄表获取进程列表
如果使用将进程从PsActiveProcesses列表中删除的办法来隐藏进程,则一定要注意,在用ZwQuerySystemInformation枚举句柄的时候,隐藏进程的句柄也会被枚举出来,由此可确定其ProcessId。这种情形会发生是因为为了便于句柄的枚举,所有的句柄表都被连成了一个双向的链表HandleTableList。Windows 2000这个表在HANDLE_TABLE结构体的偏移0x054处,而在Windows XP中为0x01C,这个链表起始于HandleTableListHead。结构体HANDLE_TABLE包含有指向拥有此结构体的进程(QuotaProcess)的指针,在Windows 2000中该指针的偏移为0x00C,而在Windows XP则为0x004。通过扫描此句柄链表我们能建立起进程列表。首先我们需要找到HandleTableListHead。对内核的反汇编显示,对其的引用都位于深层函数之内,所以我们以前用过的借助于代码反汇编的搜索方法就完全不适合了。但是对于HandleTableListHead的搜索可以使用这样一个性质,HandleTableListHead是一个全局的内核变量,所以它位于其PE文件的某个section中,而HandleTableList的其余元素位于动态分配的内存中,所以它们位于文件之外。由此,我们需要取得指向任意进程HandleTable的指针,并沿链表移动,直到其成员不在内核PE文件之内为止。这个成员就是HandleTableListHead。为了在内存中找到内核文件的基址和界限,我们使用ZwQuerySystemInformation,信息类SystemModuleInformation。该函数返回系统加载模块的数组,其中第一个就是内核。综上所述,搜索HandleTableListHead的代码如下:
void GetHandleTableListHead()
{
PSYSTEM_MODULE_INFORMATION_EX Info = GetInfoTable(SystemModuleInformation);
ULONG NtoskrnlBase = (ULONG)Info->Modules[0].Base;
ULONG NtoskrnlSize = Info->Modules[0].Size;
PHANDLE_TABLE HandleTable = *(PHANDLE_TABLE *)((ULONG)PsGetCurrentProcess() + HandleTableOffset);
PLIST_ENTRY HandleTableList = (PLIST_ENTRY)((ULONG)HandleTable + HandleTableListOffset);
PLIST_ENTRY CurrTable;
ExFreePool(Info);
for (CurrTable = HandleTableList->Flink;
CurrTable != HandleTableList;
CurrTable = CurrTable->Flink)
{
if ((ULONG)CurrTable > NtoskrnlBase && (ULONG)CurrTable < NtoskrnlBase + NtoskrnlSize)
{
HandleTableListHead = CurrTable;
break;
}
}
}
这段代码通用性很好,因为它能在任何版本的Windows NT上运行,而且不止能用于搜索HandleTableListHead,还能搜索其它有类似结构的链表。
在获得HandleTableListHead的地址之后,我们可以扫描句柄表并建立进程列表:
void ScanHandleTablesList()
{
PLIST_ENTRY CurrTable;
PEPROCESS QuotaProcess;
for (CurrTable = HandleTableListHead->Flink;
CurrTable != HandleTableListHead;
CurrTable = CurrTable->Flink)
{
QuotaProcess = *(PEPROCESS *)((PUCHAR)CurrTable - HandleTableListOffset + QuotaProcessOffset);
if (QuotaProcess) CollectProcess(QuotaProcess);
}
}
使用这种检测方法的程序有F-Secure Black Light和上一版本的KProcCheck。如何绕过它呢?我想您已经猜到了吧。
扫描PspCidTable获得进程列表
通过在PsActiveProcesses中将自己删除从而