检测隐藏进程(2)
是直接来自系统内部数据结构。在这些检测方法下隐藏进程要困难得多,因为它们都是基于同Windows内核相同的原理实现的,并且从这些内核
数据结构中删除进程将导致该进程完全失效。
内核中的进程是什么?每一个进程都有自己的地址空间,描述符,线程等,内核的数据结构就涉及这些东西。每一个进程都是由EPROCESS结构
描述,而所有进程的结构都被一个双向循环链表维护。进程隐藏的一个方法就是改变进程结构链表的指针,使得链表枚举跳过自身达到进程隐
藏的目的。避开进程枚举并不影响进程的任何功能。无论怎样,EPROCESS结构总是存在的,对一个进程的正常功能来说它是必要的。在内核态
检测隐藏进程的主要方法就是对这个结构的检查。
我们应该定义一下将要储存的进程信息的变量格式。这个变量格式应该很方便地存储来自驱动的数据(附录)。结构定义如下:
Code:
typedef struct _ProcessRecord
{
ULONG Visibles;
ULONG SignalState;
BOOLEAN Present;
ULONG ProcessId;
ULONG ParrentPID;
PEPROCESS pEPROCESS;
CHAR ProcessName[256];
} TProcessRecord, *PProcessRecord;
应该为这些结构分配连续的大块的内存,并且不设置最后一个结构的Present标志。
在内核中使用ZwQuerySystemInformation函数得到进程列表。
我们先从最简单的方式开始,通过ZwQuerySystemInformation函数得到进程列表:
Code:
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;
}
以这个函数做参考,任何内核态的隐藏进程都不会被检测出来,但是所有的用户态隐藏进程如hxdef是绝对逃不掉的。
在下面的代码中我们可以简单地用GetInfoTable函数来得到信息。为了防止有人问那是什么东西,下面列出完整的函数代码。
Code:
/*
Receiving buffer with results from 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管理器的API或者ZwLoadDriver函数加载的驱动始终都是加载到System进程的
上下文中的),BLink在ActiveProcessLinks中的偏移将指向PsActiveProcessHead。像这样:
Code:
PsActiveProcessHead = *(PVOID *)((PUCHAR)PsGetCurrentProcess + ActiveProcessLinksOffset + 4);
现在就可以遍历这个双向链表来创建进程列表了:
Code:
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;
}
为了得到进程名称、ID和父进程ID,我们利用它们在EPROCESS结构中的偏移地址(pIdOffset, ppIdOffset, NameOffset, ActPsLink)。这些
偏移随着Windows系统版本的不同而不同,因此我们要在进程检测程序的代码中进行区分后得到他们正确的值(附录)。
任何一个通过API截取方式隐藏的进程都将被上面这个方法检测出来。但是如果进程是通过DKOM(直接处理内核对象 - Direct Kernel Object
Manipulation)方式隐藏,那这个方法就失效了,因为这种进程都被从进程链表中删掉了。
通过列举调度程序(scheduler)中的线程得到进程列表。
对付这种隐藏进程(俄文翻译kao注:这个地方原文写的比较模糊,作者大概的意思应该是“使用DKOM的方式检测隐藏进程”)的其中一种检测
方式是通过调度程序(scheduler)中的线程列表来得到进程列表。Windows 2000有三个维护线程的双向链表(KiWaitInListHead,
KiWaitOutListHead, KiDispatcherReadyListHead)。前面两个链表包含等待某种事件的线程,最后面的链表包含的是等待执行的线程。我们
处理这些链表,根据线程链表结构ETHREAD中的偏移就可以得到一个线程的ETHREAD指针(俄文翻译kao注:原文中这句话实在是太难懂了,希望
我翻译的正确)。这个结构包括了很多进程相关指针,也就是结构_KPROCESS *Process(0x44, 0x150)和结构_EPROCESS *ThreadsProcess
(0x22C, 这仅是Windows 2000中的偏移量)。前面两个指针对于一个线程的功能性没有任何影响,因此可以很容易修改它们来隐藏进程。相反,
第三个指针是当切换地址空间时调度程序(schedler)使用的指针,所以这个指针是不能修改的。我们就用它来找到拥有某个线程的进程。
Klister就是使用了这种检测方法,它的最大的缺点就是只能在Windows 2000平台上工作(但是在这个平台上某个补丁包也会让它失效)。导致
这个情况发生的原因就是这种程序使用了硬编码的线程链表地址,而在每个补丁包中这些地址可能都是不同的。
在程序中使用硬编码地址是很糟糕的解决方案,操作系统的升级就会使你的程序无法正常工作,要尽量避免使用这种检测方法。所以应该通过
分析那些使用了这些链表的内核函数来动态地得到它们的地址。
首先我们试试看在Windows 2000平台上找出KiWaitInListHead和KiWaitOutListHead.使用链表地址的函数KeWaitForSingleObject代码如下:
Code:
.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
我们使用反汇编器(用我写的LDasm)反汇编KeWaitForSingleObject函数来获得这些地址。当索引(pOpcode)指向指令“mov ecx,
KiWaitInListHead”,(pOpcode + 5)指向的就是指令“test al, al”,(pOpcode + 24)指向的就是“mov ecx, KiWaitOutListHead”。
这样我们就可以通过索引(pOpcode + 1)和(pOpcode + 25)正确地得到KiWaitInListHead和KiWaitOutListHead的地址了。搜索地址的代码
如下:
Code:
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函数:
Code:
.text:0042FAAA lea eax, KiDispatcherReadyListHead[ecx*8]
.text:0042FAB1 cmp [eax], eax
搜索KiDispatcherReadyListHead函数的代码:
Code:
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下的调度程序(scheduler)只有两个线程链表:KiWaitListHead和
KiDispatcherReadyListHead。我们可以通过搜索KeDelayExecutionThread函数来查找KeWaitListHead:
Code:
.text:004055B5 mov dword ptr [ebx], offset KiWaitListHead
.text:004055BB mov [ebx+4], eax
搜索代码如下:
Code:
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函数开始,我们感兴趣的地方只有这里:
Code:
.text:00404E72 mov byte ptr [edi+50h], 1
.text:00404E76 call sub_404C5A
.text:00404E7B mov cl, 1
.text:00404E7D call sub_404EB9
这段代码中的第一个函数调用指向的就是包含KiDispatcherReadyListHead引用的函数。尽管如此,搜索KiDispatcherReadyListHead的地址却
变的更加复杂,因为这个函数的相关代码在Windows XP SP1和SP2中是不同的。在SP2中它是这个样子:
Code:
.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]
And in SP1:
SP1中是这样的:
Code:
.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”后面的指令(LDasm中的IsRelativeCmd函数)。搜索
KiDispatcherReadyListHead的全部代码如下:
Code:
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;
}
找到线程链表地址之后我们就可以非常简单地枚举出那些进程了,代码如下:
Code:
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在内存
中的位置。该指令返回这样一个结构:
Code:
typedef struct _Idt
{
USHORT Size;
ULONG Base;
} TIdt;
修改2Eh中断向量的代码如下:
Code:
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
}
}
当然在卸载驱动之前还要保存原始状态的信息:
Code:
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处理器中)实现系统调用。这些指令的功能由model-specific registers(MSR)控制
。系统调用管理器的地址保存在MSR寄存器,SYSENTER_EIP_MSR(0x176)中。用rdmsr指令读取MSR寄存器,同时设置ECX = 要读取的寄存器的号
码,结果保存在两个积存器EDX:EAX中。在我们这里,SYSENTER_EIP_MSR积存器是32位积存器,所以EDX为0,EAX内是系统调用管理器的地址。
同样地,我们也可以用wrmsr指令写MSR积存器。有一个地方需要注意:当写32位MSR积存器的时候,EDX应该被清空,否则将引起异常并且导致
系统立即崩溃。
考虑到所有的事情之后,替代系统调用管理器的代码如下:
Code:
void SetXpSyscallHook()
顶(0)
踩(0)
- 最新评论