65.9K
CodeProject 正在变化。 阅读更多。
Home

绕过 PatchGuard 3

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (24投票s)

2008年8月2日

MIT

28分钟阅读

viewsIcon

203356

downloadIcon

6146

本文展示了如何在最新版本的Windows上绕过PatchGuard 3。

为了更好的维护,您可以在 http://www.codeplex.com/easyhook 找到更新。本文属于一个也支持内核模式钩子的项目。

请注意,本文旨在与源代码并行阅读。我还强烈建议阅读第一篇非正式文章(链接如下),否则您将无法理解本文涵盖的大部分内容。这不是一篇教程,初学者不应阅读。关于 PatchGuard 有太多东西需要了解,不可能在一篇文章中全部涵盖。我也不想重复别人的工作!

您可以在此处找到本文档的 PDF 版本。

目录


1 PatchGuard 简介
2 解除 PatchGuard 3 武装
3 使用和编译驱动程序
4 PatchGuard 4 的想法
5 Windows 定时器内部机制

1 PatchGuard 3 简介


如果您想了解 PatchGuard 如何工作的优秀技术文章,我只能推荐以下内容(我的文章是关于如何禁用它,而不是太多关于 PatchGuard 如何工作的)

• PatchGuard 1:http://www.uninformed.org/?v=3&a=3&t=pdf (推荐)
• PatchGuard 2:http://www.uninformed.org/?v=6&a=1&t=pdf
• PatchGuard 3:http://www.uninformed.org/?v=8&a=5&t=pdf

还有一件事要提。当然,您可以通过在 root-shell 中运行以下命令并重启电脑,以 DOCUMENTED、STABLE 和 EASY 的方式禁用 PatchGuard

Bcdedit /debug ON
Bcdedit /dbgsettings SERIAL DEBUGPORT:1 BAUDRATE:115200 /start AUTOENABLE /noumex 

“noumex”将禁用内核调试器的用户模式异常,这实际上会阻止 Visual Studio 工作。“AUTOENABLE”将强制禁用 PatchGuard,因为即使您不附加内核调试器,您也可以随时进行,这就足够了。不要使用此设置来为最终用户编写内核修补软件。DEBUG 开关会有许多副作用。主要与 Visual Studio 和其他调试器有关,但我也认为与 DRM 内容播放有关。我还注意到系统速度令人恼火的下降和巨大的整体延迟。启动时间也会增加,可能是因为 Windows 正在等待调试器……
为什么 PatchGuard 在这些设置下被禁用?很简单,因为要设置断点,您必须覆盖内核代码,例如,用 INT3,这对 PatchGuard 来说就已经足以导致蓝屏死机(BSOD)。另一方面,调试器是探索 PatchGuard 的常用方法;-)。

1.1 警告


此驱动程序并非旨在用于任何最终用户场景。它已在 Windows Vista x64(所有更新,2008年1月8日)和 Windows Vista x64 SP1(所有更新,2008年1月8日)上进行了测试。已知它在过时的 Windows 上无法工作,因此请确保已安装上述日期之前发布的所有 PatchGuard 相关更新(最好是所有更新)。未来的任何 Windows 更新都可能使此方法失效,最坏情况下会导致您客户的系统蓝屏死机!这不仅限于我的方法……无法在最终用户的电脑上绕过 PatchGuard,只能在您自己的电脑上,您可以在其中控制更新并可以隐藏所有未来的 PatchGuard 相关更新,例如!我喜欢称之为 Symantec Showcase 的事件已经证明,您只能依靠已记录的内容,尤其是在处理内核时。就像 Microsoft 对 Symantec 所说的那样,如果 Symantec 将 PatchGuard 绕过代码放入其产品中,他们将发布一个更新,导致相关 PC 蓝屏死机,我也对您说:永远不要向客户发布绕过 PatchGuard 或 64 位未记录内核钩子的产品!一种选择可能是 PatchGuard API,但我认为它尚未公开提供。顺便说一句,我不同意 Symantec 的观点,即他们需要使用未记录的内核修补来开发安全产品。唯一的问题是他们已经拥有此类产品,如果不使用内核修补,重新发明轮子对他们来说将非常昂贵。Microsoft 做对了,没有听取 Symantec 的意见。未记录的内核修补绝不能成为在像 Vista 64 位这样的受信任环境中提高安全性的选择。它甚至可能会降低安全性和稳定性。Microsoft 的职责是保护内核免受恶意软件的侵害,而 UAC、强制驱动程序签名(第一次安装我的测试驱动程序确实很麻烦,所以我认为内核安全性表现相当好)和 PatchGuard 已经可以很好地完成这项工作。“恶意软件能够绕过 PatchGuard”的说法有些奇怪。我从未见过签名的病毒。即使如此,如果恶意软件已经进入内核,也无法采取任何措施。在这种情况下,您就输了。相反,恶意软件绝不应该进入内核;这才是安全软件应该关注的,而不是某种不稳定、未记录、不安全甚至无用的事后安全。


1.2 PatchGuard 的目标


以下数据和结构受到 PatchGuard 的保护

• 修改系统服务表,例如,通过钩子 KeServiceDescriptorTable
• 修改中断描述符表(IDT)
• 修改全局描述符表(GDT)
• 使用非内核分配的内核栈
• 修补内核的任何部分(仅在基于 AMD64 的系统上检测)
PG 使用一个系统检查例程来验证上述所有结构和数据。基本上,PatchGuard 的目的是保护这个系统检查例程不被破解。破解意味着在不了解 PatchGuard 的情况下违反上述至少一项声明。本文描述了如何通过完全禁用 PatchGuard 来违反所有这些声明!

在下文中,我将提及所谓的“代码路径”。这些代码路径旨在调用系统检查例程。最简单的方法是直接调用它,就像调用任何其他 API 一样。但 PatchGuard 的目的是使其尽可能难以
• 找出系统检查例程的调用方式
• 干扰此调用(通过阻止/重定向)
• 反汇编系统检查例程或找出其入口点地址
为了完成这项任务,PatchGuard 使用了各种技巧和不常见的处理器行为来尽可能地混淆。系统检查例程本身将每隔几分钟执行一次。

1.3 简要概述


以下是 PatchGuard 的代码逻辑图

Graph1.jpg

橙色箭头仅适用于 PatchGuard 3。正如您所看到的,PatchGuard 使用了通用机制进行延迟代码执行(定时器)。有十个 PatchGuard 相关的 DPC 例程

CmpEnableLazyFlushDpcRoutine
CmpLazyFlushDpcRoutine
ExpTimeRefreshDpcRoutine
ExpTimeZoneDpcRoutine
ExpCenturyDpcRoutine
ExpTimerDpcRoutine
IopTimerDispatch
IopIrpStackProfilerTimer
KiScanReadyQueues
PopThermalZoneDpc

但请记住,如果未调用 PatchGuard,这些例程也会执行重要的系统代码。此切换是通过传递一个无效指针作为 DeferredContext 来完成的。有人可能会说这是有问题的,因为在调度级别解引用无效指针会引发不可捕获的陷阱,从而导致错误检查。但 PatchGuard 使用的是所谓的非规范指针。这种指针不遵循 x64 处理器规范,该规范要求指针的顶部 16 位要么全部设置为 1,要么全部设置为 0(即 0x0000 或 0xFFFF)。非规范指针以 0x6238、0xF10A 等开头。解引用它们将导致一般保护错误,这是可捕获的,在 PatchGuard 的情况下,会执行系统检查例程。

PatchGuard 3 进一步能够在 #GP 之后恢复 DPC 例程中的执行,可能会再次引发另一个 #GP,这又可能决定调用系统检查例程或继续执行。PatchGuard 3 也可能在完全不引发异常的情况下执行系统检查例程。它对特定 DPC 的操作取决于加密的 DeferredContext,也可能取决于 DPC 本身。所以我们只是不知道!

1.4 代码流程图


以下是 PatchGuard 的代码流程图。绿色框适用于所有 PatchGuard 版本,而黄色框仅适用于 PatchGuard 2 及更高版本,橙色框适用于 PatchGuard 3。请记住,大部分内容只是“猜测”。我乐观地认为以下图表将是一个很好的近似值

Graph4.jpg

正如您所看到的,PG3 变得更加灵活,因此也更难绕过。我的驱动程序将攻击红色箭头

“DPC 调度程序”到“PatchGuard DPC”

驱动程序会过滤所有以 PatchGuard 特定参数作为 DeferredContext 的 DPC。此外,它还捕获 PatchGuard 的非 SEH 代码路径,该代码路径之前已被未处理的断点覆盖,从而回到我驱动程序中的异常处理程序!当然,仍然有一些 DPC 遗留下来,这就是为什么我们需要添加进一步的阻塞。

“动态存根”到“系统检查”和“PatchGuard DPC 到系统检查”

通过用断点覆盖动态存根和 PatchGuard DPC 的部分,执行将在 DPC 拦截器的异常处理程序中继续,而不是在系统检查例程中。

“ExpWorkerThread”到“动态存根”和“未知”

另一个钩子被应用到工作线程队列。这将过滤所有动态工作例程,并将所有其他例程包装在一个 try-except 语句中。一些似乎与下一个阻塞机制不兼容的特殊系统检查调用总是源自工作队列。但正如我所说,我们过滤它们,所以它们的不兼容性不会造成任何危害...


“系统检查”到“KeBugCheckEx”

只有通过 ExQueueWorkItem 引发的 PatchGuard 方法才能到达这里。重现这种情况很麻烦,因为您必须重新启动 10 到 30 次……

此块仅暂停所有 CRITICAL_STRUCTURE_CORRUPTION。因此,它们永远不会导致系统蓝屏死机。

问题是驱动程序捕获了错误检查尝试,但未能暂停调用工作线程。然后我也钩子了 ExpWorkerThread,从那一步开始,只进行了可暂停的 KeBugCheckEx 调用。


1.5 一些调用栈


通过修补一些代码路径,我可以在通往系统检查例程的路径上提取有趣的调用栈。
以下是 PatchGuard 中可能最长的调用栈之一。它引发了两次已处理的 #GP,最后直接从 DPC 调用系统检查例程,没有异常处理

nt!KeBugCheckEx
nt! ?? ::FNODOBFM::`string'+0x12767
nt!KiExceptionDispatch+0xae
nt!KiBreakpointTrap+0xb7
nt!ExpTimeRefreshDpcRoutine+0x1e6
nt!_C_specific_handler+0x8c
nt!RtlpExecuteHandlerForException+0xd
nt!RtlDispatchException+0x228
nt!KiDispatchException+0xc2
nt!KiExceptionDispatch+0xae
nt!KiGeneralProtectionFault+0xcd
nt!ExpTimeRefreshDpcRoutine+0xf1
nt!_C_specific_handler+0x140
nt!RtlpExecuteHandlerForUnwind+0xd
nt!RtlUnwindEx+0x233
nt!_C_specific_handler+0xcc
nt!RtlpExecuteHandlerForException+0xd
nt!RtlDispatchException+0x228
nt!KiDispatchException+0xc2
nt!KiExceptionDispatch+0xae
nt!KiGeneralProtectionFault+0xcd
nt!KiCustomRecurseRoutine0+0xd
nt!KiCustomRecurseRoutine9+0xd
nt!KiCustomRecurseRoutine8+0xd
nt!KiCustomRecurseRoutine7+0xd
nt!KiCustomAccessRoutine7+0x22
nt!ExpTimeRefreshDpcRoutine+0x54  <== PatchGuard DPC
nt!KiRetireDpcList+0x155
nt!KiIdleLoop+0x5f
nt!KiSystemStartup+0x1d4 

关于这一点,有趣的是 #GP 已由 PatchGuard 处理,但并未调用系统检查例程。因此,我们已经证明,自 PatchGuard 3 以来,访问冲突不一定会调用 SCR。

相比之下,下一个是可能最短的调用栈之一。它通过驻留方法(复制到非分页池)调用系统检查例程。您可能会看到本文将走向何方,因为我的驱动程序也列在调用栈中

nt!KeBugCheckEx
nt! ?? ::FNODOBFM::`string'+0x12767
nt!KiExceptionDispatch+0xae
nt!KiBreakpointTrap+0xb7
0xfffffa80010b3cc9 <== PatchGuard’s dynamic method
0xfffffa80010bbc00 <== PatchGuard’s optional intro
PG3Disable!VistaAll_DpcInterceptor+0x34
nt!KiRetireDpcList+0x117
nt!KiIdleLoop+0x62
… 

那些尝试过禁用 PatchGuard 的人可能会好奇我是如何获得这些调用栈的。KiBreakPointTrap 应该是不言自明的。我用未处理的断点修补了代码路径。因此,KeBugCheckEx 将创建内存转储,而不是调用系统检查例程,我们可以在事后使用 WinDbg 进行分析。

1.6 检测非规范、伪随机上下文


过滤 PatchGuard DPC 的一种方法是检测其特殊的 DeferredContext 值并取消相关的定时器。自 PatchGuard 3 以来,仅此一项已不足够。但它仍然是一个很好的起点,可以在 DPC 真正引发系统检查例程之前过滤掉大部分 PatchGuard 的 DPC。
以下代码片段能够判断给定指针是否为 PatchGuard 上下文

BOOLEAN CheckSubValue(ULONGLONG InValue)
{
    ULONG            i;
    ULONG            Result;
    UCHAR*        Chars = (UCHAR*)&InValue;

    // random values will have a result around 120...
    Result = 0;

    for(i = 0; i < 8; i++)
    {

        Result += ((Chars[i] & 0xF0) >> 4) + (Chars[i] & 0x0F);
    }

    // the maximum value is 240, so this should be safe...
    if(Result < 70)
        return TRUE;

    return FALSE;
}

BOOLEAN PgIsPatchGuardContext(void* Ptr)
{
    ULONGLONG        Value = (ULONGLONG)Ptr;
    UCHAR*        Chars = (UCHAR*)&Value;
    LONG            i;

    // those are sufficient proves for canonical pointers...
    if((Value & 0xFFFF000000000000) == 0xFFFF000000000000)
        return FALSE;

    if((Value & 0xFFFF000000000000) == 0)
        return FALSE;


    // sieve out other common values...
    if(CheckSubValue(Value) || CheckSubValue(~Value))
        return FALSE;

    if(Ptr == NULL)
        return FALSE;

    //This must be the last check and filters latin-char UTF16 strings...
    for(i = 7; i >= 0; i -= 2)
    {
        if(Chars[i] != 0)
            return TRUE;
    }

    // this should only return true if the pointer is a unicode string!!!
    return FALSE;
} 

问题是,我们这里的任务不是过滤非规范指针。我们的任务是区分 PatchGuard 上下文参数和任何其他常见的 DeferredContext 值,例如零、一些表索引,如“1、2、3、4、5、……”、Unicode 序列等。总而言之,上面的代码检测伪随机值。
考虑到这种能力,我们现在可以看看如何修补 DPC 调度程序,这是应用这些自定义检查的唯一方法。



2 解除 PatchGuard 3 武装


为了禁用 PatchGuard 3,我们必须阻止所有具有 PatchGuard 特定上下文的 DPC,并捕获未处理断点引发的异常。但似乎仍有一些代码路径在工作队列中运行,执行系统检查例程并最终引发错误检查。为此,我们还将钩子 KeBugCheckEx 并暂停所有引发 CRITICAL_STRUCTURE_CORRUPTION 的线程。请注意,后者是一种非常罕见的情况,不会影响系统性能。但这仍然不够。一些 KeBugCheckEx 调用是不可暂停的。我通过同时钩子 ExpWorkerThread 解决了这个问题,过滤掉动态存根并将所有其他调用包装在 try-except 语句中。

2.1 查找 Windows DPC 调用代码


现在是时候提到 KiRetireDpcListKiTimerExpiration 是内核中唯一负责调度排队 DPC 的点。如果您查看 KiTimerExpirationKiRetireDpcList 的反汇编代码,虽然太长无法展示,但您会发现以下四个间接调用代码块

nt!KiTimerExpiration+0x888:
    488b5308     mov      rdx, qword ptr [rbx+8]
    488b4bf8     mov      rcx, qword ptr [rbx-8]
    4d8bcc       mov      r9, r12
    4c8bc7       mov      r8, rdi  
    ff13         call     qword ptr [rbx]
    4084f6       test     sil, sil
    742c         je       nt!KiTimerExpiration+0x8d3    

nt!KiTimerExpiration+0x679:
    488b5308     mov      rdx,qword ptr [rbx+8]
    488b4bf8     mov      rcx,qword ptr [rbx-8]
    4d8bcc       mov      r9,r12
    4d8bc5       mov      r8,r13
    ff13         call     qword ptr [rbx] 
    4084ed       test     bpl, bpl
    742c         je       nt!KiTimerExpiration+0x7e5  

nt!KiTimerExpiration+0x799:
    488b5308     mov      rdx,qword ptr [rbx+8]
    488b4bf8     mov      rcx,qword ptr [rbx-8]
    4d8bcc       mov      r9,r12
    4d8bc5       mov      r8,r13
    ff13         call     qword ptr [rbx] 
    4084ed       test     bpl, bpl
    742c         je       nt!KiTimerExpiration+0x7e5  

nt!KiRetireDpcList+0x145:
    4d8bcc       mov      r9, r12
    4c8bc5       mov      r8, rbp
    488bd6       mov      rdx, rsi
    488bcf       mov      rcx, rdi
    ff542470     call     qword ptr [rsp+70h]
    4584ff       test     r15b, r15b     
    742b         je       nt!KiRetireDpcList+0x185                       

我们可以看到它们非常相似,在所有测试过的内核镜像中,这四个代码块都是唯一的。那么这些代码块实际在做什么呢?它们会调用用户定义的 DPC 例程……

但是,在 Windows Vista SP1 下,情况是怎样的呢?以下是 SP1 内核中仅有的两个执行用户 DPC 的点

Nt!KiTimerListExpire+0x31a:
    458b4e04          mov     r9d,dword ptr [r14+4]
    458b06            mov     r8d,dword ptr [r14]
    4189ac24a0370000  mov     dword ptr [r12+37A0h], ebp
    488b5308          mov     rdx, qword ptr [rbx+8]
    488b4bf8          mov     rcx, qword ptr [rbx-8]
    ff13              call    qword ptr [rbx]
    4084ff            test    dil, dil
    0f856c8ffdff      jne     nt! ?? ::FNODOBFM::`string'+0x39742 

nt!KiRetireDpcList+0x107:
    4d8bce            mov     r9,r14
    4d8bc5            mov     r8,r13
    498bd4            mov     rdx,r12
    488bcb            mov     rcx, rbx
    ff542470          call    qword ptr [rsp+70h]
    4584ff            test    r15b,r15b
    0f856e7ffdff      jne     nt! ?? ::FNODOBFM::`string'+0x39888  

有人会发现,即使整个底层代码逻辑已经改变(只有两个 DPC 调用,另一种计时器过期方法,末尾的远跳等),它看起来仍然非常相似。此外,Windows 更新更改此部分机器码的可能性也很小。在开发任何灵活机制之前,我们只需搜索纯字节。这可行且更稳定。对于每次更改字节的更新,我们只需添加额外的搜索向量,目前我只知道这两个……

2.2 修补 Windows DPC 调用代码


从现在开始,我将重点关注 Service Pack 1,因为它只有两个方法需要修补,而且是最新版本。没有 Service Pack 的代码非常相似,一旦您掌握了 SP1 的代码,就应该没有问题理解它。

如果我们无法在不破坏整个代码逻辑的情况下插入 JMP 指令,我们如何修补 KiTimerExpirationKiRetireDpcList 呢?我们将也覆盖最后的 MOV 指令,并在适当的跳转表中模拟它,然后继续在我们拦截方法中执行

Nt!KiTimerListExpire+0x31a:
    458b4e04           mov     r9d,dword ptr [r14+4]
    458b06             mov     r8d,dword ptr [r14]
    4189ac24a0370000   mov     dword ptr [r12+37A0h], ebp
    488b5308           mov     rdx, qword ptr [rbx+8]
    90                 nop 
    E8XXXXXXXX         call    TIMER_FIX
    4084ff             test    dil, dil
    0f856c8ffdff       jne     nt! ?? ::FNODOBFM::`string'+0x39742 

nt!KiRetireDpcList+0x107:
    4d8bce            mov     r9,r14
    4d8bc5            mov     r8,r13
    498bd4            mov     rdx,r12
    90                nop
    90                nop
    E8XXXXXXXX        call    DPC_FIX
    4584ff            test    r15b,r15b
    0f856e7ffdff      jne     nt! ?? ::FNODOBFM::`string'+0x39888   

请记住,此操作可以并且必须通过使用 64 位宽的 MOV 指令原子地执行!

你们中的一些人可能会问自己,我们到底要用 32 位偏移量跳到哪里去,而且自定义镜像区段(我们的驱动程序所在的位置)离它有数百千兆字节远?!我也思考了很多,只有两种解决方案。第一种是“大锤”方法,我不喜欢这种方法。这种方法是停止除一个 CPU 之外的所有 CPU,将 IRQL 提升到 HIGH_LEVEL,并在上面的两个代码块中放置一个绝对调用指令。这是唯一一种可以原子地替换超过八个字节的方法。另一种方法是劫持 PatchGuard 用来混淆调用栈的十个 KiCustomAccessRoutines 中的一个。所以我们在这里所做的是将一个跳转器作为此类例程的第一条指令放置,并将其重定向到另一个例程。现在我们可以使用其他字节,而且有很多,来构建我们的跳转表。当然,我知道即使是原子 MOV 指令也不是 100% 安全的。但是,线程将在 MOV 和 CALL 之间(我们的补丁会覆盖它们)以及在如此短的时间内出现的可能性非常小。NOP 必须放置在 CALL 之前,因为如果驱动程序卸载时更改被回滚,任何当前在 CALL 指令中的代码(这种可能性相反非常高)仍将返回到有效代码,即 TEST 指令,因为返回地址保持不变。

跳转表需要完成两件事。首先,它应该恢复被覆盖的 MOV 指令,然后使用绝对远跳转器调用我们的拦截方法。在 SP1 的情况下,我们必须处理两种不同的情况,并且我们放置在此类自定义访问例程中的代码可能如下所示

nt!KiCustomAccessRoutine4:
    E9XXXXXXXX            jmp      KiCustomAccessRoutine0

TIMER_FIX:
    488b4bf8              mov      rcx,qword ptr [rbx-8]
    eb03                  jmp      INTERCEPTOR 

DPC_FIX:
    488bcb                mov      rcx,rbx 

INTERCEPTOR:
    48b8XXXXXXXXXXXXXXXX  mov      rax, VistaAll_DpcInterceptor
    ffe0                  jmp      rax 

嗯,这就是修补 DPC 调用代码的全部奥秘。现在,任何 DPC 都将通过我们的拦截器。您可能可以想象有很多,因为大多数中断也会引发 DPC;因此,拦截器必须尽快执行。


2.3 设计拦截器


现在我们应该看看 DPC 拦截器
void VistaAll_DpcInterceptor(
        PKDPC InDpc,
        PVOID InDeferredContext,
        PVOID InSystemArgument1,
        PVOID InSystemArgument2)
{
    ULONGLONG        Routine = (ULONGLONG)InDpc->DeferredRoutine;

    __try
    {
        if((Routine >= 0xFFFFFA8000000000) && 
            (Routine <= 0xFFFFFAA000000000))
        {
        }
        else if(KeContainsSymbol((void*)Routine))
        {
            if(!PgIsPatchGuardContext(InDeferredContext))
                InDpc->DeferredRoutine(
                    InDpc, 
                    InDeferredContext, 
                    InSystemArgument1, 
                    InSystemArgument2);
        }
        else
            InDpc->DeferredRoutine(
                InDpc, 
                InDeferredContext, 
                InSystemArgument1, 
                InSystemArgument2);
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
    }
} 

我们首先检查 DPC 例程是在内核镜像(所有十个 DPC reside 的地方)内,还是在内存池(动态调用存根 reside 的地方)内。这样我们可以减少取消非 PatchGuard DPC 的可能性,因为内核不太可能将伪随机数作为 DeferredContext。我也不知道任何需要动态 DPC 的正常驱动程序。经过此检查后,我们可以跳过所有明显的 PatchGuard DPC 和驻留在动态内存中的 DeferredRoutines

您可能对异常帧感到疑惑。我之前提到过,PatchGuard 3 引入了一种不使用异常且仅使用规范 DeferredContexts 的代码路径。这样,它将通过我们的过滤器并到达 KeBugCheckEx。现在的问题是 PatchGuard 可能会决定直接调用它(不使用工作线程),从而导致我们的钩子在 DPC 级别运行并导致错误检查(因为在 DPC 级别尝试等待是不允许的)。我通过用断点覆盖一些指纹解决了这个问题,这实际上将这种非 SEH 代码路径转换为 SEH 代码路径,因为未处理的断点会引发可捕获的异常!以下是所有内存驻留动态方法的原型

nt!KiTimerDispatch:
    6690                   xchg      ax,ax
    9c                     pushfq
    4883ec20               sub       rsp,20h
    8b442420               mov       eax,dword ptr [rsp+20h]
    4533c9                 xor       r9d,r9d 
    4533c0                 xor       r8d,r8d   
    4889442430             mov       qword ptr [rsp+30h],rax
    488b4140               mov       rax,qword ptr [rcx+40h]
    48b90000000000f8ffff   mov       mov rcx,0FFFFF80000000000h
    4833c2                 xor       rax,rdx          
    480bc1                 or        rax,rcx         
    48b9f048311148315108   mov       rcx,8513148113148F0h <<<<<<
    488b10                 mov       rdx,qword ptr [rax]
    c700f0483111           mov       dword ptr [rax],113148F0h  
    4833d1                 xor       rdx,rcx     
    488bc8                 mov       rcx,rax       
    ffd0                   call      rax        
    4883c420               add       rsp,20h               
    59                     pop       rcx        
    c3                     ret                           

驱动程序将搜索粗体打印的字节序列并用断点覆盖它;就这样!

请注意,异常帧对于 SEH 代码路径没有任何优势。我尝试在不进行 DPC 过滤的情况下捕获它,但驱动程序验证程序似乎不喜欢它。幸运的是,SEH 代码路径总是使用非规范 DeferredContext 值,这就是为什么我们可以完全过滤掉它。

2.4 钩子 ExpWorkerThread


仅仅修补 DPC 代码是不够的。PatchGuard 3 也使用工作项来完成相同的任务。我们的工作线程拦截器看起来类似,但并不相同

VOID VistaAll_ExpWorkerThreadInterceptor(
            PWORKER_THREAD_ROUTINE InRoutine, 
            VOID* InContext, 
            VOID* InRSP)
{
    ULONGLONG        Val = (ULONGLONG)InRoutine;
    
    if((Val >= 0xfffffa8000000000) && (Val <= 0xfffffaa000000000))
        return;

    __try
    {
        InRoutine(InContext);
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
    }
} 


我们在这里所做的是过滤掉所有驻留在动态内存中的例程,就像我们在 DPC 钩子中做的那样。此外,我们将所有其他例程包装在一个 try-except 语句中。PatchGuard 工作线程的上下文是规范的,所以我们无法显式地将其过滤掉。
到目前为止,我所经历的是 PatchGuard 也为工作队列使用动态方法。在像上面那样阻止它们之后,仍然有一些错误检查。它们源自 ExpWorkerThread,但从未通过我们的处理程序!这很奇怪,因为反汇编显示我们只有一个点实际调用工作项。我只能想象 PatchGuard 再次使用异常将执行重定向到特殊的异常处理程序,就像它对 DPC 处理程序所做的那样。这就是为什么我也将调用包装在 try-except 语句中!
修补工作队列的过程与 DPC 队列非常相似。我们需要修补以下字节

nt!ExpWorkerThread+0x11a:
    5c              pop     rsp   
    2470            and     al,70h
    4c8b6318        mov     r12,qword ptr [rbx+18h]
    488b7b10        mov     rdi,qword ptr [rbx+10h]
    >498bcc          mov     rcx,r12
    >ffd7            call    rdi
    4c8d9ee0020000  lea     r11,[rsi+2E0h]
    4d391b          cmp     qword ptr [r11],r11 


并将它们重定向到同一个跳转表。不同之处在于我们不在跳转表中恢复寄存器,而是在我们驱动程序中预先准备好的跳转目标中恢复

VistaSp0_ExpWorkerThread_Fix PROC



    mov            rcx, rdi
    mov            rdx, r12
    mov            r8, rsp
    jmp            VistaAll_ExpWorkerThreadInterceptor

VistaSp0_ExpWorkerThread_Fix ENDP 


这将调用上述拦截器,我们就完成了!

2.5 钩子 KeBugCheckEx


到目前为止,我们已经能够阻止,比如说,95% 的系统检查例程调用。另外 5% 将通过我们的 DPC 过滤器,仍然会导致蓝屏死机。我之前说过,我们将钩子 KeBugCheckEx 来解决这个问题。但这并不那么容易,因为 PatchGuard 在调用之前会用新的副本覆盖该方法。所以我们需要钩子一个子例程。如果我们查看第一条指令

nt!KeBugCheckEx:

    48894c2408           mov     qword ptr [rsp+8],rcx
    4889542410           mov     qword ptr [rsp+10h],rdx 
    4c89442418           mov     qword ptr [rsp+18h],r8
    4c894c2420           mov     qword ptr [rsp+20h],r9
    9c                   pushfq
    4883ec30             sub     rsp,30h    
    fa                   cli
    65488b0c2520000000   mov     rcx,qword ptr gs:[20h]
    4881c120010000       add     rcx,120h
    e8c1050000           call    nt!RtlCaptureContext 
,我们看到 RtlCaptureContext 似乎非常适合我们的任务。这是因为 KeBugCheckEx 会在很早的时候调用它,因此系统仍然处于正常状态。为了钩子 RtlCaptureContext,我们需要像往常一样放置一个跳转器

Nt!RtlCaptureContext:

    50          push     rax
    48b9Xxx     mov      rax, PG3Disable! RtlCaptureContext_Hook
    ffe0        jmp      rax 

不同之处在于,我们正在挂钩一个上下文捕获方法,因此必须确保此上下文不会被我们的挂钩更改。这就是为什么我们必须备份易失性寄存器。跳转器将在一个本机拦截存根中继续执行,该存根仅用于挂钩 RtlCaptureContext

RtlCaptureContext_Hook PROC

    ; call high level handler without messing up the context structure...
    pushfq
    push        rcx
    push        rdx
    push        r8
    push        r9
    push        r10
    push        r11
    mov         rcx, qword ptr[rsp + 136]
    mov         rdx, qword ptr[rsp + 8 * 8]
    sub         rsp, 32
    call        KeBugCheck_Hook
    mov         qword ptr [rsp], rax
    add         rsp, 32
    pop         r11
    pop         r10
    pop         r9
    pop         r8
    pop         rdx
    pop         rcx
    popfq
    pop         rax
    
    ; recover destroyed bytes of RtlCaptureContext
    pushfq
    mov         word ptr [rcx+38h],cs
    mov         word ptr [rcx+3Ah],ds
    mov         word ptr [rcx+3Ch],es
    mov         word ptr [rcx+42h],ss
    
    ; jump behind destroyed bytes... (return value of KeBugCheck_Hook)
    jmp         qword ptr[rsp - 32 - 8 * 7 + 8]  
 
RtlCaptureContext_Hook ENDP 

首先,它安全地调用我们的 KeBugCheck_Hook 方法,以错误检查代码作为第一个参数,以 RtlCaptureContext 的调用者作为第二个参数。其次,它恢复 RtlCaptureContext 中被覆盖的指令,最后在跳转器之后继续执行。
如果您现在查看 KeBugCheck_Hook

ULONGLONG KeBugCheck_Hook(ULONGLONG InBugCode, ULONGLONG InCaller)
{
    FAST_MUTEX        WaitAlways;

    if((InCaller >= KeBugCheckEx_Sym) && 
        (InCaller <= KeBugCheckEx_Sym + 100))
    {
        if(InBugCode == CRITICAL_STRUCTURE_CORRUPTION)
        {
            EnableInterrupts();

            ExInitializeFastMutex(&WaitAlways);

            ExAcquireFastMutex(&WaitAlways);

            ExAcquireFastMutex(&WaitAlways);
        }
    }

    return RtlCaptureContext_Sym + 14;
} 
,您可能会发现它只是检查调用者是否为 KeBugCheckEx,并且错误检查代码是否为 CRITICAL_STRUCTURE_CORRUPTION。如果是这种情况,它会重新启用中断,并永久阻塞线程。我们之所以能够这样做,是因为我们排除了此错误检查是通过 DPC 调度程序引发的可能性,如果我们跳过我们的 DPC 过滤器,它肯定会发生!如果调用者是任何其他符号,我们只需执行 RtlCaptureContext()。我假设每个线程切换都会调用此方法,所以这是我们正在挂钩的下一个关键 Windows 执行路径……
我确信会有人问这是否会导致一个周期为几分钟的无限线程创建循环。同样,我们只能这样做,因为我们已经禁用了 DPC 代码路径。由于调用方法是随机选择的,我们会导致一两个孤立线程,直到 PatchGuard 不再执行(因为它不总是使用 ExQueueWorkItem,如果我们阻止所有其他代码路径,则在某个时间点不再有 PatchGuard 上下文)。

手术成功,病人死亡

那么我们到目前为止都做了些什么?我们已经在 Windows Vista SP1 上禁用了 PatchGuard 3,所有更新都已安装。当然,我们应用的补丁并非常见的编码风格;-)。但每个人都会同意,潜在的恶意软件就是这样编写的,而且这些补丁对于给定的操作系统来说实际上非常稳定。您可以在大约 20 分钟后回滚所有更改。原因是 PatchGuard 只有在调用系统检查例程时才会添加新的代码路径。所以当我们阻止其执行一段合理的时间后,就没有什么可阻止的了……我的驱动程序目前不支持回滚更改,实际上也永远不会支持。这也是为什么您在修补后无法卸载它的原因。

3 使用和编译驱动程序


如果您从未开发过 64 位驱动程序,您会遇到几个障碍需要解决。因此,我准备了一个干净的 Windows 安装。现在我将介绍如何构建和安装我的驱动程序。

3.1 准备构建环境


首先,您应该从 https://connect.microsoft.com/ 下载最新的 Windows 驱动程序工具包,并将其安装到例如 “C:\WinDDK”。您需要注册并登录到 Microsoft Passport,但我相信它仍然是免费的。
然后,您必须添加一个名为 “WDKROOT” 的系统范围环境变量,其值为您的安装路径。
从现在开始,我假设您已将 DisablePatchGuard 存档解压到 “C:\PGDisable”;因此项目解决方案位于 “C:\PGDisable\PGDisable.sln”
要在 Windows Vista 上安装驱动程序,您必须对其进行签名。现在打开一个 root-shell 并输入以下命令

> Bcdedit –set TESTSIGNING ON


您需要重新启动电脑。之后,我们就可以创建一个测试证书了
> cd c:\winddk\bin\SelfSign
> MakeCert -r -pe -ss PGDisableCertStore -n  "CN=PGDisableCert"  “c:\PGDisable\PGDisableCert.cer” 

现在双击在资源管理器中打开证书。目前该证书是不可信的。要使 Windows 信任该证书,请单击 “安装证书” – “下一步” – “将所有证书放入以下存储” – “受信任的根证书颁发机构” – “确定” – “下一步” – “完成”,并执行相同的步骤将其添加到 “受信任的发布者” 存储中。
该项目带有一个构建后事件,它依赖于名为 “PGDisableCert” 的证书存在于 “PGDisableCertStore” 中的事实。这将在每次构建后自动签名驱动程序,您无需担心任何事情。

我们完成了,您可以开始构建项目了!

3.2 服务管理


为了保持代码简洁,我没有提供太多服务管理功能。应用程序会检查驱动程序是否已安装。如果未安装,它会安装驱动程序。如果已安装,它只会确保驱动程序正在运行。

但是,如果您想经常重新编译它,您需要确保在重新启动应用程序之前,驱动程序已卸载并从服务控制管理器中删除。否则,您新编译的驱动程序将无法加载,因为旧的驱动程序已经存在……
如果您想从服务管理器中删除驱动程序,只需在 root shell 中输入 “sc delete PG3Disable.sys”。如果驱动程序当前正在运行,此命令仍将成功,但驱动程序实际上仍会保持安装状态。为了防止这种情况,您还必须使用 “sc stop PG3Disable.sys” 来停止它。但请记住,由于缺少补丁回滚,您将无法在当前系统会话中再次加载驱动程序(如果您禁用了 PatchGuard)!

在开发过程中,频繁的系统重启是很常见的。这就是我为什么在虚拟机中开发此类驱动程序的原因。


3.3 驱动程序接口


这些驱动程序实际上非常易于使用。下表显示了 PG3Disable-Driver 支持的控制代码

IOCTL_PATCHGUARD_DUMP

将指纹列表写入 “C:\patchguard.log”。如果您在禁用 PatchGuard 后执行此命令,该文件应该只包含八个自定义访问例程。

IOCTRL_PATCHGUARD_DISABLE

成功时将静默禁用 PatchGuard。

这两个控制代码都没有参数,也没有返回值。唯一有的是通过 GetLastError 可用的状态码

ERROR_SUCCESS

操作已成功完成。

ERROR_NOT_SUPPORTED

如果您的系统不受支持或 PatchGuard 已被驱动程序修补,则会返回此值。出于稳定性原因,驱动程序有时可能会拒绝修补;十分钟后,您再试 5-10 分钟。

所有其他错误值并非由我的代码显式引发,而是可能由调用的内核 API 返回。

PG2Disable-Driver 的工作方式非常相似,但有两个主要区别。首先,您可以多次禁用 PatchGuard,并且始终会得到 ERROR_SUCCESS 的结果。其次,您可以根据需要多次加载/卸载驱动程序。此外,它还导出一个额外的命令

IOCTL_PATCHGUARD_PROBE

KeCancelTimer 安装一个测试钩子。请注意,如果 PatchGuard 尚未禁用,您的系统将蓝屏死机!
请注意,PG2Disable 在 Windows Vista SP1 上无法工作。

4 PatchGuard 4 的想法


我非常感谢微软迄今为止为强化 PatchGuard 所做的一切。我不认为将禁用 PatchGuard 所需的工作量提高到可以认为是不可利用的程度需要付出很大的努力。我会将 DPC 调度程序作为主要的故障点。微软应该通过调试器隐藏关键符号。这可能可以通过服务中断导出适当的调试 API 来完成,这些 API 在内部使用所有必需的符号;这样调试器就不需要了解它们,但仍然可以保持所有功能。此外,某种形式的延迟代码执行的私有方式也会改善整个事情。

实现这个想法的一个实际方法是定义一个名为 “PATCHGUARD_INVOKATION” 的宏,并将其用于所有 Windows 源代码中,但仅用于更高阶的未导出 API(不在任何公共 API 中调用)。然后,一个预构建事件可以自动将此类宏替换为随机生成的调用存根,或者在大多数情况下甚至不执行任何操作。可以基于一个常量值来决定随机性,这样主要更新将使用不同的常量值,从而产生完全不同的 PatchGuard 调用存根,而次要更新不会对二进制文件中的 PatchGuard 相关部分造成任何更改。

延迟执行的形式可以通过在内部中断处理程序中添加额外代码,并且仅在计数器递增 X 次后调用 PatchGuard 来进一步改进。然后,此代码可以以更昂贵的方式再次检查是否“是时候”执行系统检查。即使仍然可以指纹识别 PatchGuard 的许多部分,也无法以稳定的方式禁用它,因为总会有一些部分缺失。

此外,系统检查例程的多个版本,然后当然应该通过代码变形引擎,也会发挥作用。

记住,目标不是让 PatchGuard 在单台机器上无法被利用。目标是防止恶意软件在广泛的机器上以自动化方式禁用它。

5 Windows 定时器内部机制


现在我想写一些关于我最初对 PatchGuard 3 的调查。一开始,我以为我只需取消所有 PatchGuard 计时器就可以了。如您所见,我错了,但这就是编程的魅力;-)。

我不想把这与禁用 PatchGuard 联系起来。原因是 PG3 实际上不能用计时器取消方法来利用。PG2 确实可以,并且 PG2Disable-Driver 拥有完成此操作所需的所有代码,并且还展示了如何稳定地提取未导出的内核符号(自 Service Pack 1 以来不起作用;在这里您必须使用指纹识别,例如 KiRetireDpcList 所做的那样)。

表面上,网上没有显示如何枚举计时器的内容。WDK 文档中也没有。所以我最终反汇编了一些计时器例程,如 KeSetTimerExKeCancelTimer 等。KeCancelTimer 似乎是最好的起点,因为它非常小

// push    rbx
// sub     rsp,20h
KIRQL                OldIrql = 0;
BOOLEAN              Existed = FALSE;
PKSPIN_LOCK_QUEUE    LockArray = NULL;
ULONG                LockIndex = 0;
KTIMER_TABLE_ENTRY*  TimerEntry = NULL;

// mov     r9,rcx
// call    nt!KiAcquireDispatcherLockRaiseToSynch 
OldIrql = KiAcquireDispatcherLockRaiseToSynch();

// mov     bl,byte ptr [r9+3]    
// test    bl,bl
// mov     r10b,al    
Existed = InTimer->Header.Inserted;

// je      nt!KeCancelTimer+0x76 
if(Existed)
{
// nt!KeCancelTimer+0x19:
    // mov     rcx,qword ptr gs:[28h]
    LockArray = KeGetPcr()->LockArray;

    // movzx   r8d, byte ptr [r9+2]
    // mov     eax,r8d
    // shr     eax,4
    // and     eax,0Fh
    // add     eax,11h

    LockIndex = ((InTimer->Header.Hand / sizeof(KSPIN_LOCK_QUEUE)) & 0x0F) + LockQueueTimerTableLock;

    // shl     rax,4        
    // add     rcx,rax
    // call    nt!KeAcquireQueuedSpinLockAtDpcLevel 
    KeAcquireQueuedSpinLockAtDpcLevel(&LockArray[LockIndex]);

    // mov     byte ptr [r9+3],0
    InTimer->Header.Inserted = FALSE;

    // mov     rax,qword ptr [r9+28h]
    // mov     rdx,qword ptr [r9+20h]
    // cmp     rdx,rax    
    // mov     qword ptr [rax],rdx
    // mov     qword ptr [rdx+8],rax

    //jne     nt!KeCancelTimer+0x71 
    if(RemoveEntryList(&InTimer->TimerListEntry))
    {
    //nt!KeCancelTimer+0x58:
        // lea     rdx,[r8+r8*2]
        // lea     rax,[nt!KiTimerTableListHead]
        // lea     r8,[rax+rdx*8]
        TimerEntry = &KiTimerTableListHead[InTimer->Header.Hand];

        // cmp     r8,qword ptr [r8]
        //jne     nt!KeCancelTimer+0x71 
        if(TimerEntry == (KTIMER_TABLE_ENTRY*)TimerEntry->Entry.Flink)
        {
        //nt!KeCancelTimer+0x6c:
            // or      dword ptr [r8+14h],0FFFFFFFFh
            TimerEntry->Time.HighPart = 0xFFFFFFFF;
        }
    }

//nt!KeCancelTimer+0x71:
    //call    nt!KeReleaseQueuedSpinLockFromDpcLevel 
    KeReleaseQueuedSpinLockFromDpcLevel(&LockArray[LockIndex]);
}
//nt!KeCancelTimer+0x76:
//call    nt!KiReleaseDispatcherLockFromSynchLevel 
KiReleaseDispatcherLockFromSynchLevel();

// mov     cl,r10b
// call    nt!KiExitDispatcher 
KiExitDispatcher(OldIrql);

// mov     al,bl
return Existed;

// add     rsp,20h
// pop     rbx
// ret 

我已经插入了从反汇编推测的源代码。我不想解释如何获取它,因为这主要基于编译器构建和(反)汇编器的经验。

如您所见,其 C 代码相当直接。但是,计时器管理中存在一个错误,我稍后会回到这个问题。以下是一些完全未记录和未导出的内核符号的简短文档。

KIRQL KiAcquireDispatcherLockRaiseToSynch()

可能锁定计时器和/或 DPC 数据库。将 IRQL 提升到 DISPATCH_LEVEL 并返回之前的状态。
void KeAcquireQueuedSpinLockAtDpcLevel(PKSPIN_LOCK_QUEUE)

类似于公开可用的 KeAcquireInStackQueuedSpinLock。您可以使用此方法获取 KPCR::LockArray 中的任何锁。请注意,此方法应仅在 DISPATCH_LEVEL 调用。以下常量可能有助于索引正确的锁
             LockQueueDispatcherLock            0
             LockQueueExpansionLock             1
             LockQueuePfnLock                   2
             LockQueueSystemSpaceLock           3
             LockQueueVacbLock                  4
             LockQueueMasterLock                5
             LockQueueNonPagedPoolLock          6
             LockQueueIoCancelLock              7
             LockQueueWorkQueueLock             8
             LockQueueIoVpbLock                 9
             LockQueueIoDatabaseLock            10
             LockQueueIoCompletionLock          11
             LockQueueNtfsStructLock            12
             LockQueueAfdWorkQueueLock          13
             LockQueueBcbLock                   14
             LockQueueMmNonPagedPoolLock        15
             LockQueueTimerTableLock            17 

void KeReleaseQueuedSpinLockFromDpcLevel (PKSPIN_LOCK_QUEUE)

类似于公开可用的 KeReleaseInStackQueuedSpinLock。您必须调用它来释放 KPCR::LockArray 中之前获取的任何锁。请注意,此方法应仅在 DISPATCH_LEVEL 调用。

void KiReleaseDispatcherLockFromSynchLevel()

可能在 DISPATCH_LEVEL 释放计时器/DPC 锁,并且不更改 IRQL。

void KiExitDispatcher(KIRQL InOldIrql)

应在 DISPATCH_LEVEL 调用,并将 IRQL 降低到 InOldIrql。我认为此方法未与前一方法结合,以允许在实际降低 IRQL 之前在 DISPATCH_LEVEL(不持有锁)执行更多操作。我最近在一些微软论文中读到,它也可能在 DPC 级别操作完成后调度一个新线程……

考虑到这些信息,我们准备构建自己的枚举方法

// a little helper…
PKSPIN_LOCK_QUEUE KeTimerIndexToLockQueue(UCHAR InTimerIndex)
{
    return &(KeGetPcr()->LockArray[((InTimerIndex / sizeof(KSPIN_LOCK_QUEUE)) & 0x0F) + LockQueueTimerTableLock]);
}

// this is where the enumeration starts
OldIrql = KiAcquireDispatcherLockRaiseToSynch();

for(Index = 0; Index < TIMER_TABLE_SIZE; Index++)
{
    LockQueue = KeTimerIndexToLockQueue((UCHAR)(Index & 0xFF));

    KeAcquireQueuedSpinLockAtDpcLevel(LockQueue);
    
    // now we can work with the timer list...
    TimerListHead = &KiTimerTableListHead[Index];
    TimerList = TimerListHead->Entry.Flink;

    while(TimerList != (PLIST_ENTRY)TimerListHead)
    {
        Timer = CONTAINING_RECORD(TimerList, KTIMER, TimerListEntry);        TimerList = TimerList->Flink;

        // TODO: work with the timer…
    }

    KeReleaseQueuedSpinLockFromDpcLevel(LockQueue);
}

KiReleaseDispatcherLockFromSynchLevel();

KiExitDispatcher(OldIrql); 

如果您现在想在枚举期间取消计时器,可以使用以下代码片段

// TODO: work with the timer…
Timer->Header.Inserted = FALSE;

if(RemoveEntryList(&Timer->TimerListEntry))
    TimerListHead->Time.HighPart = 0xFFFFFFFF; 

自 PatchGuard 2 以来,计时器 DPC 已加密,因此请勿尝试解引用指针。PG2Disable-Driver 向您展示了如何获取两个内部解密密钥 KiWaitNeverKiWaitAlways。使用这些符号,您可以使用以下代码解密 KDPC 指针

ULONGLONG        RDX = (ULONGLONG)Timer->Dpc;

RDX ^= InKiWaitNever;
RDX = _rotl64(RDX, *KiWaitNever & 0xFF);
RDX ^= (ULONGLONG)Timer;
RDX = _byteswap_uint64(RDX);
RDX ^= *KiWaitAlways;

return (KDPC*)RDX;  

现在您可以以稳定且互锁的方式枚举所有 Windows 计时器,就像操作系统所做的那样。请注意,在枚举期间不能调用 KeCancelTimer,因为这会导致死锁!PG2Disable-Driver 可以将所有计时器信息写入日志文件,即使我们在枚举期间在 DPC 级别运行。

5.1 Windows 定时器错误


现在我们可以比较我们两个代码部分。在 KeCancelTimer 的代码中,我们有

TimerEntry = &KiTimerTableListHead[InTimer->Header.Hand];

在我们的枚举中

TimerListHead = &KiTimerTableListHead[Index];

嗯,表面上它们看起来是相同的。但是这里的区别在于,我们枚举中的 Index 实际上范围是从零到 511。这是因为公共 WDK 常量 TIMER_TABLE_SIZE 的值为 512。现在您可能会看到问题:根据公开可用的 DISPATCH_HEADER 结构,InTimer->Header.Hand 只有一字节宽。如果计时器放置在索引大于 255 的链表中,这会导致 Hand 溢出。这也解释了我们从 KeCancelTimer 中提取的奇怪开关,它再次检查链表是否为空,即使使用 RemoveEntryList 已经证明了这一点。Redmon 可能意识到有些不对劲,并应用了这个变通方法,以确保只有空计时器列表的时间戳被重置。

但这实际上似乎并没有造成太大的麻烦。只是我们通过纯粹的逆向工程发现了这样一个错误的原因,这很有趣。
© . All rights reserved.