系统调用接口极其复杂,但由于大部分内容与我们的工作无关,我只想做一个较高层次的总结。在大多数情况下,你并不需要深入了解它是如何工作的,就可以使用这些技术,但了解一下还是有帮助的。
在 Windows 中,内核有一张允许从用户模式调用的函数表。这些函数有时被称为系统服务、本地函数或 Nt 函数。它们是以 Nt 或 Zw 开头的函数,位于 ntoskrnl.exe 中。系统服务表称为系统服务描述符表,简称 SSDT。
要从用户模式调用系统服务,必须执行系统调用,通过 syscall 指令完成。应用程序将系统服务 ID 保存在 eax 寄存器中,以此告诉内核要调用哪个系统服务。系统服务 ID(通常称为系统服务号、系统调用号或简称 SSN)是该函数在 SSDT 中的索引项。因此,将 eax 设置为 0 将调用 SSDT 中的第一个函数,1 将调用第二个函数,2 将调用第三个函数,依此类推...
查询结果如下:entry = nt!KiServiceTable+(SSN * 4)。
syscall 指令会使 CPU 切换到内核模式并调用系统调用处理程序,该程序会从 eax 寄存器中获取 SSN 并调用相应的 SSDT 函数。
假设一个应用程序调用 kernel32.dll 中的 OpenProcess() 函数来打开一个进程的句柄。
图片
正如你所看到的,该函数的真正作用是调用位于 ntdll.dll 中的 NtOpenProcess()。现在,让我们来看看 NtOpenProcess() 的逻辑。
图片
在 NtOpenProcess() 中,几乎没有任何代码。这是因为与所有以 Nt 或 Zw 开头的函数一样,NtOpenProcess() 实际上位于内核中。这些函数的 ntdll(用户模式)版本只是执行系统调用来调用其内核模式对应函数,这就是为什么它们经常被称为系统调用存根。
在我们的例子中,NtOpenProcess 的 SSN 是 0x26,但这个数字会随着 Windows 版本的变化而变化,所以不要指望它对你来说也是一样的。从简化的高层视图来看,调用流程大致如下:
图片
下面是关于 x86 系统调用流程的更详细概述:
图片
注意:在用户模式下,函数的 Nt 和 Zw 版本完全相同。在内核模式下,Zw 函数的运行路径略有不同。这是因为 Nt 函数是为从用户模式调用而设计的,因此要对函数参数进行更广泛的验证。
2.EDR和用户模式钩子
自 2005 年微软推出内核补丁保护(又称 PatchGuard)以来,许多对内核的修改现在都被阻止了。以前,安全产品通过挂钩 SSDT 从内核内部监控用户模式调用。由于所有 Nt/Zw 功能都是在内核中实现的,因此所有用户模式调用都必须通过 SSDT,并因此受到 SSDT 挂钩的影响。补丁防护使 SSDT 钩子成为禁区,因此许多 EDR 钩子转向挂钩 ntdll。
图片
由于 SSDT 存在于内核中,因此用户模式应用程序无法在不加载内核驱动程序的情况下干扰这些钩子。现在,钩子被放置在用户模式下,与应用程序并存。
那么,用户模式钩子是什么样的呢?
图片
要挂接 ntdll.dll 中的函数,大多数 EDR 只需用 jmp 指令覆盖函数代码的前 5 个字节。jmp 指令会将代码执行重定向到 EDR 自身 DLL(会自动加载到每个进程中)中的某些代码。CPU 被重定向到 EDR 的 DLL 后,EDR 可以通过检查函数参数和返回地址来执行安全检查。一旦 EDR 完成检查,它就可以通过执行覆盖指令恢复 ntdll 调用,然后跳转到钩子(jmp 指令)之后的 ntdll 位置。
图片
在上例中,NtWriteFile 被挂钩。绿色指令是 NtWriteFile 的原始指令。NtWriteFile 的前 3 条指令已被 EDR 的钩子(将执行重定向到 edr.dll 中名为 NtWriteFile 的函数的 jmp)覆盖。每当 EDR 想要调用真正的 NtWriteFile 时,它会执行 3 条被覆盖的指令,然后跳转到挂钩函数的第 4 条指令,完成系统调用。
虽然不同厂商的 EDR 挂钩可能略有不同,但原理仍然相同,而且都有一个共同的弱点:它们都位于用户模式下。由于钩子和 EDR 的 DLL 都必须放在每个进程的地址空间内,因此恶意进程可以篡改它们。
3.绕过EDR钩子
绕过 EDR 钩子的方法有很多,我只介绍主要的几种。
卸载EDR钩子
由于挂钩的 ntdll 位于我们自己进程的内存中,因此我们可以使用 VirtualProtect() 使内存可写,然后用原始函数代码覆盖 EDR 的 jmp 指令。为了替换钩子,我们当然需要知道原来的汇编指令是什么。最常见的方法是从磁盘读取 ntdll.dll 文件,然后将内存版本与磁盘版本进行比较。前提是 EDR 不会检测或阻止从磁盘手动读取 ntdll.dll。
这种方法的主要缺点是,EDR 可以定期检查 ntdll 的内存,查看其钩子是否已被删除。如果 EDR 检测到其钩子已被移除,它可能会将钩子写回,更有甚者会终止进程并触发检测事件。虽然钩子可能需要放在用户模式下,但检查钩子可以在内核模式下进行,因此我们也没有什么办法来防止这种情况发生。
手动映射DLL
与其从磁盘中读取 ntdll 的纯净拷贝来解锁原始 ntdll,我们还不如直接将纯净拷贝加载到进程内存中,然后使用它来代替原始 ntdll。由于 LoadLibrary() 和 LdrLoadDll() 等函数不允许系统两次加载同一个 DLL,所以我们必须手动加载。手动映射 DLL 的代码可能会很繁杂,而且容易出错或被检测到。
DLL 通常也会调用其他 DLL,因此我们要么只能使用手动加载的 ntdll 中的函数,要么为我们需要的每个 DLL 加载第二个副本,并修补它们,使其只能使用其他手动加载的 DLL,这可能会变得非常混乱。如果杀毒软件在进行内存扫描时,发现每个 DLL 都有多个副本加载到内存中,那么也很有可能被发现。
直接系统调用
正如前面讨论的那样,用户模式下的Nt/Zw函数实际上除了执行系统调用之外并不执行其他任何操作。因此,我们实际上不需要映射整个新的ntdll副本来执行一些系统调用。相反,我们可以直接将系统调用逻辑实现到我们自己的代码中。我们只需将要调用的函数的SSN(函数号)移动到eax寄存器中,然后执行syscall指令。
__asm {
mov r10, rcx
mov eax, 0x123
syscall
ret
}
不幸的是,由于EDR的钩子通常会覆盖设置eax寄存器的指令,我们不能简单地从被挂钩的函数中提取它。但是...有一些方法我们可以找出它是什么。
从ntdll读取一个干净的拷贝
你可能已经对这个想法感到厌倦了,但我们可以从磁盘上读取一个干净的ntdll副本,然后从中提取SSN。由于SSN始终被放入eax寄存器,我们只需扫描我们想要调用的函数以找到"mov eax, imm32"指令即可。但是,如果我们想要一种不仅仅是从磁盘读取ntdll的变体呢?别担心!
根据函数顺序计算系统调用号
系统调用ID是索引,因此是顺序的。如果我们想要调用的函数的SSN是0x18,那么直接在它之前的可能是0x17,直接在它之后的可能是0x19。由于EDR并不挂钩每个Nt函数,我们可以简单地从最近的未被挂钩的函数中获取SSN,然后通过添加或减去在它和我们目标函数之间有多少个函数来计算我们想要的函数的SSN。
图片
这种方法确实有一个缺陷:我们无法百分之百地保证系统调用号将永远保持连续,或者DLL不会跳过一些。
硬编码
最简单的方法就是直接硬编码系统调用号。虽然它们在不同版本之间会有所改变,但在过去它们的变化并不是很大。检测操作系统版本并加载正确的SSN集并不是太难的工作。事实上,j00ru友好地发布了每个Windows版本的每个系统调用号的列表。这种方法唯一的缺点是,如果系统调用号发生变化,代码可能在新的Windows版本上无法自动运行。
直接系统调用的问题
在过去的十多年里,直接系统调用一直是绕过用户模式钩子的首选方法。实际上,我自己在2012年初次尝试了这种方法。不幸的是,为了防止这种绕过方式,已经进行了很多工作。最常见的检测方法是让EDR的内核模式驱动程序检查调用堆栈。
尽管EDR不能再在内核中挂钩很多地方,但它可以利用操作系统提供的监视功能,比如:
- ETW事件
- 内核回调
- 过滤驱动程序
如果我们执行手动系统调用,而在调用的内核函数经过以上任何一种情况时,EDR可以利用机会检查我们线程的调用堆栈。通过展开调用堆栈并检查返回地址,EDR可以看到导致此系统调用的整个函数调用链。
如果我们执行对kernel32!VirtualAlloc()的正常调用,调用堆栈可能如下所示:
图片
在这种情况下,对VirtualAlloc()的调用是由ManualSyscall!main+0x53启动的。按照调用的顺序,调用堆栈的相关部分如下:
- ManualSyscall!main+0x53
- KERNELBASE!VirtualAlloc+0x48
- ntdll!NtAllocateVirtualMemory+0x14
- nt!KiSystemServiceCopyEnd+0x25
这告诉我们(或者EDR)可执行文件(ManualSyscall.exe)调用了VirtualAlloc(),这个函数调用了NtAllocateVirtualMemory(),然后执行了一个系统调用以切换到内核模式。
现在让我们看看进行直接系统调用时的调用堆栈:
图片
调用堆栈的相关部分按顺序如下:
- ManualSyscall!direct_syscall+0xa
- nt!KiSystemServiceCopyEnd+0x25
在这里,很明显内核转换是由ManualSyscall.exe内部的代码触发的,而不是ntdll。但是,这有什么问题吗?
嗯,在像Linux这样的系统上,应用程序直接发起系统调用是完全正常的。但请记住我提到过Windows版本之间系统调用号会发生变化吗?结果,编写依赖于直接系统调用的Windows软件是非常不切实际的。由于ntdll已经为您实现了每个系统调用,几乎没有理由进行手动系统调用。除非你正在编写绕过EDR钩子的恶意软件。你是在写用于绕过EDR钩子的恶意软件吗?
由于直接系统调用是恶意活动的强有力指标,更复杂的EDR系统将记录源自于ntdll之外的系统调用的检测情况。说实话,你仍然可以在很多时候逃脱检测,但这有什么乐趣呢?
4.间接系统调用
大多数EDR在Nt函数的开头写入它们的钩子,覆盖SSN但保留系统调用指令不变。这使我们能够利用ntdll已经提供的系统调用指令,而不是引入我们自己的。我们只需自己设置r10和eax寄存器,然后跳转到被挂钩的ntdll函数内的系统调用指令(位于EDR钩子之后)。
图片
注意:我们并不严格需要test或jnz指令,它们只是为了向后兼容。一些古老的CPU不支持syscall指令,而是使用int 0x2e。test指令检查系统调用是否启用,如果没有启用,则回退到软中断。如果我们希望支持这些系统,我们可以自己执行检查,然后根据需要跳转到int 0x2e指令(也位于Nt函数内)。
就像直接系统调用一样,我们仍然需要系统调用号放入eax寄存器,但我们可以使用在直接系统调用部分详细介绍的所有相同技术。
通过这种方式设置系统调用将给我们一个类似以下的调用堆栈:
图片
正如你所看到的,调用堆栈现在看起来好像是来自ntdll!NtAllocateVirtualMemory()而不是我们的可执行文件,因为从技术上讲确实是这样的。
我们可能会遇到的一个问题是,如果EDR钩子或覆盖了Nt调用中的syscall指令的部分。我从未见过这种情况发生,但理论上可能会发生。在这种情况下,我们可以跳转到另一个未被挂钩的Nt函数内的syscall指令。这仍然可以绕过仅验证调用名称是否来自ntdll的EDR,但对于检查内核函数是否与来自ntdll的函数相匹配的这种检查通常都会失败。
更大的问题是,如果EDR检查的不仅仅是第一个返回地址。不仅仅是系统调用的来源,还有执行系统调用的函数是谁调用的。如果我们正在从位于动态分配内存中的某个shellcode进行间接系统调用,那么EDR将会察觉到。来自于有效PE节(exe或DLL内存)之外的调用是相当可疑的。
此外,由于函数被EDR挂钩,EDR的钩子预期会出现在调用堆栈中。实际上,我并不确定哪些EDR,如果有的话,会检查这一点。但是,正如你在这里看到的,从调用堆栈中很明显我们绕过了EDR的钩子。
图片
理想情况下,我们希望伪造的不仅仅是系统调用的返回地址。对此的一个有趣解决方案是调用堆栈欺骗,我可能会在另一篇文章中详细介绍。使用调用堆栈欺骗,可以伪造整个调用堆栈,但要保持调用堆栈稳定不崩溃可能会遇到一些挑战。