问答
发起
提问
文章
攻防
活动
Toggle navigation
首页
(current)
问答
商城
实战攻防技术
漏洞分析与复现
NEW
活动
摸鱼办
搜索
登录
注册
Windows系统调用免杀的过去和未来
安全工具
本文将介绍Windows系统调用在免杀对抗中需要解决的问题以及各个免杀的系统调用项目是如何解决这些问题的,通过本文的学习能让大家更加深入地了解Windows系统调用的过程并了解运用系统调用去规避AV/EDR的检测的一些技巧。
前言 == 大家好,我是拖更博主r0leG3n7。本文将介绍Windows系统调用在免杀对抗中需要解决的问题以及各个免杀的系统调用项目是如何解决这些问题的,通过本文的学习能让大家更加深入地了解Windows系统调用的过程并了解运用系统调用去规避AV/EDR的检测的一些技巧。如有任何错误和不足欢迎各位师傅指正,转载请注明文章出处。 注意:下面的大部分内容都只介绍系统调用从用户态准备到进入内核态的过程,并不会详细介绍内核态中的细节以及怎么从内核态回到用户态,因为对于免杀,我们通常只需要处理系统调用从用户态转到内核态转变过程,这个过程是AV/EDR重点监测的过程。 基础知识 ==== ntoskrnl.exe ------------ ntoskrnl.exe(NT Operating System Kernel Executable)是Windows 操作系统内核的核心组件。它是 Windows NT 系列操作系统(包括 Windows XP 及之后的版本)的内核执行体,负责管理系统资源、硬件抽象和核心系统服务。 ntoskrnl.exe对系统调用入口的意义: 1、SSDT表管理:维护系统服务描述符表(KeServiceDescriptorTable),存储所有内核 API 地址(如NtOpenprocess); 2、响应系统调用指令(int 2Eh / sysenter / syscall)。 **syscall stub** ---------------- 系统调用存根(syscall stub) 是用户空间程序与操作系统内核之间交互的桥梁,用于触发系统调用(syscall)。它是 Windows NT 系列操作系统(包括 Windows XP 及之后的版本)的内核执行体,负责管理系统资源、硬件抽象和核心系统服务。 系统调用号(System Call Number)是用户态程序通过系统调用进入内核时传递的一个数字,用于索引系统服务调度表(SSDT)中的函数指针。在用户态,每个系统调用在 ntdll.dll 中都有一个对应的存根函数(stub)。这些存根函数的主要工作就是将系统调用号放入特定的寄存器(如 eax 或 rax),然后执行触发系统调用的指令(如 int 0x2E 或 syscall)。因此,可以通过反汇编 ntdll.dll 中对应的存根函数来获取系统调用号,不同的系统版本对应函数的系统调用号是不一样的。 系统调用号(System Call Number)与系统服务号SSN(System Service Number)指的是同一个东西。 \_KUSER\_SHARED\_DATA --------------------- \_KUSER\_SHARED\_DATA 是 Windows 操作系统内核与用户态程序之间共享数据的关键结构体,位于固定的内存地址,用于高效传递系统信息。 在 User 层和 Kernel 层分别定义了一个 \_KUSER\_SHARED\_DATA 结构区域,用于 User 层和 Kernel 层共享某些数据,它们使用固定的地址值映射, \_KUSER\_SHARED\_DATA 结构区域在 User 和 Kernel 层地址分别为: User 层地址为:0x7ffe0000 Kernel 层地址为:0xffdf0000 这两个地址映射的物理页是相同的,但在User层是只读的,在Kernel层是可写的 \_KUSER\_SHARED\_DATA结构体如下: ```php kd> dt _KUSER_SHARED_DATA ntdll!_KUSER_SHARED_DATA +0x000 TickCountLowDeprecated : Uint4B +0x004 TickCountMultiplier : Uint4B +0x008 InterruptTime : _KSYSTEM_TIME +0x014 SystemTime : _KSYSTEM_TIME +0x020 TimeZoneBias : _KSYSTEM_TIME +0x02c ImageNumberLow : Uint2B +0x02e ImageNumberHigh : Uint2B +0x030 NtSystemRoot : [260] Wchar +0x238 MaxStackTraceDepth : Uint4B +0x23c CryptoExponent : Uint4B +0x240 TimeZoneId : Uint4B +0x244 LargePageMinimum : Uint4B +0x248 Reserved2 : [7] Uint4B +0x264 NtProductType : _NT_PRODUCT_TYPE +0x268 ProductTypeIsValid : UChar +0x26c NtMajorVersion : Uint4B +0x270 NtMinorVersion : Uint4B +0x274 ProcessorFeatures : [64] UChar +0x2b4 Reserved1 : Uint4B +0x2b8 Reserved3 : Uint4B +0x2bc TimeSlip : Uint4B +0x2c0 AlternativeArchitecture : _ALTERNATIVE_ARCHITECTURE_TYPE +0x2c4 AltArchitecturePad : [1] Uint4B +0x2c8 SystemExpirationDate : _LARGE_INTEGER +0x2d0 SuiteMask : Uint4B +0x2d4 KdDebuggerEnabled : UChar +0x2d5 NXSupportPolicy : UChar +0x2d8 ActiveConsoleId : Uint4B +0x2dc DismountCount : Uint4B +0x2e0 ComPlusPackage : Uint4B +0x2e4 LastSystemRITEventTickCount : Uint4B +0x2e8 NumberOfPhysicalPages : Uint4B +0x2ec SafeBootMode : UChar +0x2ed TscQpcData : UChar +0x2ed TscQpcEnabled : Pos 0, 1 Bit +0x2ed TscQpcSpareFlag : Pos 1, 1 Bit +0x2ed TscQpcShift : Pos 2, 6 Bits +0x2ee TscQpcPad : [2] UChar +0x2f0 SharedDataFlags : Uint4B +0x2f0 DbgErrorPortPresent : Pos 0, 1 Bit +0x2f0 DbgElevationEnabled : Pos 1, 1 Bit +0x2f0 DbgVirtEnabled : Pos 2, 1 Bit +0x2f0 DbgInstallerDetectEnabled : Pos 3, 1 Bit +0x2f0 DbgSystemDllRelocated : Pos 4, 1 Bit +0x2f0 DbgDynProcessorEnabled : Pos 5, 1 Bit +0x2f0 DbgSEHValidationEnabled : Pos 6, 1 Bit +0x2f0 SpareBits : Pos 7, 25 Bits +0x2f4 DataFlagsPad : [1] Uint4B +0x2f8 TestRetInstruction : Uint8B +0x300 SystemCall : Uint4B +0x304 SystemCallReturn : Uint4B +0x308 SystemCallPad : [3] Uint8B +0x320 TickCount : _KSYSTEM_TIME +0x320 TickCountQuad : Uint8B +0x320 ReservedTickCountOverlay : [3] Uint4B +0x32c TickCountPad : [1] Uint4B +0x330 Cookie : Uint4B +0x334 CookiePad : [1] Uint4B +0x338 ConsoleSessionForegroundProcessId : Int8B +0x340 Wow64SharedInformation : [16] Uint4B +0x380 UserModeGlobalLogger : [16] Uint2B +0x3a0 ImageFileExecutionOptions : Uint4B +0x3a4 LangGenerationCount : Uint4B +0x3a8 Reserved5 : Uint8B +0x3b0 InterruptTimeBias : Uint8B +0x3b8 TscQpcBias : Uint8B +0x3c0 ActiveProcessorCount : Uint4B +0x3c4 ActiveGroupCount : Uint2B +0x3c6 Reserved4 : Uint2B +0x3c8 AitSamplingValue : Uint4B +0x3cc AppCompatFlag : Uint4B +0x3d0 SystemDllNativeRelocation : Uint8B +0x3d8 SystemDllWowRelocation : Uint4B +0x3dc XStatePad : [1] Uint4B +0x3e0 XState : _XSTATE_CONFIGURATION ``` MSR寄存器 ------ MSR(Model Specific Register)模型特定寄存器是x86/x64架构CPU中的一组64位专用寄存器,主要用于配置硬件参数、监控运行状态及支持特定功能。 快速调用依赖MSR寄存器,MSR寄存器保存着内核入口点、内核栈指针等重要信息。 快速调用sysenter用到的MSR寄存器: 1、IA32\_SYSENTER\_CS:内核代码段选择; 2、IA32\_SYSENTER\_EIP:内核入口点(如 KiFastCallEntry); 3、IA32\_SYSENTER\_ESP:内核栈指针。 快速调用syscall用到的MSR寄存器: 1、IA32\_LSTAR:64 位内核入口点(如 KiSystemCall64); 2、IA32\_STAR:高 32 位指定内核 CS/SS,低 32 位指定用户态返回的 CS/SS。 用户态调用 syscall时,CPU 从 IA32\_LSTAR 加载 RIP,返回地址存入 RCX。 KiIntSystemCall / KiSystemService --------------------------------- KiIntSystemCall是用户模式中断门(INT 2E)发起系统调用的函数,CPU 执行 INT 2E 指令触发软中断,通过中断描述符表(IDT)找到对应的中断服务例程(ISR)KiSystemService,KiIntSystemCall执行的是完整的KiSystemService。KiSystemService 是(内核模式)系统调用的统一入口,其关键步骤包括: 1、定位 SSDT 表:通过 KeServiceDescriptorTable(全局变量)获取 SSDT 基地址(ServiceTableBase),附SSDT表结构: ```php typedef struct _SYSTEM_SERVICE_TABLE { PVOID ServiceTableBase; // 函数地址数组基址 PVOID ServiceCounterTable; ULONG NumberOfServices; // 服务数量 PVOID ParamTableBase; // 参数表基址(SSPT) } SYSTEM_SERVICE_TABLE, *PSYSTEM_SERVICE_TABLE; ``` 2、索引服务函数:以 EAX 中的服务号为索引,从 ServiceTableBase 指向的数组中取出目标函数地址。 ```php PULONG function_address = ServiceTableBase[EAX]; ``` 3、通过 SSPT(System Service Parameter Table) 获取参数大小(ParamTableBase\[EAX\]),将用户栈参数复制到内核栈。 4、构建完整的陷阱帧(\_KTRAP\_FRAME),保存线程状态。 KiFastSystemCall / KiFastCallEntry ---------------------------------- KiFastSystemCall是用户模式快速系统调用指令(sysenter)的入口,CPU 执行 sysenter 指令,直接跳转到预设的 MSR(模型特定寄存器)指定的地址(即 KiFastCallEntry)。 内核模式下,KiFastCallEntry的核心操作: 1、将用户栈切换成内核栈; 2、保存关键寄存器,将用户态的 RIP、CS、RFLAGS、RS等 存入内核栈,形成精简陷阱帧(\_KTRAP\_FRAME),保护现场; 3、执行KiSystemService 的核心逻辑,但不是完整的KiSystemService。 这时你可能会问这个这个快速调用的KiFastCallEntry和上面中断门的KiSystemService有什么相同和不同? 两者都会执行上面提到的KiSystemService 的关键步骤;但中断门会将通用寄存器(RAX、RCX、RDX 等)不只是关键的寄存器存入,形成完整的陷阱帧(\_KTRAP\_FRAME),而快速调用只保存关键寄存器形成精简的陷阱帧,两者最终会被KiSystemService补全成完整的陷阱帧,只是补全前保存的寄存器的数量不同,所以快速调用比中断门的系统开销更低,所以快速调用更"快",这个"快"强调的是进入ring 0的快;快速调用的"快"还体现在它不用像中断门那样去查中断描述符表(IDT)才能找到入口,快速调用是直接去MSR寄存器找到入口,再去跳转KiSystemService的关键步骤。 Zw\*开头的函数和Nt\*开头的函数 ------------------- 在用户模式(ring3)下,二者实现的功能完全相同,且都需要进行参数校验和模式切换,由于调用源自用户模式,所有参数均被严格检查,防止非法内存访问或权限越界。 在内核模式(ring0)下,二者实现的功能完全相同,但有以下区别: 1、调用Zw\*开头时,函数会将系统服务号SSN传入eax,将传入的函数指针传入edx,将Previous Mode设置为内核模式(Kernel Mode),然后调用KiSystemService,KiSystemService会根据eax中的服务号,到SSDT表中查找对应的Nt函数地址,但由于Previous Mode是信任的内核模式,所以不会进行如缓冲区溢出等严格的参数检查(可以说在内核模式下,Zw\*开头的函数是Nt\*开头函数的代理)。此过程严重依赖SSDT表,所以很容被SSDT Hook。 2、调用Nt\*开头时,其不会改变当前Previous Mode,而是继承调用者模式,也就是说此时的Previous Mode有可能是用户模式也有可能是内核模式,Nt 函数会执行严格的参数检查。这个过程不依赖SSDT,可以绕过SSDT Hook直接调用服务函数。 Windows异常处理机制 ------------- 1、CPU检测到异常; 2、查IDT寻找处理函数; 3、保存现场执行中断函数,触发系统中断进入ring0; 4、(ring0)CommonDispatchException把异常代码、异常发生的地址等信息存入\_EXCEPTION\_RECORD结构体,KiDispatchException分发异常信息至\_Trap\_Frame结构体,这个过程会判断是否存在调试器,如果存在调试器则发送给调试器处理异常,直至调试器处理异常完毕否则一直处于中断状态;如果不存在调试器,则在判断是否是在ring 0发生的异常,是则交给对应的ring 0异常处理函数(如果是ring 0的的异常且没有函数处理,系统将直接蓝屏),不是则返回ring 3; 5、返回ring 3后执行ntdll.dll的KiUserExceptionDispatcher进行用户态异常分发; 6、调用RtlDispatchException 遍历用户态处理链,先遍历VEH(Vectored Exception Handler)向量化异常处理链表寻找异常处理函数,再遍历SEH(Structured Exception Handler)结构化异常处理链表寻找异常处理函数,如果VEH链表和SEH链表都没有对应的用户态异常处理函数,则程序终止。 参考文章: [https://blog.csdn.net/qq\_41988448/article/details/112467210](https://blog.csdn.net/qq_41988448/article/details/112467210) Windows系统调用的过去 ============== 这里的"过去"主要是系统调用在x86系统时期在免杀方面的应用。这个时期的Windows系统还没有Patch Guard保护,所以杀软基本是可以随便在SSDT表上Hook,但这个时期杀软对于syscall/sysenter的检测也相对薄弱。这个时期的免杀对抗中,最为经典的代表就是地狱之门,系统调用主要需要解决两个问题: **1、系统调用号的获取** **2、syscall/sysenter硬编码的检测** 在介绍怎么解决这两个问题之前,我们先回顾一下用户模式下函数的调用执行过程,以OpenProcess为例: 1、用户态入口:用户程序调用OpenProcess函数,从已经预加载的Kernel32.dll找到该函数地址,校验权限,传入OBJECT\_ATTRIBUTES、CLIENT\_ID等需要用到的参数;接着转到Ntdll.dll,找到底层函数 NtOpenProcess,获取对应的存根函数(stub)地址,获取系统调用号; 2、用户态系统调用准备:将系统调用号存入EAX寄存器;通过共享内存页 KUSER\_SHARED\_DATA(地址 0x7FFE0000)获取内核入口函数地址(如 KiFastSystemCall 或 KiIntSystemCall); 3、内核态分发:KiFastSystemCall 或 KiIntSystemCall 根据 EAX 中的系统调用号在 SSDT 表中查找 NtOpenProcess 的内核地址,并进行严格的参数检查(防止产生缓冲区溢出、未进行内存对齐等问题); 4、内核执行:Ntoskrnl.exe执行NtOpenProcess。 在免杀对抗中,我们只需要处理用户态入口和用户态系统调用准备就行了,因为正常情况下不会有AV/EDR允许我们有加载驱动之类的操作。我们可以整一段简单的NtOpenProcess调用代码,然后在调式状态下看下它的反汇编代码是怎样的:  在x64系统中,快速调用syscall(x86下的快速调用为sysenter)可以总结为如下的经典格式: ```php 4C8BD1 -> mov r10, rcx B8XX000000 -> move eax,XX ;XX为系统调用号 0f05 -> syscall ``` 快速调用sysenter ------------ sysenter是x86下快速调用进入ring 0的关键指令 ,先来看一段sysenter间接调用NtReadFile代码: ```php section .text global _start _start: ; 1. 设置系统调用号 mov eax, 0xB7 ; 系统调用号存入EAX;0xB7是某个windows系统版本NtReadFile的调用号 lea edx, [esp+4] ; 用户态参数区地址存入EDX ; 2. 通过共享内存区调用SystemCallStub mov edx, 0x7FFE0300 ; KUSER_SHARED_DATA!SystemCallStub地址;0x7FFE0000(用户模式_KUSER_SHARED_DATA 结构体地址)+0x300(SystemCall偏移) call dword [edx] ; 间接调用sysenter入口 ``` 代码中0x7ffe0300 保存的 KiFastSystemCall 函数的地址,由0x7FFE0000(用户模式\_KUSER\_SHARED\_DATA 结构体地址)+0x300(SystemCall偏移)得到。KiFastSystemCall 函数存在进入ring0的关键指令sysenter。  上图来源: [https://mp.weixin.qq.com/s?\_\_biz=MjM5NTc2MDYxMw==&mid=2458297197&idx=1&sn=fd0f84e3fc6162fb563208c299d4ae82&chksm=b18193e786f61af12593779be9cf6fd1741f449c4b87152ff16c87d396984d565816491f394c&scene=27](https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458297197&idx=1&sn=fd0f84e3fc6162fb563208c299d4ae82&chksm=b18193e786f61af12593779be9cf6fd1741f449c4b87152ff16c87d396984d565816491f394c&scene=27) 若 CPU 支持 sysenter/sysexit 指令(通过 CPUID 检查 EDX 的第 11 位),则 SystemCall 指向 ntdll!KiFastSystemCall(使用 sysenter),否则指向 ntdll!KiIntSystemCall(通过 int 0x2E 中断门进入内核) 中断门 --- 从上面syscall和sysenter示例代码不难看出,快速调用有很明显的静态特征,那就是"syscall"和"sysenter"这两个关键字,早期的AV/EDR都可以很轻松地检测出来。我们聪明的黑客senpai就想到了int 2e中断门代替"sysenter"来绕过AV/EDR检测,通过触发中断向量0x2E,CPU根据中断描述符表(IDT) 跳转到预设的中断处理函数(如KiSystemService)。 int 2E(中断门)进入0环后执行的内核函数:NT!KiSystemService 我们可以从SysWhispers2项目代码看到中断门在免杀中的应用,使用SW2\_GetSyscallNumber函数获取到系统调用号以后再通过INT 02eh进入ring 0。  项目地址:<https://github.com/jthuraisamy/SysWhispers2> 地狱之门HellsGate ------------- 早期通过系统调用执行函数的木马的系统调用号基本都是直接硬编码,常见的系统调用号可以从如下网站获取: 32位:<https://j00ru.vexillium.org/syscalls/nt/32/> 64位:[https://j00ru.vexillium.org/syscalls/nt/64/](https://j00ru.vexillium.org/syscalls/nt/32/) 但随着Windows操作系统版本的增多,硬编码系统调用号的木马已经无法适应随之变化的免杀对抗环境。一是硬编码系统调用号更容易被静态检测;二是因为不同的Windows系统版本系统调用号是不一样的,硬编码意味着要嵌入大量的版本数据使木马体积增加,而且已经硬编码的木马也不一定有更新版本的系统调用号,意味着无法进行长时间APT量级的权限维持。这时我们聪明的黑客senpai又想到通过动态调用去获取系统调用号,最经典的就是地狱之门这个项目,项目地址: <https://github.com/am0nsec/HellsGate> 接下来我们看下地狱之门是如何解决本节开头提到的两个问题中的系统调用号的获取问题: 1、从项目main.c的主函数wmain()开始,它首先通过ProcessEnvironmentBlock函数获取TEB的结构体地址,再通过TEB结构体获取PEB结构体地址。  2、这里用PEB结构体的LoaderData的InMemoryOrderModuleList.Flink->Flink减去0x10偏移得到Ntdll的基址。  其实这里直接通过PEB结构体的LoaderData的InMemoryOrderModuleList.Flink->Flink减去0x10偏移获取Ntdll的基址是不太准确的,这里也有点硬编码的味道了,实际更为稳妥的获取ntdll基址方式应该是遍历的LoaderData的InMemoryOrderModuleList.Flink->Flink双向链表,通过LoaderData的BaseDllName.Buffer(dll的函数名)判断是不是ntdll.dll,如果是则获取LoaderData的DllBase作为ntdll的基址,否则继续遍历双向链表。 3、然后是通过PEB获取到的ntdll基址去解析ntdll的导出表。  4、然后这个项目最最最重点的部分来了,就是通过ntdll导出表中的函数地址获取系统调用号,这个项目每一个需要syscall的Windows API信息都存储在的自定义\_VX\_TABLE\_ENTRY结构体中,而这些\_VX\_TABLE\_ENTRY都存储在\_VX\_TABLE结构体中。 可以看到地狱之门这个项目会通过syscall调用NtAllocateVirtualMemory、NtProtectVirtualMemory、NtCreateThreadEx和NtWaitForSingleObject这些底层的windows API。  5、然后它是怎么通过ntdll导出表中的函数地址获取系统调用号的呢?我们可以直接看项目中的GetVxTableEntry函数 GetVxTableEntry函数传入的三个参数分别是pModuleBase(ntdll的基址),pImageExportDirectory(ntdll的导出表地址)和pVxTableEntry(需要进行syscall的Window API的\_VX\_TABLE\_ENTRY的指针),从主函数wmain()看出来我们只需要修改第三个参数,即需要进行syscall的Window API的\_VX\_TABLE\_ENTRY结构体指针,而且是\_VX\_TABLE\_ENTRY结构体中的dwHash成员。也就是说dwHash成员的值是我们预先设定好的,这个哈希值是通过ntdll中的导出表的函数名称和项目中的djb2函数计算出来的,在GetVxTableEntry函数的前半段我们也可以看出来,解析ntdll导出表中的序号导出表地址、名称导出表地址和函数地址,这三个地址对应的导出函数名称、导出函数序号和导出地址一一对应,所以可以通过遍历函数名称,再用项目中的djb2函数计算出对应的哈希值,再与我们预先设定好的对应函数名称的哈希值对比,确定当前遍历的函数是否是我们需要进行syscall的函数,再获取一一对应的函数地址。 找到需要进行syscall的函数地址后就可以进行系统调用号的提取,直接看GetVxTableEntry函数的这一段代码,这部分有点像我上一篇讲RDI中反射dll定位自身基址部分。这里就是向下逐个字节去遍历,如果找到连续的0x4C、0x8B、0xD1和0xB8这几个字节(汇编代码为mov r10, rcx 和 move eax,XX ),下一个字节XX就是系统调用号,直接赋值给GetVxTableEntry结构体的wSystemCall成员;如果到字节0x0F、0x05(汇编代码为syscall)或 0xC3(汇编代码为ret),说明很可能找不到系统调用号了,直接返回FALSE值退出循环。 ```php for (WORD cx = 0; cx < pImageExportDirectory->NumberOfNames; cx++) { PCHAR pczFunctionName = (PCHAR)((PBYTE)pModuleBase + pdwAddressOfNames[cx]); PVOID pFunctionAddress = (PBYTE)pModuleBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]]; if (djb2(pczFunctionName) == pVxTableEntry->dwHash) { pVxTableEntry->pAddress = pFunctionAddress; // Quick and dirty fix in case the function has been hooked WORD cw = 0; while (TRUE) { // check if syscall, in this case we are too far if (*((PBYTE)pFunctionAddress + cw) == 0x0f && *((PBYTE)pFunctionAddress + cw + 1) == 0x05) return FALSE; // check if ret, in this case we are also probaly too far if (*((PBYTE)pFunctionAddress + cw) == 0xc3) return FALSE; // First opcodes should be : // MOV R10, RCX // MOV RCX, <syscall> if (*((PBYTE)pFunctionAddress + cw) == 0x4c && *((PBYTE)pFunctionAddress + 1 + cw) == 0x8b && *((PBYTE)pFunctionAddress + 2 + cw) == 0xd1 && *((PBYTE)pFunctionAddress + 3 + cw) == 0xb8 && *((PBYTE)pFunctionAddress + 6 + cw) == 0x00 && *((PBYTE)pFunctionAddress + 7 + cw) == 0x00) { BYTE high = *((PBYTE)pFunctionAddress + 5 + cw); BYTE low = *((PBYTE)pFunctionAddress + 4 + cw); pVxTableEntry->wSystemCall = (high << 8) | low; break; } cw++; }; ``` 如果还是看不懂上面的代码,可以结合下图去理解  6、在主函数wmain()中这一系列的hash值传入和if判断就是为了确保能正确获取系统调用号,并存入\_VX\_TABLE\_ENTRY结构体,确保后续Payload()函数能正确执行。  7、最后就是直接调用syscall去执行函数   地狱之门有几个比较严重的缺陷: 1、如果系统中ntdll部分函数内存被HOOK,地狱之门就无法获取到需要调用的函数的正确系统调用号; 2、syscall是硬编码的,容易被检测。 虽然说地狱之门已经不适用于现在的免杀对抗了,但是它能在当时AV/EDR还在纠结检测特征码的时候出现绝对是开天辟地的,当时用地狱之门过那些什么EDR的检测可谓是屡试不爽,包括后来的什么TartarusGate、HaloGate都是在地狱之门HellsGate的基础上改进的,地狱之门也一定程度上提高了AV/EDR对恶意软件动态检测的能力。虽然它已经很久很久没更新了,地狱之门对于安全开发者或红队成员仍具有学习的价值。沉舟侧畔千帆过,病树前头万木春,可能没有地狱之门,就没有现在各种花哨的系统调用,所以我把它作为Windows系统调用"过去"的代表,它引出了系统调用免杀需要解决的问题。 HalosGate --------- 项目地址: <https://github.com/boku7/AsmHalosGate> HalosGate主要解决HellsGate地狱之门无法正确获取被hook函数系统调用号问题。大概的思路就是在原来需要调用的函数(被hook的函数)的位置向上或者向下n\*32字节的偏移去寻找一些不常用的没有被hook的函数,获取这个没有被hook函数的系统调用号再加上或者减去n,就得到了我们需要的被hook函数的系统调用号。 核心代码如下,这个项目已经不怎么好用了,大家知道有这么一个东西就行。  TartarusGate ------------ 项目地址: <https://github.com/trickster0/TartarusGate/> TartarusGate项目增强了地狱之门HellsGate对被hook函数的判断能力,通过检查函数地址开头是否存在0xe9(汇编代码jmp指令),x86下如果某个函数被hook,开头一般就是0xe9 + 一个四字节的地址。如果发现这个函数被hook,就像HalosGate那样向上或者向下寻找相邻函数,以同样的方式检测这个相邻函数有没有被hook,如果相邻的函数也被hook就继续检测相邻函数的相邻函数是否被hook。  对于syscall的特征检测,TartarusGate增加nop空指令混淆绕过检测  Unhook ntdll ------------ unhook ntdll的思路就是将磁盘中的ntdll.dll数据重新映射进内存,进行节表展开、修复重定位表、调整内存属性等操作(还不懂这些操作的可以看我之前反射dll的文章)后得到一份没有被hook的ntdll。unhook ntdll在早期是能解决系统调用号获取问题,参考项目: <https://github.com/mdsecactivebreach/ParallelSyscalls> unhook ntdll后我们也可以直接放弃使用syscall,就正常动态调用函数就行,因为重新映射进内存的ntdll.dll在ring 3已经脱离杀软的监控了,但是这仅对那些没有在ring 0做监控的辣鸡杀软有效。在加了驱动监控SSDT表的AV/EDR面前就是纯小丑,unhook ntdll在这种杀软面前不仅无效,反而会增大被检测的概率。 Windows系统调用的未来 ============== 下面我将介绍系统调用在对抗EDR检测方面至今仍在使用的一些技巧,其实现在公开的系统调用项目没有太多特别好用的,想要运用到实战项目上必须对这些项目进行二开或者微调,所以学习这些的项目中的技巧和思路是很有必要的。理解系统调用过程,明白系统调用需要解决的问题,像TartarusGate/HalosGate改进HellsGate那样用学到的规避syscall检测的技巧根据实战环境的杀软去修补和重组现有的系统调用项目来达到规避检测的目的,这个就是系统调用的未来的趋势。在现在的免杀对抗环境中,系统调用需要解决的问题在原来两个问题基础上再新增了一个: **3、检测调用函数的syscall是不是从ntdll.dll发起的** 我在"过去"的那一节开头就讲到一个正常从用户态发起的OpenProcess会经过Kernel32.dll->ntdll.dll->内核这么一个过程,这个过程在静态反编译以后就是一套类似模板的汇编代码序列,AV/EDR在静态检测到可执行文件中存在syscall关键字后,他会继续检测syscall的上下文是不是类似的"模板",这些"模板"与那些像HellsGate那样的突然莫名其妙地手搓一个syscall还是有很大区别的;除了静态检测之外,AV/EDR还会通过动态检测syscall完以后的回调RIP是不是指向ntdll、通过ETW(Event Tracing for Windows)检测线程堆栈是否异常等方式来辅助检测恶意的系统调用,bypass ETW也能一定程度上绕过AV/EDR对异常系统调用的检测。 egg hunters ----------- egg hunters(彩蛋猎人)大致思路是先替换syscall指令为其他的数据,在可执行文件加载进内存以后再将syscall指令还原,这个技巧在SysWhispers3项目上就有应用,我们可以从如下地址下载SysWhispers3: <https://github.com/klezVirus/SysWhispers3> 下载后执行如下命令查看SysWhispers3的egg hunters是怎么实现的 ```php python syswhispers.py -a x64 -c msvc -m egg_hunter -f NtOpenProcess -o openprocess_demo ```  执行后会生成三个文件  生成.asm 文件中就有生成的八字节的彩蛋{ 0x72, 0x0, 0x0, 0x76, 0x72, 0x0, 0x0, 0x76 }(对应的字节字符串"r00vr00v")  八字节彩蛋替换函数,在执行syscall执行之前会将八字节的彩蛋替换为syscall、nop等指令 ```php void FindAndReplace(unsigned char egg[], unsigned char replace[]) { ULONG64 startAddress = 0; // 主模块加载基址 ULONG64 size = 0; // 主模块映像大小 GetMainModuleInformation(&startAddress, &size); // 获取主模块信息,更新startAddress和size if (size <= 0) { printf("[-] Error detecting main module size"); exit(1); } ULONG64 currentOffset = 0; // 当前偏移 unsigned char* current = (unsigned char*)malloc(8 * sizeof(unsigned char*)); // 分配8字节空间 size_t nBytesRead; printf("Starting search from: 0x%llu\n", (ULONG64)startAddress + currentOffset); while (currentOffset < size - 8) // 循环搜索, currentOffset 的最大值为 size - 8 { currentOffset++; LPVOID currentAddress = (LPVOID)(startAddress + currentOffset); // 计算当前搜索地址 if (DEBUG > 0) { printf("Searching at 0x%llu\n", (ULONG64)currentAddress); } if (!ReadProcessMemory((HANDLE)((int)-1), currentAddress, current, 8, &nBytesRead)) { printf("[-] Error reading from memory\n"); exit(1); } if (nBytesRead != 8) { printf("[-] Error reading from memory\n"); continue; } if (DEBUG > 0) { // 调试输出当前读取的8字节 for (int i = 0; i < nBytesRead; i++) { printf("%02x ", current[i]); } printf("\n"); } if (memcmp(egg, current, 8) == 0) // 如果读取的8字节与egg匹配 { printf("Found at %llu\n", (ULONG64)currentAddress); // 替换为replace WriteProcessMemory((HANDLE)((int)-1), currentAddress, replace, 8, &nBytesRead); } } printf("Ended search at: 0x%llu\n", (ULONG64)startAddress + currentOffset); free(current); // 释放current分配的内存空间 } ``` 加载进内存以后八字节的彩蛋就会被替换为这几个字节: 0x0f, 0x05,(对应汇编指令syscall) 0x90, (对应汇编指令nop) 0x90,(对应汇编指令nop) 0xC3, (对应汇编指令ret) 0x90, (对应汇编指令nop) 0xCC, (对应汇编指令int3,断点调试) 0xCC(对应汇编指令int3,断点调试) egg hunters可以一定程度上绕过AV/EDR对syscall的静态检测,但是SysWhispers3的egg hunters并不是完美的: 1、SysWhispers的egg hunters是直接调用syscall,加载进内存替换彩蛋以后可能扛不住卡巴之类的EDR第一波内存扫描; 2、从上面egg hunters生成的.asm文件我们可以看到彩蛋是由为汇编DB伪指令定义的,DB伪指令无实际的操作码;而jmp、mov等本质上是CPU指令,这些指令有实际的操作码 ,比如"jmp \[某个四字节地址\]"汇编命令在内存中看到的就是"0xe9 + 四个字节地址",0xe9就是jmp指令的操作码;而DB汇编命令比如"DB 72h"在内存中看到就是"0x72",连续的DB命令在可读可执行的代码段是有一些突兀的,有些EDR经过AI大模型分析已经能识别这类彩蛋了。所以最理想的状态就是将部分DB命令也改成有实际操作码的辣鸡指令,这也意味着要扩大彩蛋的长度来进行更精确地识别并替换彩蛋,不然可能会彩蛋替换错误导致程序崩溃。当然我不是说SysWhispers3的egg hunters不能用了,只是之前遇到某个环境直接用SysWhispers3的egg hunters静态都过不了,改了生成后的.asm文件中部分DB指令和彩蛋替换函数就能用了。 参考文章: <https://fuzzysecurity.com/tutorials/expDev/4.html> 随机跳转间接系统调用 ---------- 最经典的就是SysWhispers3的随机跳转,大致实现思路就是先获取所有Zw开头的函数地址,将这些函数地址和对应的函数hash按获取的顺序排列存储进一个自定义的列表,然后随机获取ntdll.dll中干净的syscall指令的地址,构造系统调用号,最后再跳转回带有syscall指令的地址,实现间接调用syscall。 我们可以用SysWhispers3看下其jumper\_randomized随机跳转是如何实现的 ```php python syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtOpenProcess -o ./random_jump_demo/random_jump_demo ``` 1、首先看.asm文件,保存完现场以后先将需要syscall的函数hash作为参数传入SW3\_GetRandomSyscallAddress。  2、SW3\_GetRandomSyscallAddress首先执行SW3\_PopulateSyscallList方法,如果SW3\_PopulateSyscallList执行成功再遍历全局变量SW3\_SyscallList中每个函数的hash,并比较之前传入的需要进行syscall函数Hash与当前遍历到函数hash是否一致,一致即返回SW3\_SyscallList列表中该函数的地址。  3、SW3\_PopulateSyscallList的作用是初始化全局变量SW3\_SyscallList,这个过程有点类似HellsGate获取系统调用号的过程,不同的是这个过程只将所有0x775a(即Zw)开头的函数的hash和函数地址按遍历顺序存入SW3\_SyscallList而不直接进行获取系统调用号,后面再通过SW3\_GetSyscallNumber得到系统调用号。这个过程能较为正确地获取系统调用号,因为系统调用号实际是从0开始的,从第一个遍历到的Nt开头或Zw开头的函数开始排列,对应的函数顺序号就是系统调用号,这个GetSSN方法能解决HalosGate相邻的函数的相邻的函数的相邻的函数.....也被Hook的问题。 ```php BOOL SW3_PopulateSyscallList() { // Return early if the list is already populated. if (SW3_SyscallList.Count) return TRUE; #ifdef _WIN64 PSW3_PEB Peb = (PSW3_PEB)__readgsqword(0x60); #else PSW3_PEB Peb = (PSW3_PEB)__readfsdword(0x30); #endif PSW3_PEB_LDR_DATA Ldr = Peb->Ldr; PIMAGE_EXPORT_DIRECTORY ExportDirectory = NULL; PVOID DllBase = NULL; // Get the DllBase address of NTDLL.dll. NTDLL is not guaranteed to be the second // in the list, so it's safer to loop through the full list and find it. PSW3_LDR_DATA_TABLE_ENTRY LdrEntry; for (LdrEntry = (PSW3_LDR_DATA_TABLE_ENTRY)Ldr->Reserved2[1]; LdrEntry->DllBase != NULL; LdrEntry = (PSW3_LDR_DATA_TABLE_ENTRY)LdrEntry->Reserved1[0]) { DllBase = LdrEntry->DllBase; PIMAGE_DOS_HEADER DosHeader = (PIMAGE_DOS_HEADER)DllBase; PIMAGE_NT_HEADERS NtHeaders = SW3_RVA2VA(PIMAGE_NT_HEADERS, DllBase, DosHeader->e_lfanew); PIMAGE_DATA_DIRECTORY DataDirectory = (PIMAGE_DATA_DIRECTORY)NtHeaders->OptionalHeader.DataDirectory; DWORD VirtualAddress = DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress; if (VirtualAddress == 0) continue; ExportDirectory = (PIMAGE_EXPORT_DIRECTORY)SW3_RVA2VA(ULONG_PTR, DllBase, VirtualAddress); // If this is NTDLL.dll, exit loop. PCHAR DllName = SW3_RVA2VA(PCHAR, DllBase, ExportDirectory->Name); if ((*(ULONG*)DllName | 0x20202020) != 0x6c64746e) continue; if ((*(ULONG*)(DllName + 4) | 0x20202020) == 0x6c642e6c) break; } if (!ExportDirectory) return FALSE; DWORD NumberOfNames = ExportDirectory->NumberOfNames; PDWORD Functions = SW3_RVA2VA(PDWORD, DllBase, ExportDirectory->AddressOfFunctions); PDWORD Names = SW3_RVA2VA(PDWORD, DllBase, ExportDirectory->AddressOfNames); PWORD Ordinals = SW3_RVA2VA(PWORD, DllBase, ExportDirectory->AddressOfNameOrdinals); // Populate SW3_SyscallList with unsorted Zw* entries. DWORD i = 0; PSW3_SYSCALL_ENTRY Entries = SW3_SyscallList.Entries; do { PCHAR FunctionName = SW3_RVA2VA(PCHAR, DllBase, Names[NumberOfNames - 1]); // Is this a system call? if (*(USHORT*)FunctionName == 0x775a) { Entries[i].Hash = SW3_HashSyscall(FunctionName); Entries[i].Address = Functions[Ordinals[NumberOfNames - 1]]; Entries[i].SyscallAddress = SC_Address(SW3_RVA2VA(PVOID, DllBase, Entries[i].Address)); i++; if (i == SW3_MAX_ENTRIES) break; } } while (--NumberOfNames); // Save total number of system calls found. SW3_SyscallList.Count = i; // Sort the list by address in ascending order. for (DWORD i = 0; i < SW3_SyscallList.Count - 1; i++) { for (DWORD j = 0; j < SW3_SyscallList.Count - i - 1; j++) { if (Entries[j].Address > Entries[j + 1].Address) { // Swap entries. SW3_SYSCALL_ENTRY TempEntry; TempEntry.Hash = Entries[j].Hash; TempEntry.Address = Entries[j].Address; TempEntry.SyscallAddress = Entries[j].SyscallAddress; Entries[j].Hash = Entries[j + 1].Hash; Entries[j].Address = Entries[j + 1].Address; Entries[j].SyscallAddress = Entries[j + 1].SyscallAddress; Entries[j + 1].Hash = TempEntry.Hash; Entries[j + 1].Address = TempEntry.Address; Entries[j + 1].SyscallAddress = TempEntry.SyscallAddress; } } } return TRUE; } ``` 4、SW3\_GetRandomSyscallAddress的返回值(函数地址)根据x64的调用约定返回至rax寄存器,再将rax寄存器中的函数地址存到r11寄存器为后面间接syscall做铺垫。然后执行SW3\_GetSyscallNumber根据需要调用函数的Zw排列顺序转化为对应的SSN,SW3\_GetSyscallNumber的返回值(SSN)根据x64的调用约定返回至eax寄存器(相当于间接 mov eax,系统调用号)。 这时候你可能会问为什么这两个函数分别返回rax寄存器和eax寄存器?因为这两者的返回值类型不同,SW3\_GetRandomSyscallAddress的返回值PVOID是完整64位指针,写入rax寄存器;而SW3\_GetSyscallNumber的返回值DWORD返回值在现实中是小于32位的,写入rax寄存器的低32位(即eax寄存器),rax低32位写入后rax寄存器的高32位会自动清零。   5、还原现场后mov r10, rcx,结合上面SW3\_GetSyscallNumber的返回值(SSN)以及后面的jmp r11(r11存的是之前SW3\_GetRandomSyscallAddress返回的函数地址,里面有syscall指令),实现随机跳转间接调用syscall。  这部分跟下面syscall的经典格式是类似的 ```php 4C8BD1 -> mov r10, rcx B8XX000000 -> move eax,XX ;XX为系统调用号 0f05 -> syscall ``` SysWhispers3的jumper\_randomized间接调用syscall实现思路可以解决AV/EDR检测syscall是不是由ntdll发起的难题,因为syscall回调时的RIP指向ntdll。 VEH间接系统调用 --------- 参考项目 <https://github.com/Dec0ne/HWSyscalls> <https://github.com/coleak2021/vehsyscall> 强烈建议大家去认真看下HWSyscalls这个项目的代码,HWSyscalls的特点在于其利用了硬件断点和VEH异常处理,并且利用kernel32或kernelbase伪造栈空间作为代理跳板,这个项目比起SysWhispers3的随机跳转实现了更完美的间接系统调用。  1、先看InitHWSyscalls(),其首先会调用FindRetGadget()寻找"\\x48\\x83\\xC4\\x68\\xC3"(汇编指令:ADD RSP,68;RET )这几个字节查看kernel32或kernelbase是否存在可以伪造栈空间的点。然后在VEH链表注册HWSyscallExceptionHandler异常处理函数,最后设置硬件断点。  2、通过在线程上下文的Dr0寄存器设置触发硬件断点的函数地址PrepareSyscall(这个函数的传入参数是我们需要进行syscall的函数地址名称,返回值为函数地址),注册VEH后只要程序访问这个函数地址,就会触发系统异常,最终会交给之前在VEH链表注册的HWSyscallExceptionHandler异常处理函数去处理异常(但实际不是去处理异常,而是进行间接系统调用)。  3、再来看核心函数HWSyscallExceptionHandler是怎么构造syscall的经典格式。  4、系统调用号获取参考了HalosGate,就是寻找隔壁的隔壁的没有被Hook的函数。  5、syscall指令返回地址也是通过逐字节寻找连续的0x0f和 0x05(汇编指令syscall;ret;)定位的。  尽管HWSyscalls还有很多可以优化的地方,但是不影响它是个非常优秀的项目。它硬件断点和VEH注册函数那里还可以做一个反调试,防止被人工或者机器学习分析;系统调用号获取再优化一下,在实战中是简直不要太好的一个项目。而且它的使用方法也极其简单,直接把HWSyscalls.h和HWSyscalls.cpp文件加入到项目,直接调用PrepareSyscall就可以实现间接系统调用,非常适合我们这些脚本小子。 总结 == 如果你是刚刚接触系统调用的小白,请你一定要把HellsGate这个项目从头到尾的理解一遍,其他系统调用项目会用就行。你可以看到我狂吹的HWSyscalls除了硬件断点和VEH的创新外,其他基本上都有HellsGate这位前辈和它后辈的影子。系统调用不管过去和未来,始终在解决上面提到的那三个问题,系统调用号动态获取,间接调用syscall和回调时的指令指针指向ntdll等。或许再过个一两年HWSyscalls就像HellsGate那样变成了旧时代的残骸,但只要你能在这些旧项目的基础上去优化用户态转变内核态的过程并解决上面提到的那三个问题,就永远有载你绕过新版AV/EDR的船。
发表于 2025-09-05 09:00:00
阅读 ( 211 )
分类:
渗透测试
0 推荐
收藏
0 条评论
请先
登录
后评论
r0leG3n7
2 篇文章
×
发送私信
请先
登录
后发送私信
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!