22、进程保护与隐藏:DKOM技术、进程隐藏、进程保护、反反调试
说实话,进程保护与隐藏这个话题,在Windows内核开发里算是个“老牌劲旅”了。我早年做安全产品的时候,几乎天天跟这些东西打交道。你想想看,一个恶意软件想藏起来不被发现,一个反病毒软件又想把它揪出来——这本质上就是一场猫鼠游戏。
今天这一章,咱们就聊聊DKOM(Direct Kernel Object Manipulation)技术。说白了,就是直接操作内核对象。这招很底层,也很暴力。我个人的习惯是,先理解它为什么能工作,再动手写代码。
DKOM的基本原理
Windows内核里管理进程,靠的是一个叫EPROCESS的结构体。每个进程都有一个。这些结构体通过一个双向链表串在一起。你打开任务管理器,看到的进程列表,其实就是遍历这个链表的结果。
那么问题来了:如果我把某个进程的EPROCESS从链表里摘掉,任务管理器是不是就看不到它了?
没错,这就是DKOM隐藏进程的核心思路。
核心思想: 直接修改内核中的EPROCESS链表,将目标进程的节点从链表中移除,从而达到“隐藏”的效果。
但这里有个坑。我当年第一次做这个实验时,直接把节点摘掉,结果系统蓝屏了。为什么?因为链表操作必须保证前后指针的正确性。你摘掉一个节点,必须把它的前一个节点和后一个节点连起来。
// 伪代码:从双向链表中移除一个节点
// 假设 pEProcess 指向目标进程的 EPROCESS 结构
PLIST_ENTRY pListEntry = &pEProcess->ActiveProcessLinks;
PLIST_ENTRY pPrev = pListEntry->Blink;
PLIST_ENTRY pNext = pListEntry->Flink;
pPrev->Flink = pNext;
pNext->Blink = pPrev;
// 注意:这里只是从链表移除,并没有释放内存
// 进程本身还在运行,只是“看不见”了
警告: 直接操作内核链表必须关中断,并且要保证操作是原子的。否则在操作过程中被线程切换打断,链表就乱套了。我曾经因为这个原因,调试了整整一个下午。
进程隐藏的完整实现
隐藏进程,不仅仅是摘链表那么简单。你想想看,任务管理器看不到它了,但ZwQuerySystemInformation这个API呢?很多安全软件是通过这个API枚举进程的。
所以,真正的隐藏需要做两件事:
- 摘链表:让
EPROCESS不出现在内核的进程链表中。 - Hook API:拦截
ZwQuerySystemInformation,在返回结果前过滤掉目标进程。
我个人更推荐第二种方式,因为它更安全。直接摘链表容易导致某些依赖链表遍历的内核功能出问题。
// Hook ZwQuerySystemInformation 的简化逻辑
NTSTATUS HookedZwQuerySystemInformation(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength)
{
NTSTATUS status;
// 先调用原始函数
status = OriginalZwQuerySystemInformation(
SystemInformationClass,
SystemInformation,
SystemInformationLength,
ReturnLength);
if (NT_SUCCESS(status) &&
SystemInformationClass == SystemProcessInformation)
{
// 遍历返回的进程信息,移除目标进程
RemoveTargetProcessFromList(SystemInformation);
}
return status;
}
提示: 我在项目中遇到过一个问题:Hook了ZwQuerySystemInformation之后,某些杀毒软件会检测到我们的Hook。后来我改用Inline Hook + 硬件断点的组合方式,才绕过了检测。嗯,这里面的门道很深。
进程保护:让进程“杀不死”
隐藏是第一步,保护是第二步。什么叫保护?就是别人想结束你的进程,做不到。
常见的结束进程方式有两种:
- 用户态:调用
TerminateProcessAPI。 - 内核态:直接调用
ZwTerminateProcess。
保护的核心思路,就是拦截这些调用。我常用的方法是:
- Hook ObReferenceObjectByHandle:当有人想打开你的进程句柄时,返回拒绝。
- Hook NtTerminateProcess:直接拦截结束进程的请求。
// 保护进程:拦截 TerminateProcess
NTSTATUS HookedNtTerminateProcess(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
PEPROCESS pEProcess = NULL;
// 获取目标进程的 EPROCESS
if (NT_SUCCESS(PsLookupProcessByProcessId(
GetCurrentProcessId(), &pEProcess)))
{
// 检查是否是受保护的进程
if (IsProtectedProcess(pEProcess))
{
ObDereferenceObject(pEProcess);
return STATUS_ACCESS_DENIED;
}
ObDereferenceObject(pEProcess);
}
// 不是受保护进程,放行
return OriginalNtTerminateProcess(ProcessHandle, ExitStatus);
}
关键点: 保护进程时,一定要考虑“递归调用”的问题。比如你的Hook函数里又调用了PsLookupProcessByProcessId,而这个函数内部可能又调用了你Hook的函数……那就死循环了。我建议在Hook函数入口处加一个“重入标志”。
反反调试:与调试器的博弈
反调试和反反调试,说白了就是“你藏我找”的游戏。调试器想附加到你的进程,你不想让它附加。怎么办?
常见的反调试手段有:
- 检测调试器:调用
NtQueryInformationProcess检查ProcessDebugPort。 - 时间差检测:执行一段代码前后计时,如果时间差过大,说明被调试了。
- 异常处理:故意触发异常,如果被调试器捕获,说明有调试器存在。
但反反调试也有对策。我记得有一次,一个恶意软件用NtSetInformationProcess把自己从调试器里“踢”出来。我当时是怎么应对的?
嗯,我在内核里Hook了NtSetInformationProcess,当检测到有人想修改ProcessDebugPort时,直接返回成功,但实际上什么也不做。这样调试器就“赖”在进程里不走了。
// 反反调试:阻止调试器被移除
NTSTATUS HookedNtSetInformationProcess(
HANDLE ProcessHandle,
PROCESS_INFORMATION_CLASS ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength)
{
// 如果试图修改调试端口,直接返回成功
if (ProcessInformationClass == ProcessDebugPort)
{
return STATUS_SUCCESS;
}
// 其他信息正常处理
return OriginalNtSetInformationProcess(
ProcessHandle,
ProcessInformationClass,
ProcessInformation,
ProcessInformationLength);
}
注意: 反调试和反反调试是一个动态博弈的过程。你今天写的代码,明天可能就被新的技术绕过了。我个人的建议是:不要追求“绝对安全”,而是要做到“足够难破解”。
知识体系总览
下面这张图,是我对本章知识点的总结。你可以把它当作一个“思维导图”来看。
避坑指南
最后,分享几个我踩过的坑:
- 链表操作一定要关中断:否则在操作过程中被中断,链表指针可能被其他线程修改,导致蓝屏。
- Hook函数要处理重入:你的Hook函数里如果调用了被Hook的原始函数,一定要有重入保护。否则就是死循环。
- 不要忘记恢复:卸载驱动时,一定要把被修改的内核对象恢复原状。否则系统会变得不稳定。
- 测试环境要用虚拟机:我当年直接在物理机上测试,结果蓝屏了,数据全丢了。从那以后,我再也不敢在物理机上做内核实验了。
个人经验: 做内核开发,一定要有“敬畏之心”。你写的每一行代码,都可能让整个系统崩溃。我建议你在写代码之前,先在纸上画清楚逻辑,想清楚所有边界情况。嗯,这听起来很老套,但真的有用。