Win11 26H1内核异常体系分析与实验:提前接管用户态、内核态异常,实现无痕Hook、进程保护、反调试,附PoC

1. 写在前面

​ 大家好,我是__Shinn,凌晨码字,精神有点迷糊,文章中如果存在表述不严谨、甚至出错的地方,欢迎在评论区指正

​ 本文定位为抛砖引玉,分享一些实验性质的思路、代码实现方法,本文在结构上大体分为了以下2部分

笔者最初原稿是4种提前接管异常的手法,后来发现,利用InstrumentationCallback提前接管异常,论坛内已经有师傅发文公开了,所以发文前删除了这部分内容,避免重复赘述

​ 本文涉及到的大部分工程文件已经整理好并开源到Github,欢迎给仓库Star

文章目录速览

  1. 开篇碎碎念
  2. Win11 26H1内核异常体系分析 2.1 内核页表隔离(KVA Shadow) 2.1.1 2套CR3引入的效率问题 2.2 KiDivideErrorFaultShadow 2.3 KiDivideErrorFault 2.3.1 R0异常派发路径 2.3.2 R3异常路径 2.4 KiExceptionDispatch 2.5 KiDispatchException 2.5.1 上下文转换(构造Context) 2.5.2 内部错误码转换 2.5.3 R0异常路径 2.5.4 R3异常路径(FirstChance路径) 2.5.5 R3异常路径(SecondChance路径) 2.5.6 R3异常路径小结 2.6 Win11 26H1异常体系总结
  3. 提前接管用户态异常方法1 3.1 ntdll!KiUserExceptionDispatcher 3.2 ntdll!Wow64PrepareForException
  4. 无痕Hook代码实验 4.1 制造影子模块(ShadowModule) 4.2 向目标函数页面注入异常(隐藏不可执行页面) 4.3 修复控制流防护问题(CFG) 4.4 修复全局变量不一致 4.5 给影子模块挂Inline Hook
  5. 提前接管用户态异常方法2 5.1 nt!PsPicoDispatchException 5.2 分析PicoProvider注册流程 5.3 注册成为PicoProvider 5.4 将目标进程设置为WSL子进程 5.5 利用Pico异常处理回调实现无痕Hook 5.6 利用Pico异常回调实现反调试效果 5.7 WSL子进程进程保护
  6. 提前接管用户态、内核态异常方法 6.1 Win11 26H1 6.2 Win11 25H2及以下 6.2.1 特征码定位HalpPerformanceCounter 6.2.2 替换Hal函数指针 6.2.3 堆栈回溯筛选出异常派发调用 6.2.4 从堆栈中恢复丢失的异常信息 6.2.5 构造中断返回帧,恢复异常派发流程 6.2.6 大量踩坑
  7. Git仓库开源地址

2. Win11 26H1内核异常体系分析

​ 本次分析的目标系统版本为Win11 26H1 28000,如图所示:

图片描述 ​ 首先我们可以主动制造一个除零异常,如下所示:

volatile int Divisor = 0;
volatile int Result = 1 / Divisor;

​ 当CPU检测到除零异常后会抛出#DE。由于#DE的异常向量号为0,所以CPU会找到IDT表中第0项中断门描述符,从中解析出异常处理回调的函数入口,将执行流转移到该入口。使用WinDbg调试如下所示:

图片描述

​ 由于在虚拟机中调试,并没启用内核页表隔离,所以这里的异常处理例程为KiDivideErrorFault,正常用户的PC开启该机制后,CPU抛出#DE异常实际执行的是KiDivideErrorFaultShadow

2.1 内核页表隔离(KVA Shadow)

  1. 内核页表隔离并不是Win下特有的,在Linux下该技术叫做KPTI

  2. 在早期的Windows、Linux中,用户态和内核态会共用同一份页表基址(Cr3)。当前Cr3除了映射用户地址空间,还会完整映射内核地址空间。由于现代CPU存在预测执行机制,以 2018 年公开的 Meltdown(CVE-2017-5754)为例,攻击者可以构造一段用户态代码,使CPU在权限检查完成前短暂读取内核地址中的数据,虽然这次读取最终会被判定为非法并回滚,但是CPU回滚状态时并不会清空缓存内容,通过测信道攻击后,R3可以读取到R0的数据

​ Win在开启内核页表隔离后,一个进程其实有2套页目录表基址(Cr3),平时R3程序在运行时使用的是一份残缺的页表,这份页表会映射完整的用户态内容,由于异常、中断、系统调用等操作会进入到内核,必须在内核设置一个落脚点,所以还会映射一小部分的内核态代码,这部分的代码保存在KVASCODE节区中。真正进入内核后,再切换到包含完整内核地址空间的页表。

​ 在KPROCESS结构中,其实有着2个Cr3的保存点,如下所示:

struct _KPROCESS
{
    struct _DISPATCHER_HEADER Header;                                       //0x0
    struct _LIST_ENTRY ProfileListHead;                                     //0x18
    ULONGLONG DirectoryTableBase;                                           //0x28  // 内核态CR3
    // ...
    ULONGLONG UserDirectoryTableBase;                                       //0x280 // 用户态CR3
    // ...
}; 

2.1.1 2套CR3引入的效率问题

​ 我们知道不同的Cr3切换时,会清空TLB缓存(当然,PTE.G = 1的全局PTE会保留),那么在启用了KVA Shadow后,一个进程有着2套CR3,这样是不是也会导致切Cr3清空TLB?

所以CPU厂商在这里引入了优化:CR4.PCIDE = 1启用进程识别标识符后,此时CR3的Bit0~11位会保存一个标识符,通过这个标识符可以告诉CPU这是同一个进程下不同的CR3在切换,此时就保留TLB缓存

2.2 KiDivideErrorFaultShadow

​ KiDivideErrorFaultShadow这个函数本身只是一个桩函数(过渡用,本身不处理异常),代码保存在KVASCODE节区中,首先判断判断CS.RPL,区分本次异常的来源是R3还是R0,如果是来自R0的异常,直接跳转到KiDivideErrorFault处理该异常。如果是来自R3的异常,会做以下事情:

图片描述

2.3 KiDivideErrorFault

​ KiDivideErrorFault函数开头其实是在堆栈中构造TrapFrame对象,由于#DE 不压入硬件错误码,所以函数入口先保留了8字节的槽位,再保存Rcx Rdx R8 R9等等易失寄存器,因为这些寄存器可能包含被中断代码正在使用的临时值,如图所示:

图片描述

2.3.1 R0异常派发路径

​ 如果时来自内核的#DE异常,处理流程会简短很多,首先读取CET影子栈指针并将其保存到TrapFrame中

图片描述

​ 这里涉及到的CET机制,笔者比较通俗的理解如下:

  1. 某些攻击手法是想办法让栈溢出,覆盖返回地址,做到劫持执行流,跳转执行自己的代码
  2. 在开启了CET机制后,在call xxx调用函数时,CPU会同时将返回地址保存到普通栈、影子栈中,在调用ret返回时,CPU同时取出2个栈中的返回地址,此时做校验,如果不一致就抛出异常
  3. 普通栈、影子栈是相互独立的,很难做到同时攻击2个栈中的返回地址

​ 随后执行lfence入口屏障,限制CPU预测执行不能超过这个位置,根据每 CPU 的预测执行状态更新Msr寄存器

图片描述

​ 保存SIMD浮点现场、保存XMM0~XMM5等易失寄存器现场

图片描述

​ 由于CPU执行中断门时,会自动清除RFlags.IF,这里判断异常发生时CPU的中断响应状态,并根据先前状态重新设置中断状态

图片描述

​ 构造内部异常错误码10000003h、异常发生时的线性地址,调用KiExceptionDispatch公共异常分发入口进行异常派发

// 根据x64调用约定,伪代码如下
KiExceptionDispatch(
    0x10000003,
    0,
    ExceptionAddress
);

图片描述

2.3.2 R3异常路径

​ 如果时来自R3的#DE错误,首先根据KVA Shadow状态选择是否切换GS

图片描述

​ 在KiDivideErrorFaultShadow中已经将内核过渡栈切换到了内核普通栈,但是由于CET保护机制,还需要将过渡影子栈切换到内核影子栈

图片描述

​ 继续往下看汇编,会发现有着大量、重复的call xxx、add rsp, 8组合,这样的组合有32组,表面看似无意义,如图所示:

图片描述

​ 这里的汇编涉及到CPU中的RSB机制,笔者对此理解如下:

  1. RSB叫做Return Stack Buffer,可以理解为CPU内部专门预测ret返回地址的小型硬件栈

  2. 每次call xxx时,会将返回地址同时压入普通栈、RSB硬件栈,当CPU执行到ret返回时,CPU会提前读取RSB中的返回地址,提前取指令、译码,减少流水线执行的等待空窗,提高CPU执行效率

​ 结合本文的异常处理,由于本次异常(R3异常路径)是在R3程序中出现的,就导致RSB中可能已经存储了大量R3的返回地址,那么CPU就有可能拿着R3返回地址进行错误的预测执行,这是非常危险的。

​ 写到这里,这些大量调用的call xxx的作用就非常明显了,其作用如下:

// 1. 将返回地址压入普通栈
// 2. 将预测返回地址压入CPU的RSB(Return stack buffer)
call xxx

// 丢弃普通栈上的返回地址,但是保留RSB中的返回地址
add rsp, 8

​ 如此循环32次后,RSB中会被覆盖为内核态的返回地址,这个过程叫做RSB stuffing。同时由于CET影子栈保护机制的存在,如此循环执行32次call xxx,会导致Shadow Stack 也会保存32条内核态返回地址,所以还需要同步影子栈,如图所示:

图片描述

​ 随后判断当前进程是否处于调试状态,如果是则保存调试寄存器:

图片描述

​ 之后的代码其实算是R3、R0的公共代码了,都是保存MxCsr、XMM0~XMM5、尝试重新启用中断、调用KiExceptionDispatch派发异常,如下所示:

图片描述

2.4 KiExceptionDispatch

​ 函数入口处抬升堆栈,在栈中构造ExceptionRecord、ExceptionFrame对象,将XMM6~XMM15、rbx、rdi、rsi、r12~r15等非易失寄存器保存到KEXCEPTION_FRAME中:

图片描述

​ 继续构造ExceptionRecord对象,填充异常错误码0x10000003、异常地址ExceptionAddress等等:

图片描述

​ 构造好ExceptionRecord、ExceptionFrame对象后,构造参数调用KiDispatchException继续派发异常:

图片描述

​ 在KiDispatchException返回后,此时会有一次派发用户Apc回调的机会:

图片描述

2.5 KiDispatchException

​ KiDivideErrorFault、KiExceptionDispatch等函数,主要功能是完成异常的记录,也有朋友称为异常登记,指的是同一件事。KiDispatchException函数负责异常的派发,在函数开头,会将增加异常派发计数:

图片描述

2.5.1 上下文转换(构造Context)

​ 在当前函数中,有着TrapFrame对象,其中保存着易失寄存器环境。还有着ExceptionFrame对象,其中保存着非易失寄存器环境。通过两者结合,就可以还原出异常发生时完整的Context上下文,有过异常开发、异常处理相关经验的朋友对这个对象肯定不陌生

​ 转换的过程为:获取Context对象大小、初始化Context对象、将TrapFrame和ExceptionFrame转为Context

图片描述

2.5.2 内部错误码转换

​ 通过前文我们可以知道,#DE除零错误的内部错误码为0x10000003,会调用KiPreprocessFault将内部错误码转为熟知的C0000094,代码如下:

图片描述

2.5.3 R0异常路径

​ 首先来分析R0发生#DE异常时的处理路径:会判断FirstChance是否成立,如果成立,就尝试先将本次异常派发给内核调试器`

​ 这里存在一个内核全局变量KdpDebugRoutineSelect,由这个全局变量控制调用KdTrap还是KdStub,这是一个伏笔,后续章节会涉及到该全局变量

图片描述

​ 不管是调用KdTrap还是KdStub,返回TRUE则代表内核调试器顺利处理了该异常,会将Context恢复到TrapFrame中,之后KiDispatchException函数正常返回

图片描述

​ 如果内核调试器没有处理异常,KdTrap、KdStub就会返回FALSE,会调用RtlDispatchException函数,将异常派发到内核SEH,尝试让程序自己来处理异常

图片描述

​ 如果程序的SEH Handler顺利处理了该异常,RtlDispatchException函数返回TRUE,会将Context恢复到TrapFrame中,之后KiDispatchException函数正常返回

图片描述

​ RtlDispatchException函数返回FALSE,说明内核SEH没有处理该(程序没有注册SEH、或者SEH没处理)异常,此时就会进入到内核异常的二次派发,当然,笔者称其为二次派发可能不太严谨,因为KiDispatchEXception函数本身并没有二次调用

​ 内核异常的二次派发其实就是再次将异常派发给内核调试器,如果这次内核调试还不处理异常,则调用KeBugCHeckEx触发蓝屏

图片描述

​ 内核异常的派发,笔者画了一个粗糙的流程图:

图片描述

2.5.4 R3异常路径(FirstChance路径)

​ 我们首先来看一下如果#DE异常来自用户态,并且为首次派发的情况。

​ 会检测内核全局变量KdIgnoreUmExceptions的状态,如果为FALSE,那么会将该用户态异常派发到内核调试器,如果内核调试器顺利处理了该异常,会将Context恢复到TrapFrame中,之后KiDispatchException函数正常返回

图片描述

​ 如果内核调试器没有处理该异常,则调用DbgkForwardException将异常派发给用户态调试器,即Windbg、x64Dbg等常用调试器,该函数返回TRUE,说明R3调试器处理了异常,KiDispatchException正常返回,本次异常派发流程结束

图片描述

​ 如果该3环程序没有挂载调试器、或者调试器选择不处理该异常,DbgkForwardException会返回FALSE,此时轮到用户态的VEH、SEH处理异常了,但是这些异常处理回调处于R3用户态,必须退回到R3环境才能执行这些回调,所以我们需要构造返回R3的用户栈,该过程跟用户Apc回调非常相似

​ 要安全地访问用户态内存,要使用ProbeForWrite来探测用户态内存,之后在用户栈中填充ExceptionRecord、Context

图片描述

图片描述

图片描述

​ 退回到用户态的栈环境布置好了,接下来就修改TrapFrame中的Rsp、Rip,这里的Rip其实就是用户态ntdll!KiUserExceptionDispatcher函数,该函数是用户态异常异常分发的落脚点,所以很多游戏安全厂商喜欢Hook这个点,可以最早监控到用户态异常的动态

​ 图片描述

​ 完成以上步骤后,KiDispatchException正常返回,结束FirstChance异常派发,当退回到用户态继续执行时,用户态代码如图所示(ntdll!KiUserExceptionDispatcher),内部主要做了以下几件事:

图片描述

​ 由于本文主要是针对Win11内核异常体系的分析,用户态部分碍于篇幅,在此就不详细展开分析了

2.5.5 R3异常路径(SecondChance路径)

​ 上一小节提到了,如果用户态异常也没处理好异常,会调用ZwRaiseException模拟产生异常异常派发,这就是用户态异常的二次派发,我们的视角继续回到KiDispatchException函数

​ 用户异常的二次派发就简单多了,尝试将异常派发给用户调试器、异常端口,如果都没处理,最后结束当前进程

图片描述

2.5.6 R3异常路径小结

​ 各位朋友看到这里应该发现了,用户态的异常派发相比内核态异常过程要麻烦得多,因为用户态异常的处理中间还涉及到构建用户栈、回退用户态、再次派发异常进入内核态,笔者当年首次分析Windows这套异常体系时,就被迷惑了好一段时间,这里笔者画了一张粗糙的流程图,或许可以帮助初学的朋友理清流程:

图片描述

2.6 Win11 26H1异常体系总结

​ 从整个异常处理的流程来看,大体上还是遵循着异常记录、异常派发、异常处理这三步骤进行:保存被打断的执行现场,将 CPU 硬件事件转换为统一的 Windows 异常对象,再根据异常来源和处理机会逐层派发,最后使用修改后的上下文恢复执行,或者终止无法恢复的执行主体。

​ KVA Shadow、CET、RSB等机制相关的代码,更多的是出于安全考虑,并不等同于异常处理逻辑本身

3. 提前接管用户态异常方法1

​ 经过本文上半部分的铺垫,相信各位朋友对于Windows异常体系已经有了初步的认识,接下来笔者将会分析有哪些可以利用的点。

3.1 ntdll!KiUserExceptionDispatcher

​ 首先让让我回顾一下,已知内核态退回到用户态让VEH、SEH有机会处理异常时,回去的落脚点为KiUserExceptionDispatcher函数,该函数完整反汇编如下:

图片描述

​ 我们注意看函数的开头部分,首先判断一个全局变量Wow64PrepareForException是否为空,如果不为空则提前调用该回调,该回调的调用时机是明显早于RtlDispatchException的,即异常处理的优先级高于所有的用户VEH、SEH异常处理的

图片描述

​ 我们使用x64Dbg附加一个64位的程序调试一下,会发现大部分的应用程序Wow64PrepareForException该变量其实是空的

图片描述

​ 那么这个点我们可以利用起来,手动将我们自己的一个回调"注册"进去,这样每次自己的异常回调不就早于所有的VEH、SEH提前处理异常了?

3.2 ntdll!Wow64PrepareForException

​ 现在方向非常明确了,我们要找到ntdll!Wow64PrepareForException这个全局变量,将我们的回调函数写进该变量中,那么如何定位到该变量呢?

​ 笔者最早想到的是直接特征码硬搜呗,但是检查了一下发现,ntdll!KiUserExceptionDispatcher这个函数其实是导出的,那就很方便了

图片描述

​ 所有我们的注册异常回调函数,部分代码如下如下:

BOOLEAN
InstallExceptionHook(
    _In_ fn_ExceptionCallback ExceptionCallback
)
{
    BOOLEAN Ret = FALSE;
    do
    {
        if (nullptr == ExceptionCallback)
        {
            break;
        }

        HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
        if (nullptr == hNtdll)
        {
            break;
        }

        _NtContinue = (fn_NtContinue)GetProcAddress(hNtdll, "NtContinue");
        if (nullptr == _NtContinue)
        {
            break;
        }

        PVOID pKiUserExcpetionDispatch = GetProcAddress(hNtdll, "KiUserExceptionDispatcher");
        if (nullptr == pKiUserExcpetionDispatch)
        {
            break;
        }

        BOOLEAN bIsFound = FALSE;
        PUCHAR pByetCode = (PUCHAR)pKiUserExcpetionDispatch;
        while (TRUE)
        {
            if (pByetCode[0] == 0xC3 && pByetCode[1] == 0xCC)
            {
                break;
            }

            if (pByetCode[0] == 0x48 && pByetCode[1] == 0x8B && pByetCode[2] == 0x05)
            {
                bIsFound = TRUE;
                break;
            }

            pByetCode++;
        }

        if (!bIsFound)
        {
            break;
        }

        _ExceptionCallback = ExceptionCallback;

        LONG Offset = *(LONG*)(pByetCode + 3);
        pWow64PrepareForException = (fn_ExceptionCallback*)(pByetCode + 7 + Offset);

        DWORD OldProtrect = 0;
        VirtualProtect(
            (PVOID)PAGE_ALIGN(pWow64PrepareForException),
            USN_PAGE_SIZE, 
            PAGE_READWRITE,
            &OldProtrect
        );

        Old_ExceptionCallback =
            (fn_ExceptionCallback)_InterlockedExchangePointer((volatile PVOID*)pWow64PrepareForException, Wow64PrepareForException_Hook);

        VirtualProtect(
            (PVOID)PAGE_ALIGN(pWow64PrepareForException), 
            USN_PAGE_SIZE,
            OldProtrect, 
            &OldProtrect
        );

        Ret = TRUE;
    } while (FALSE);

    return Ret;
}

​ 到了这里我们注册好了异常处理回调,那么这个回调本身笔者的写法如下所示:

VOID
Wow64PrepareForException_Hook(
    _In_ PEXCEPTION_RECORD ExceptionRecord,
    _In_ PCONTEXT ContextRecord
)
{
    if (_ExceptionCallback(ExceptionRecord, ContextRecord))
    {
        _NtContinue(ContextRecord, FALSE);
    }
    else
    {
        if (nullptr != Old_ExceptionCallback)
        {
            Old_ExceptionCallback(ExceptionRecord, ContextRecord);
        }
    }
    return VOID();
}

​ 测试代码如下所示,自定义异常处理器确实早于VEH、SEH接管了异常处理:

图片描述

4. 无痕Hook代码实验(无痕Hook发包函数为例)

​ 现在有了一个可以最早接管用户态异常的手法,笔者在想是不是应该做一个有意思的实验来证明其价值。所以就利用这个手法实现一个无痕Hook实验吧

​ 当然,这里的无痕Hook并不是绝对意义上的无痕,指的是我们不会破坏目标代码处的任何机器码,让常规的代码CRC校验失效

​ 笔者这里挑选的目标函数,是网络通讯中最常用的send函数

​ 笔者的实验思路、粗糙画图如下:

图片描述

4.1 制造影子模块(ShadowModule)

​ 制造影子模块其实很简单,传入我们要Hook的函数,这里以send发包函数为例,找到其所在模块基址、解析PE头得到模块大小,再完完整整构造一份ShadowModule,部分关键代码如下所示:

PVOID
BuildShadowModule(
    _In_ PVOID pFuncAddr
)
{
    PVOID ShadowFuncAddr = nullptr;

    do
    {
        if (nullptr == pFuncAddr)
        {
            break;
        }

        PVOID ImageBase = nullptr;
        if (!RtlPcToFileHeader(pFuncAddr, &ImageBase))
        {
            break;
        }

        ULONG64 FuncOffset = (ULONG64)pFuncAddr - (ULONG64)ImageBase;

        SHADOW_MODULE_INFO* pExisting = FindModuleByBase(ImageBase);
        if (nullptr != pExisting)
        {
            ShadowFuncAddr = (PVOID)((ULONG64)pExisting->ShadowImageBase + FuncOffset);
            break;
        }

        PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)ImageBase;
        PIMAGE_NT_HEADERS64 pNtHeaders = (PIMAGE_NT_HEADERS64)((PUCHAR)pDosHeader + pDosHeader->e_lfanew);
        ULONG SizeOfImage = pNtHeaders->OptionalHeader.SizeOfImage;

        PVOID ShadowModule = VirtualAlloc(
            NULL,
            SizeOfImage,
            MEM_COMMIT,
            PAGE_EXECUTE_READWRITE
        );

        if (nullptr == ShadowModule)
        {
            break;
        }

        ULONG PageNum = SizeOfImage >> 12;
        BOOLEAN CopySuccess = TRUE;
        for (ULONG i = 0; i < PageNum; i++)
        {
            ULONG CurOffset = i * USN_PAGE_SIZE;

            BOOL ReadRet = ReadProcessMemory(
                GetCurrentProcess(),
                (PUCHAR)ImageBase + CurOffset,
                (PUCHAR)ShadowModule + CurOffset,
                USN_PAGE_SIZE,
                NULL
            );

            if (FALSE == ReadRet)
            {
                CopySuccess = FALSE;
                VirtualFree(ShadowModule, 0, MEM_RELEASE);
                break;
            }
        }

        if (!CopySuccess)
        {
            break;
        }

        SHADOW_MODULE_INFO ShadowInfo = { 0 };
        ShadowInfo.OriginalImageBase = ImageBase;
        ShadowInfo.ShadowImageBase = ShadowModule;
        ShadowInfo.ImageSize = SizeOfImage;

        PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNtHeaders);
        for (WORD i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
        {
            if (memcmp(pSectionHeader[i].Name, ".data", 5) == 0)
            {
                ShadowInfo.OriginalDataSectionAddr = (PVOID)((ULONG64)ImageBase + pSectionHeader[i].VirtualAddress);
                ShadowInfo.ShadowSectionDataAddr = (PVOID)((ULONG64)ShadowModule + pSectionHeader[i].VirtualAddress);
                ShadowInfo.DataSectionSize = pSectionHeader[i].Misc.VirtualSize;

                break;
            }
        }

        g_ShadowModules.push_back(ShadowInfo);

        ShadowFuncAddr = (PVOID)((ULONG64)ShadowModule + FuncOffset);

    } while (FALSE);

    return ShadowFuncAddr;
}

4.2 向目标函数页面注入异常(隐藏不可执行页面)

​ 在构造好影子模块后,就要想办法让send函数所在的页面注入页面异常了,很常见的方式是将目标函数所在页面使用VirtualProtect,修改页面属性为以下几种:

这种都是非常常见的应用层套路,单论效果来说,确实可以达到注入页面异常的效果,但是这样痕迹太明显了,安全程序扫描时直接调用VirtualQuery就能查询目标页面的内存保护属性,很轻易就能发现页面保护属性被修改了

​ 所以我们目前的发现,就是要隐藏目标页面的不可执行属性,该如何实现呢,这里笔者想到了DEP数据执行保护机制,简单讲解一下这是什么(很口语化的讲解,不保证严谨):

在很早期的Win系统时期,当时的应用程序虽然在设计上区分出来代码段、数据段(堆栈区),但是这仅仅是逻辑上的划分,数据段(堆栈区)其实也是可以运行代码的

笔者这里举一个非常经典的栈溢出攻击例子:调用recv接收网络数据包时,攻击者可以精心构造一个数据包,其中其实是一段shellcode,当数据包接收后,会栈溢出覆盖栈上的RetAddress,这样函数返回时就会跑去执行攻击者的shellcode,做到劫持执行流,可以给目标PC植入木马

所以后来Win系统跟CPU厂商合计了一下发现,嘶~这是个大问题啊,得想办法修好这个问题。所以就设计出了DEP数据执行保护机制,通过硬件层面的强控制,来保证数据区(堆栈区)不能执行代码

​ DEP数据执行保护在硬件层面设计上,是将PTE的最高位保留为Nx不可执行属性位,由于Win的段页模式存在2MB大页面的情况,所以PDE的最高位也保留为了Nx不可执行属性位。

​ 当PTE.Nx = 1后,该PTE所控制的4kb页面就是不可执行代码的。当PDE.Nx = 1后,该PDE所控制的2MB大页面都是不可执行代码的

​ 设置目标页面代码不可执行,关键代码如下所示:

typedef struct HARDWAREPTE_X64
{
    ULONG64 valid : 1;               //!< [0]
    ULONG64 write : 1;               //!< [1]
    ULONG64 owner : 1;               //!< [2]
    ULONG64 write_through : 1;       //!< [3]
    ULONG64 cache_disable : 1;       //!< [4]
    ULONG64 accessed : 1;            //!< [5]
    ULONG64 dirty : 1;               //!< [6]
    ULONG64 large_page : 1;          //!< [7]
    ULONG64 global : 1;              //!< [8]
    ULONG64 copy_on_write : 1;       //!< [9]
    ULONG64 prototype : 1;           //!< [10]
    ULONG64 reserved0 : 1;           //!< [11]
    ULONG64 page_frame_number : 36;  //!< [12:47]
    ULONG64 reserved1 : 4;           //!< [48:51]
    ULONG64 software_ws_index : 11;  //!< [52:62]
    ULONG64 no_execute : 1;          //!< [63]
}HARDWAREPTE, *PHARDWAREPTE;

BOOLEAN 
SetPageNonExecutable(
    ULONG64 VirtualAddress,
    ULONG Size
)
{
    ULONG64 EndAddress = (ULONG64)PAGE_ALIGN(VirtualAddress + Size);
    ULONG64 StartAddress = (ULONG64)PAGE_ALIGN(VirtualAddress);

    while (EndAddress >= StartAddress)
    {
        HARDWAREPTE* Pde = (HARDWAREPTE*)GetPde(StartAddress);
        if (MmIsAddressValid(Pde) && Pde->valid && !Pde->large_page)
        {
            HARDWAREPTE* Pte = (HARDWAREPTE*)GetPte(StartAddress);
            if (MmIsAddressValid(Pte) && Pte->valid)
            {
                Pte->no_execute = 1;
            }

        }

        StartAddress += PAGE_SIZE;
    }

    return TRUE;
}

​ 我们设置硬件PTE来隐藏不可执行页面,效果如下所示,虽然内存监控工具CE表面上显示该页面保护属性为PAGE_EXECUTE_READ可执行可读,但是实际上该页面已经被注入了页面执行异常

图片描述

4.3 修复控制流防护问题(CFG)

​ 我们构造好了ShadowModule、目标函数页面注入了执行异常,那么我们要着手写异常处理回调中的逻辑了,关键代码如下所示:

BOOLEAN
ExceptionHandler(
    _In_ PEXCEPTION_RECORD ExceptionRecord,
    _In_ PCONTEXT ContextRecord
)
{
    if (STATUS_ACCESS_VIOLATION == ExceptionRecord->ExceptionCode)
    {
        ShadowModule::SHADOW_MODULE_INFO ShadowInfo = { 0 };
        if (!ShadowModule::QueryByExceptionAddress(ExceptionRecord->ExceptionAddress, &ShadowInfo))
        {
            return FALSE;
        }

        ULONG64 FuncOffset = (ULONG64)ExceptionRecord->ExceptionAddress - (ULONG64)ShadowInfo.OriginalImageBase;
        ContextRecord->Rip = (ULONG64)ShadowInfo.ShadowImageBase + FuncOffset;

        return TRUE;
    }

    return FALSE;
}

​ 我们现在编译出exe、drv进行实验,放到Win11 26H1中进行实验,打开程序结果直接崩溃了,好吧果然不能一次过

​ 我们挂上x64Dbg调试器看看是啥情况,笔者第一次看到这个反汇编窗口,懵了一下,代码怎么跑飞到这种地方来了,没招了分析吧

  1. 当前崩溃的ExceptionAddress = 12B701980C0,这个rip地址,看起来像是是属于影子模块ShadowModule的范围内

图片描述

  1. 我们看一下栈顶的返回地址000000242BFFF5D8,跳到其反汇编窗口看看,如下图所示
  2. 问题点很清晰了,十有八九是call 12B6FDEC010出了问题

图片描述

  1. 我们跟进这个call看看,如图所示:
  2. 好吧,果然是jmp 12B701980C0跳转到了异常的代码段,由于这里的相对跳转E9 xxxx,很明显这里的相对偏移是跳转不到正确代码的,这下跟步骤1的现象吻合了

图片描述

  1. 既然现在发现了问题的来源,那么jmp 12B701980C0这个跳转是啥?我们可以用IDA反汇编ws2_32.dll,看看原始模块的代码是什么,如图所示

图片描述

  1. 到了这里,各位读者应该就豁然开朗了,原来是CFG控制流防护相关的代码!

​ 既然定位到了问题的根源,要解决该问题就轻松多了,笔者的解决思路如下:

  1. 众所周知,CFG控制流防护的_guard_dispatch_icall,会将真实调用的函数地址放在rax寄存器中
  2. 那直接动态Patch一下影子模块中的_guard_dispatch_icall,将其修改为直接jmp rax不就解决问题了?

​ 修复CFG控制流防护问题的关键代码如下:

BOOLEAN 
FixCFG_ws2_32(
    _In_ ULONG64 ModuleAddress
)
{
    const auto ResolveCallRel32 = [](ULONG64 callAddress) -> ULONG64
    {
        if (!callAddress || *reinterpret_cast<BYTE*>(callAddress) != 0xE8)
        {
            return NULL;
        }

        const auto relativeOffset = *reinterpret_cast<std::int32_t*>(callAddress + 1);
        return callAddress + 5 + relativeOffset;
    };

    SearchCode::cSearchCode CodeSearcher;
    ULONG64 CodeAddr = CodeSearcher.SearchModule(
        ModuleAddress,
        "48 8D 54 24 ?? 4C 89 7C 24 ?? 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 89 44 24 ?? 85 C0 0F 84 ?? ?? ?? ?? EB ??",
        PAGE_EXECUTE_READWRITE
    );
    if (NULL == CodeAddr)
    {
        return FALSE;
    }

    ULONG64 ResolveAddr = ResolveCallRel32(CodeAddr + 15);
    if (NULL == ResolveAddr)
    {
        return FALSE;
    }

    // Jmp rax
    *(USHORT*)((PUCHAR)ResolveAddr) = 0xE0FF;

    return TRUE;
}

4.4 修复全局变量不一致

​ 在修复了CFG控制流防护问题后,再次运行程序,这个崩溃果然解决了

​ 但是随之而来程序又崩溃了,额,挂上x64Dbg调试器再看看,如图所示,笔者分析过程如下:

  1. rdx寄存器为空,很明显访问了空指针
  2. 而rdx寄存器是来源于全局变量000002A93348AB28解引用
  3. 在内存区域查看全局变量000002A93348AB28,果然是空的

图片描述

  1. 分析到了这里,问题的根源就很清晰了,就是由于源模块、影子模块之间,虽然代码区域是一模一样的,但是由于全局变量本质上是独立的,导致劫持执行流后,在影子模块中访问一些全局变量时,出现了上下文不一致的缺陷
  2. 要解决这个问题,笔者的思路就是想办法让原始模块、影子模块的全局变量区域内存变为共享的,即虽然用户态线性地址不一样,但是最终指向的物理页面是同一份

笔者首先想到了修改PTE.page_frame_num修改PTE的物理页帧号,使2个线性地址都指向同一份4KB物理页,实现代码如下所示:

BOOLEAN
MapAddress_PTE(
    ULONG64 SrcVa,
    ULONG64 DstVa,
    ULONG Size
)
{
    ULONG64 SrcStartAddress = (ULONG64)PAGE_ALIGN(SrcVa);
    ULONG64 DstStartAddress = (ULONG64)PAGE_ALIGN(DstVa);
    ULONG64 EndOffset = (ULONG64)PAGE_ALIGN((PVOID)(SrcVa + Size));

    ULONG64 PageCount = (EndOffset - SrcStartAddress) / PAGE_SIZE;
    if (PageCount == 0)
    {
        PageCount = 1;
    }

    for (ULONG64 i = 0; i < PageCount; i++)
    {
        ULONG64 CurrentSrcVa = SrcStartAddress + (i * PAGE_SIZE);
        ULONG64 CurrentDstVa = DstStartAddress + (i * PAGE_SIZE);

        HARDWAREPTE* SrcPde = (HARDWAREPTE*)GetPde(CurrentSrcVa);
        HARDWAREPTE* DstPde = (HARDWAREPTE*)GetPde(CurrentDstVa);

        if (!MmIsAddressValid(SrcPde) || !SrcPde->valid)
        {
            return FALSE;
        }

        if (!MmIsAddressValid(DstPde) || !DstPde->valid)
        {
            return FALSE;
        }

        HARDWAREPTE* SrcPte = (HARDWAREPTE*)GetPte(CurrentSrcVa);
        HARDWAREPTE* DstPte = (HARDWAREPTE*)GetPte(CurrentDstVa);

        if (!MmIsAddressValid(SrcPte) || !SrcPte->valid)
        {
            return FALSE;
        }

        if (!MmIsAddressValid(DstPte) || !DstPte->valid)
        {
            return FALSE;
        }

        DstPte->page_frame_number = SrcPte->page_frame_number;

        __invlpg((PVOID)CurrentDstVa);
    }

    return TRUE;
}

​ 但是很遗憾,经过笔者实验后发现,这种修改PTE页帧号的手法,在处理一些普通的内存页面时,的确可以实现内存共享(稳定挺长时间不蓝屏)。但是涉及到dll模块内的内存时,修改后系统1分钟之内必定会出现蓝屏错误,错误码为MEMORY_MANAGEMENT

​ 直接修改硬件PTE的方式似乎走不通了,笔者这里选择的是通过MDL内存映射实现了内存页面共享,实现思路如下:

  1. 释放影子模块.data节区的内容,让该线性地址释放、空闲出来
  2. 调用MDL内存映射,让系统将指定的物理页面,映射到笔者指定的线性地址处
  3. 经过笔者测试后,该方法算是比较完美地解决全局变量内存共享的问题,关键代码如下所示:
BOOLEAN
MapAddress_MDL(
    ULONG64 SrcVa,
    ULONG64 DstVa,
    ULONG Size
)
{
    ULONG64 SrcStartAddress = (ULONG64)PAGE_ALIGN(SrcVa);
    ULONG64 DstStartAddress = (ULONG64)PAGE_ALIGN(DstVa);
    ULONG64 EndOffset = (ULONG64)PAGE_ALIGN((PVOID)(SrcVa + Size));

    ULONG64 PageCount = (EndOffset - SrcStartAddress) / PAGE_SIZE;
    if (PageCount == 0)
    {
        PageCount = 1;
    }

    for (ULONG64 i = 0; i < PageCount; i++)
    {
        ULONG64 CurrentSrcVa = SrcStartAddress + (i * PAGE_SIZE);
        ULONG64 CurrentDstVa = DstStartAddress + (i * PAGE_SIZE);

        if (!MmIsAddressValid((PVOID)CurrentSrcVa))
        {
            DbgPrint(
                "[MapAddress] Source address 0x%llx is invalid\n", 
                CurrentSrcVa
            );
            return FALSE;
        }

        PVOID BaseAddress = (PVOID)CurrentDstVa;
        SIZE_T RegionSize = PAGE_SIZE;
        NTSTATUS status = ZwFreeVirtualMemory(
            NtCurrentProcess(),
            &BaseAddress,
            &RegionSize,
            MEM_RELEASE
        );

        if (!NT_SUCCESS(status))
        {
            DbgPrint(
                "[MapAddress] Failed to decommit target address 0x%llx, status=0x%x\n",
                CurrentDstVa, 
                status
            );
        }

        PMDL Mdl = IoAllocateMdl((PVOID)CurrentSrcVa, PAGE_SIZE, FALSE, FALSE, NULL);
        if (!Mdl)
        {
            DbgPrint(
                "[MapAddress] Failed to allocate MDL for 0x%llx\n", 
                CurrentSrcVa
            );
            return FALSE;
        }

        __try
        {
            MmProbeAndLockPages(Mdl, UserMode, IoModifyAccess);
        }
        __except (EXCEPTION_EXECUTE_HANDLER)
        {
            DbgPrint(
                "[MapAddress] Failed to lock pages for 0x%llx\n", 
                CurrentSrcVa
            );
            IoFreeMdl(Mdl);
            return FALSE;
        }

        PVOID MappedAddress = MmMapLockedPagesSpecifyCache(
            Mdl,
            UserMode,
            MmCached,
            (PVOID)CurrentDstVa, 
            FALSE,              
            NormalPagePriority
        );

        if (!MappedAddress)
        {
            DbgPrint(
                "[MapAddress] Failed to map to address 0x%llx\n", 
                CurrentDstVa
            );
            MmUnlockPages(Mdl);
            IoFreeMdl(Mdl);
            return FALSE;
        }

        if (MappedAddress != (PVOID)CurrentDstVa)
        {
            DbgPrint(
                "[MapAddress] Mapped to 0x%p instead of requested 0x%llx\n",
                MappedAddress, 
                CurrentDstVa
            );
            MmUnmapLockedPages(MappedAddress, Mdl);
            MmUnlockPages(Mdl);
            IoFreeMdl(Mdl);
            return FALSE;
        }

        if (!AddMdlMapEntry(CurrentDstVa, Mdl))
        {
            DbgPrint("[MapAddress] Failed to track MDL for 0x%llx\n", CurrentDstVa);
            MmUnmapLockedPages(MappedAddress, Mdl);
            MmUnlockPages(Mdl);
            IoFreeMdl(Mdl);
            return FALSE;
        }

        DbgPrint(
            "[MapAddress] Successfully mapped 0x%llx -> 0x%llx (MDL tracked)\n",
            CurrentSrcVa, 
            CurrentDstVa
        );
    }

    return TRUE;
}

4.5 给影子模块挂Inline Hook

​ 经过前文的各种调试、处理后,的确可以较为稳定地将代码执行流劫持到影子模块中

​ 那么接下来完美就可以自由地做手脚了,笔者这里给影子模块中的send函数下inline hook,可以正常监控参数信息,如下所示:

int
WINAPI
MySend(
    _In_ SOCKET s,
    _In_reads_bytes_(len) const char FAR* buf,
    _In_ int len,
    _In_ int flags
)
{
    printf("--------------------------------------------------- \n");
    printf("[+] Send Hook - Socket: %llu, Length: %d\n", (ULONG64)s, len);
    printf("[+] Data:%s \n", buf);
    printf("--------------------------------------------------- \n");

    return g_OriginalSend(s, buf, len, flags);
}

​ 使用调试器,send处的代码没有任何修改,但是实际上确实是被我们Hook了,并正常输出参数

图片描述

​ 这部分代码已经整理好,Git仓库地址见文末

5. 提前接管用户态异常方法2

​ 前文介绍了一种Hook ntdll!Wow64PrepareForException实现提前接管用户态异常的方法,但是毕竟是用户态的对抗,一些游戏安全厂商、安全开发者往往会直接Hook ntdll!KUserExceptionDispatcher实现总管用户态异常,那么我们提前接管用户态异常的行为,还是有可能会被监控、扫描出来

​ 那么有没有一种方法?

​ 在退回到用户态之前,直接在内核态提前接管用户态异常呢?

​ 有的兄弟,有的,笔者接下来将会介绍一种类似Hook nt!KiDispatchException的方法,实现在内核态提前接管用户异常

​ 前文长篇分析Win11内核异常体系、无痕Hook,都属于前期铺垫,现在笔者将带着各位朋友,把对抗的层级拉到内核层,本文精彩的部分才刚刚开始

5.1 nt!PsPicoDispatchException

​ 我们重新回到nt!KiDispatchException中进行分析,在该函数的开头,会连续判断3个条件是否成立:

图片描述

​ 如果以上3点都满足,那么不调用KiPreprocessFault函数提前将#DE内部错误转换为文档化的错误码C0000094

图片描述

​ 随后从全局变量xmmword_140F05660取出一个函数指针,通过CFG发起一次间接函数调用,如果该函数返回TRUE,那么ntKiDispatchException函数提前返回,所以说该函数的异常接管优先级非常高,早于内核调试器、R3调试器、用户VEH、用户SEH接管异常。这个函数调用其实是nt!PsPicoDispatchException,这其实和WSL子系统有关,笔者对此粗浅的理解如下:

由于WSL Linux子系统的存在,那么子系统中的进程就叫做WSL进程吧,这些进程内同样会出现各种异常,需要系统异常体系派发、处理,但是WSL进程毕竟不是原生的Win进程,微软希望的是由WSL子系统本身优先接管、处理异常

异常派发中,是如何区分原生Win进程、WSL子系统进程呢?

靠的就是EPROCESS.PicoContext字段是否为NULL,没错,判断条件非常单薄

图片描述

5.2 分析PicoProvider注册流程

​ 我们继续分析这个利用点,由于是从全局变量xmmword_140F05660取出一个函数指针,再通过CFG发起一次间接函数调用,那么简单粗暴的方法,直接特征码搜索到这处代码,然后将我们自己的异常回调写进去。

​ 这种方法是可行的,但是笔者认为这并不是最优解,首先涉及到特征码,后续在兼容多版本时工程维护起来会麻烦得多。还有就是这种系统异常派发的关键代码路径上,涉及到的全局变量可能会有PG保护。

​ 所以笔者这里的思路是,既然这是给WSL子系统准备的异常处理,那么我们自己能不能通过合法的方式,将自己的Pico处理回调注册进去?既免去后期特征码维护、还让系统自己把异常回调写到全局变量xmmword_140F05660中

​ 思路确定了,现在开始分析,我们查看全局变量xmmword_140F05660的交叉引用,发现PsRegisterPicoProvider函数处有调用

图片描述

​ 我们直接定位到该函数处,更惊喜的发现,这个函数居然还是导出!这个函数本身不长,我们直接来看:

图片描述

​ 使用Windbg调试发现,当Win11 26H1正常启动进入桌面后,全局变量PspPicoRegistrationDisabled会被置1,看来微软并不想让其他驱动随便注册Pico回调

图片描述

图片描述

​ 我们继续分析PsRegisterPicoProvider函数,以下代码功能很清晰,将参数1中的各个字段成员保存到全局变量中,参数2是作为输出参数,接收结果

图片描述

​ 根据以上汇编,笔者反推参数1、参数2的部分对象结构,如下所示:

typedef struct _PS_PICO_EXCEPTION_ARG1
{
    ULONG64 Size;
    PVOID Pad1[3];
    PVOID DispatchException;    // 这个是关键,我们自定义的异常处理回调指针
    PVOID Pad2[2];
    ULONG Flag[2];
    PVOID Pad3[3];
} PS_PICO_EXCEPTION_ARG1, *PPS_PICO_EXCEPTION_ARG1;

typedef struct _PS_PICO_EXCEPTION_ARG2
{
    ULONG64 Size;
    PVOID Pad1[11];
} PS_PICO_EXCEPTION_ARG2, *PPS_PICO_EXCEPTION_ARG2;

5.3 注册成为PicoProvider

​ Pico回调的注册流程已经分析清楚了,那么我们的注册思路、关键代码如下:

  1. 从nt导出表找到PsRegisterPicoProvider函数指针
  2. 构造注册参数1、参数2,补齐必要的字段信息,避免注册函数检查失败
  3. 修改PspPicoRegistrationDisabled这个控制变量,手动置零
  4. 调用PsRegisterPicoProvider注册Pico回调
  5. 还原PspPicoRegistrationDisabled这个控制变量,由于该变量修改窗口期非常小,不会对系统运行有什么影响
typedef 
BOOLEAN
(*fn_PicoExceptionHandler)(
    _In_ PEXCEPTION_RECORD ExceptionRecord,
    _In_ PKEXCEPTION_FRAME ExceptionFrame,
    _In_ PKTRAP_FRAME TrapFrame,
    _In_ ULONG Unknown,
    _In_ KPROCESSOR_MODE PreviousMode
);

BOOLEAN
InstallPicoExceptionHook(
    _In_ fn_PicoExceptionHandler ExceptionHandler
)
{
    BOOLEAN Ret = FALSE;
    NTSTATUS Status;

    do
    {
        if (NULL == ExceptionHandler)
        {
            break;
        }

        UNICODE_STRING FuncName;
        RtlInitUnicodeString(&FuncName, L"PsRegisterPicoProvider");
        _PsRegisterPicoProvider = (fn_PsRegisterPicoProvider)MmGetSystemRoutineAddress(&FuncName);
        if (NULL == _PsRegisterPicoProvider)
        {
            break;
        }

        _pPspPicoRegistrationDisabled = SearchPspPicoRegistrationDisabled(_PsRegisterPicoProvider);
        if (NULL == _pPspPicoRegistrationDisabled)
        {
            break;
        }

        PS_PICO_EXCEPTION_ARG1 ProviderRoutines = { 0 };
        ProviderRoutines.Size = 88;
        ProviderRoutines.DispatchException = ExceptionHandler;
        ProviderRoutines.ProtectedRanges[0] = 0;
        ProviderRoutines.ProtectedRanges[1] = 0;

        PS_PICO_EXCEPTION_ARG2 PicoRoutines = { 0 };
        PicoRoutines.Size = 96;

        *_pPspPicoRegistrationDisabled = 0;

        Status = _PsRegisterPicoProvider(&ProviderRoutines, &PicoRoutines);

        *_pPspPicoRegistrationDisabled = 1;

        if (!NT_SUCCESS(Status))
        {
            break;
        }

        Ret = TRUE;

    } while (FALSE);

    return Ret;
}

5.4 将目标进程设置为WSL子进程

​ 我们现在注册好了Pico异常处理回调,但是该回调只能提前接管WSL子进程的异常,那么如何将目标进程变为WSL子进程呢?非常检查,只需要将EPROCESS.PicoContext字段成员设置一个任意非零值即可,笔者这里直接将其设置为1

//0x840 bytes (sizeof)
struct _EPROCESS
{
    struct _KPROCESS Pcb;                                                   //0x0
    struct _EX_PUSH_LOCK ProcessLock;                                       //0x1c8
    VOID* UniqueProcessId;                                                  //0x1d0
    // ...
    VOID* PicoContext;                                                      //0x640
    // ...
}; 
VOID SetProcessPicoContext(
    _In_ PEPROCESS Process
)
{
    ULONG PicoContextOffset = GetPicoContextOffset();
    *(PUCHAR)((PUCHAR)Process + PicoContextOffset) = 1;
}

5.5 利用Pico异常处理回调实现无痕Hook

​ 实现以上步骤后,当目标进程发生用户态异常后,我们的Pico回调就能实现最高接管该异常,早于内核调试器、用户调试器,至于VEH、SEH更不必多说,此时我们可以将前文的无痕Hook实现,异常处理部分改动一下,就能实现更加隐蔽的无痕Hook效果,关键代码、效果图如下所示:

BOOLEAN
ExceptionHandler(
    PEXCEPTION_RECORD ExceptionRecord,
    PKEXCEPTION_FRAME ExceptionFrame,
    PKTRAP_FRAME TrapFrame,
    ULONG Unknown,
    KPROCESSOR_MODE PreviousMode
)
{
    UNREFERENCED_PARAMETER(ExceptionFrame);
    UNREFERENCED_PARAMETER(Unknown);
    UNREFERENCED_PARAMETER(PreviousMode);

    if (g_TargetProcess == PsGetCurrentProcess() && STATUS_ACCESS_VIOLATION == ExceptionRecord->ExceptionCode)
    {
        SHADOW_EXCEPTION_INFO ShadowInfo = { 0 };
        if (!QueryShadowModule((ULONG64)ExceptionRecord->ExceptionAddress, &ShadowInfo))
        {
            return FALSE;
        }

        ULONG64 FuncOffset = (ULONG64)ExceptionRecord->ExceptionAddress - ShadowInfo.OriginalImageBase;
        TrapFrame->Rip = ShadowInfo.ShadowImageBase + FuncOffset;

        return TRUE;
    }

    return FALSE;
}

​ 这部分代码已经整理好,Git仓库地址见文末

5.6 利用Pico异常回调实现反调试效果

​ 既然我们目前可以最早接管目标进程的用户态异常,那么就很容易实现各种反调试手法

​ 第一种常见的反调试思路是破坏调试现场,因为攻击方使用调试器分析我们的代码时,一个非常重要的过程就是看寄存器列表,反推参数的意义,但是暴力清空所有寄存器,这是一个非常明显、且强烈的反调试信号。这相当于提示调试者,当前程序有反调试措施,要先把反调试干掉!所以笔者的反调试的思路是,魔改一份寄存器现场,让寄存器中的内容看起来非常正常,但是调试者尝试断点、单步走时,就会出现各种乱七八糟的情况,误导调试者以为这个程序的数据结构怎么乱七八糟的,从而增加调试者的时间成本

​ 第二种反调试的方式,就直接退出自身进程呗,代码如下:

​ 调试者看到的效果就是,怎么断点一命中,程序就退出了?

BOOLEAN
ExceptionHandler(
    PEXCEPTION_RECORD ExceptionRecord,
    PKEXCEPTION_FRAME ExceptionFrame,
    PKTRAP_FRAME TrapFrame,
    ULONG Unknown,
    KPROCESSOR_MODE PreviousMode
)
{
    UNREFERENCED_PARAMETER(ExceptionFrame);
    UNREFERENCED_PARAMETER(Unknown);
    UNREFERENCED_PARAMETER(PreviousMode);

    if (g_TargetProcess == PsGetCurrentProcess() && 
        (STATUS_BREAKPOINT == ExceptionRecord->ExceptionCode || STATUS_SINGLE_STEP == ExceptionRecord->ExceptionCode))
    {
        // 结束自身进程,报一个栈溢出的假退出码
        ZwTerminateProcess((HANDLE)-1, STATUS_STACK_OVERFLOW);
        return TRUE;
    }

    return FALSE;
}

5.7 WSL子进程进程保护

​ 笔者这里测试,将任务管理器进程Process.PicoContext置1,将其变为WSL子进程,此时使用普通的调试器,例如x64Dbg、CE是无法打开进程的

  1. x64Dbg进程列表找不到目标程序
  2. CE无法进程目标进程报错,如图所示

图片描述

​ 关于这种进程保护的原理,碍于当前文章篇幅问题,这里就不展开详细分析过程了,笔者之前在IDA中粗略分析了一下,原理如下:

NtOpenProcess尝试打开进程时,会判断目标进程是不是WSL子进程,如果是就会返回错误码STATUS_ACCESS_DENIED,所以达到了进程保护的效果

6. 提前接管用户态、内核态异常方法

6.1 Win11 26H1

​ 笔者在前文介绍了2种提前接管用户态异常的方法,但是局限在于仅能提前接管用户态异常,所以笔者这里将会介绍在Win11 26H1下,可以同时提前接管用户态、内核态异常的思路

​ 还是回去分析我们的老熟人KiDispatchException函数,已知需要将异常派发到内核调试器时,会调用KdTrap或者KdStub函数,如图所示

图片描述

​ 利用点在KdTrap中,大量的无关中间调用这里不展开分析,直到最后的KeStallExecutionProcessor函数

图片描述

​ 在KeStallExecutionProcessor函数中,会取出HalpStallCounter这个对象下0x70的函数指针,最后通过CFG发起一次间接函数调用,看到这里,对ETW Hook有所了解的朋友应该猜到劫持异常的原理了,没错,又是Halxxx指针导致的问题

​ 已知不管是用户态还是内内核态异常,系统都先尝试将异常派发到内核调试器,此时替换Halxxx中的函数指针,并配合修改沿途的一些标志位,使得最终调用到我们处理回调中,让我们有机会提前接管异常。这个实现思路跟ETW Hook(或者InfinityHook)可以说非常相似了

图片描述

图片描述

6.2 Win11 25H2及以下

​ 着手点还在KdTrap中,大量的无关中间调用这里不展开分析,直到最后的KeQueryPerformanceCounter函数

图片描述

​ 在KeQueryPerformanceCounter函数中,会取出HalpPerformanceCounter这个对象下0x70的函数指针,最后通过CFG发起一次间接函数调用,没错,又又是Halxxx指针导致的问题

图片描述

图片描述

6.2.1 特征码定位HalpPerformanceCounter

​ 接下来开始简单实验,首先特征码定位到HalpPerformanceCounter对象

ULONG64
SearchHalpPerformanceCounter()
{
    static ULONG64 HalpPerformanceCounter = 0;
    if (HalpPerformanceCounter)
    {
        return HalpPerformanceCounter;
    }

    ULONG64 Addr = SearchFeatureCode(
        (PCCHAR)"ntoskrnl.exe",
        (PCCHAR)".text",
(PCUCHAR)"\x48\x8B\x35\xFC\xE3\xD6\x00\x48\x89\x7C\x24\x28\x4C\x89\x74\x24\x20\x83\xBE\xE4\x00\x00\x00\x05\x0F\x84\x0A\x01\x00\x00\x83\xBE\xDC\x00\x00\x00\x40\x48\x8B\x9E\xC0\x00\x00\x00",
        "xxx????xxxx?xxxx?xx?????xx????xx?????xxx????",
        0
    );

    if (NULL == Addr)
    {
        return NULL;
    }

    ULONG64* pHalpPerformanceCounter = DEREF_RELATIVE_ADDR(ULONG64*, Addr, 3);
    HalpPerformanceCounter = *pHalpPerformanceCounter;
    return HalpPerformanceCounter;
}

6.2.2 替换Hal函数指针

​ 搜索成功后,替换其中0x70处的Hal函数指针,替换为自己的异常处理函数:

g_HalpHvCounterQueryCounter =
        (fn_HalpHvCounterQueryCounter)_InterlockedExchangePointer((volatile PVOID*)(HalpPerformanceCounter + 0x70), HalpHvCounterQueryCounter_Hook);
__int64
HalpHvCounterQueryCounter_Hook(
    PVOID Arg1,
    PVOID Arg2
)
{
    _InterlockedIncrement64((volatile LONG64*)&g_Counter);

    // 筛选出异常调用来源
    if (ExceptionStackTrace())
    {
        _InterlockedIncrement64((volatile LONG64*)&g_ExceptionCounter);

         // 从堆栈中恢复丢失的ExceptionRecord、Context等
        CONTEXT* Context = NULL;
        EXCEPTION_RECORD* ExceptionRecord = NULL;
         ResumeExceptionInfoFromStack(&Context, &ExceptionRecord);

        if (STATUS_ACCESS_VIOLATION == ExceptionRecord->ExceptionCode)
        {
            // ...
        }

         // 构造中断返回栈,恢复异常派发
        ContinueExecution();
    }

    return g_HalpHvCounterQueryCounter(Arg1, Arg2);
}

6.2.3 堆栈回溯筛选出异常派发调用

​ 由于我们的Hook点位于KeQueryPerformanceCounter,就导致大量的非异常调用来源,所以我们要需要回溯堆栈信息,筛选出异常派发来源,笔者这里堆栈回溯判定比较简单,在堆栈中找到KeThawExecution、KdpReport、KiDispatchException范围附近的返回地址,便认为本次调用来源属于异常派发,部分代码如下:

BOOLEAN
ExceptionStackTrace()
{
    PVOID* StackBase = (PVOID*)__readgsqword(0x1A8);
    PVOID* StackCurrent = (PVOID*)_AddressOfReturnAddress();

    if ((ULONG64)StackBase - (ULONG64)StackCurrent > 0x6000 || StackCurrent > StackBase)
    {
        return FALSE;
    }

    BOOLEAN bFindKeThawExecution = FALSE;
    BOOLEAN bFindKdpReport = FALSE;
    BOOLEAN bFindKiDispatchException = FALSE;

    for (; StackCurrent < StackBase; ++StackCurrent)
    {
        ULONG64 StackValue = *(ULONG64*)StackCurrent;

        if (StackValue <= (ULONG64)MM_HIGHEST_USER_ADDRESS)
        {
            continue;
        }

        if (!bFindKeThawExecution)
        {
            if (StackValue > g_TrackStackFunc.KeThawExecution &&
                StackValue < g_TrackStackFunc.KeThawExecution + 0x100)
            {
                bFindKeThawExecution = TRUE;
            }
            continue;
        }

        if (!bFindKdpReport)
        {
            if (StackValue > g_TrackStackFunc.KdpReport &&
                StackValue < g_TrackStackFunc.KdpReport + 0x150)
            {
                bFindKdpReport = TRUE;
            }
            continue;
        }

        if (!bFindKiDispatchException)
        {
            if (StackValue > g_TrackStackFunc.KiDispatchException &&
                StackValue < g_TrackStackFunc.KiDispatchException + 0x100)
            {
                bFindKiDispatchException = TRUE;
                return TRUE;
            }
        }
    }
    return FALSE;
}

​

6.2.4 从堆栈中恢复丢失的异常信息

​ 顺利筛选出异常派发的调用来源后,那么新的问题来了,在多层的函数调用中,原始ExceptionRecord、Context等参数已经丢失了,并没有传递给我们的Hook异常回调

​ 这个时候没招了嘛?接管异常的路子走不通了?

​ 当然不会,此时我们可以分析上层函数的调用逻辑,向上回溯堆栈,尝试从上层函数栈帧中恢复异常参数,根据x64调用约定,调用子函数中,涉及到使用非易失寄存器时,由被调用方自己保存、恢复,所以这里笔者列出来整个调用链中,每一个函数的序言部分代码:

// KdpTrap
PAGEKD:0000000140B662B8 48 8B C4       mov     rax, rsp
PAGEKD:0000000140B662BB 48 89 58 08    mov     [rax+8], rbx
PAGEKD:0000000140B662BF 48 89 68 10    mov     [rax+10h], rbp
PAGEKD:0000000140B662C3 48 89 70 20    mov     [rax+20h], rsi
PAGEKD:0000000140B662C7 57             push    rdi
PAGEKD:0000000140B662C8 48 83 EC 40    sub     rsp, 40h

// KdpReport
.text:00000001404CEF84 48 8B C4        mov     rax, rsp
.text:00000001404CEF87 48 89 58 08     mov     [rax+8], rbx // Context
.text:00000001404CEF8B 48 89 68 10     mov     [rax+10h], rbp
.text:00000001404CEF8F 48 89 70 18     mov     [rax+18h], rsi
.text:00000001404CEF93 48 89 78 20     mov     [rax+20h], rdi
.text:00000001404CEF97 41 54           push    r12
.text:00000001404CEF99 41 56           push    r14
.text:00000001404CEF9B 41 57           push    r15
.text:00000001404CEF9D 48 83 EC 20     sub     rsp, 20h

// KdExitDebugger
PAGEKD:0000000140B66008 40 53          push    rbx
PAGEKD:0000000140B6600A 48 83 EC 20    sub     rsp, 20h

// KeThawExecution
.text:00000001404EE390 48 89 5C 24 08  mov     [rsp+arg_0], rbx
.text:00000001404EE395 48 89 74 24 10  mov     [rsp+arg_8], rsi
.text:00000001404EE39A 57              push    rdi
.text:00000001404EE39B 48 83 EC 20     sub     rsp, 20h

// KeQueryPerformanceCounter
.text:0000000140254AD0 40 53           push    rbx
.text:0000000140254AD2 41 54           push    r12
.text:0000000140254AD4 41 57           push    r15
.text:0000000140254AD6 48 83 EC 30     sub     rsp, 30h

​ 我们从KdpTrap函数开始分析,该函数将Context保存到了rbx这个非易失寄存器中,由于调用了KdpReport函数的内部使用了rbx,会将原rbx保存到栈上,那么我们可以向上回溯堆栈,找到一个返回地址是处于KdpTrap函数范围内的,即可找到期栈帧位置,从中恢复Context异常上下文信息

图片描述

6.2.5 构造中断返回帧,恢复异常派发流程

​ 由于我们接管了异常后,不能从正常栈中取出返回地址一层层返回,需要从KeQueryPerformanceCounter函数直接折返到KdpTrap的返回地址处,即直接跳转回到KiDispatchException函数内,流程如下所示:

图片描述

​ 要做到这一点,我们要保证进入、退出KdpTrap函数时非易失寄存器现场要保持一致,那么就需要往上回溯栈帧,恢复所有被破坏的非易失寄存器环境,笔者这里给出部分实验代码:

typedef struct _EXECUTION_CONTEXT
{
    ULONGLONG RFlags;   // +0x00
    ULONGLONG Rax;      // +0x08
    ULONGLONG Rcx;      // +0x10
    ULONGLONG Rdx;      // +0x18
    ULONGLONG Rbx;      // +0x20
    ULONGLONG Rsp;      // +0x28
    ULONGLONG Rbp;      // +0x30
    ULONGLONG Rsi;      // +0x38
    ULONGLONG Rdi;      // +0x40
    ULONGLONG R8;       // +0x48
    ULONGLONG R9;       // +0x50
    ULONGLONG R12;      // +0x58
    ULONGLONG R13;      // +0x60
    ULONGLONG R14;      // +0x68
    ULONGLONG R15;      // +0x70
    ULONGLONG Rip;      // +0x78
} EXECUTION_CONTEXT, *PEXECUTION_CONTEXT;

VOID 
ContinueExecution()
{
    PVOID* StackBase = (PVOID*)__readgsqword(0x1A8);
    PVOID* CurStackPtr = (PVOID*)_AddressOfReturnAddress();

    if ((ULONG64)StackBase - (ULONG64)CurStackPtr > 0x6000 || CurStackPtr > StackBase)
    {
        return VOID();
    }

    BOOLEAN bFindKdpReport = FALSE;
    BOOLEAN bFindKdpTrap = FALSE;
    BOOLEAN bFindKdTrap = FALSE;

    EXECUTION_CONTEXT ExecutionContext = { 0 };
    ExecutionContext.R13 = Asm_GetR13();
    ExecutionContext.RFlags = Asm_GetRFlags() | 0x200;

    for (; CurStackPtr < StackBase; ++CurStackPtr)
    {
        ULONG64 StackValue = *(ULONG64*)CurStackPtr;

        if (StackValue <= (ULONG64)MM_HIGHEST_USER_ADDRESS)
        {
            continue;
        }

        if (!bFindKdpReport)
        {
            if (StackValue > g_TrackStackFunc.KdpTrap &&
                StackValue < g_TrackStackFunc.KdpTrap + 0x100)
            {
                ExecutionContext.Rbx = *(ULONG64*)((ULONG64)CurStackPtr + 0x08);
                ExecutionContext.Rbp = *(ULONG64*)((ULONG64)CurStackPtr + 0x10);
                ExecutionContext.Rsi = *(ULONG64*)((ULONG64)CurStackPtr + 0x18);
                ExecutionContext.Rdi = *(ULONG64*)((ULONG64)CurStackPtr + 0x20);

                ExecutionContext.R12 = *(ULONG64*)((ULONG64)CurStackPtr - 0x8);
                ExecutionContext.R14 = *(ULONG64*)((ULONG64)CurStackPtr - 0x10);
                ExecutionContext.R15 = *(ULONG64*)((ULONG64)CurStackPtr - 0x18);

                bFindKdpReport = TRUE;
            }

            continue;
        }

        if (!bFindKdTrap)
        {
            if (StackValue > g_TrackStackFunc.KiDispatchException &&
                StackValue < g_TrackStackFunc.KiDispatchException + 0x100)
            {
                ExecutionContext.Rip = StackValue;
                ExecutionContext.Rsp = (ULONG64)CurStackPtr + 8;

                ExecutionContext.Rbx = *(ULONG64*)((ULONG64)CurStackPtr + 0x08);
                ExecutionContext.Rbp = *(ULONG64*)((ULONG64)CurStackPtr + 0x10);
                ExecutionContext.Rsi = *(ULONG64*)((ULONG64)CurStackPtr + 0x18);

                ExecutionContext.Rdi = *(ULONG64*)((ULONG64)CurStackPtr - 0x8);

                bFindKdTrap = TRUE;
                break;
            }

        }

    }

    if (bFindKdpReport && 
        bFindKdpReport && 
        bFindKdTrap)
    {
        // DbgBreakPoint();

        ContinueWithContext(&ExecutionContext);
        KeBugCheck(0);
    }
    else
    {
        DbgBreakPoint();
        KeBugCheck(0);
    }

    return VOID();
}

6.2.6 大量踩坑

​ 这一部分内容,单从劫持异常的原理来说,其实不算是非常难,但是在上手写代码、调试时,笔者遇到遇到了大量踩坑的地方,其中感受最强烈的2点如下:

  1. 这种劫持异常的点,正好处在系统派发异常到内核调试器路径上,导致调试比较困难。笔者甚至遇到了无限异常递归的尴尬情况,即虚拟机无影响、但是Windbg一直接收不到异常
  2. 挂内核调试器、不挂内核调试器这2种情况,似乎会改变系统内核异常派发的逻辑,导致不挂调试器时加载驱动卡死,挂上WinDbg后再加载驱动,问题却消失了

​ 由于以上2点问题的存在,导致这个实验本身进度非常缓慢,达不到笔者心中可公开PoC的标准,所以笔者决定暂时不放出这部分实验的工程实现,对这部分内容感兴趣的朋友可以关注一下,日后工程完善了,笔者会放出实验工程文件

7. Git仓库开源地址

​ 劫持Wow64PrepareForException接管异常,实现无痕Hook,仓库地址:InvisibleHook_ShadowPage

​ 注册PicoDispatchException接管异常,实现无痕Hook,仓库地址:InvisibleHook_PicoException

​ 如果各位朋友看完本文觉得有所帮助、启发,欢迎点赞、收藏本文。另外,欢迎各位朋友给仓库Star!