内核补丁保护(三)触发 PG 检查

😄

0 触发PG检查方式

从目前的研究来看,触发 PG 检查的方法有很多种。主要为 DPC 函数执行、系统线程执行、APC 等。

1 DPC 函数触发 PG 检查

1.1 DPC函数触发PG

以下 DPC 函数执行的时候,会触发 PatchGuard 检查执行。前 10 个函数中,会通过触发异常来开始 PatchGuard 检查执行(通过检查参数DeferredContext是否是Canonical地址);而后两个函数是是直接调用 PatchGuard 检查执行。

注意:KiBalanceSetManagerDeferredRoutine 函数仅用于方法 5。

1
2
3
4
5
6
7
8
9
10
11
12
ExpTimerDpcRoutine;
ExpTimeZoneDpcRoutine;
ExpCenturyDpcRoutine;
ExpTimeRefreshDpcRoutine;
CmpLazyFlushDpcRoutine;
CmpEnableLazyFlushDpcRoutine;
IopTimerDispatch;
IopIrpStackProfilerDpcRoutine;
KiBalanceSetManagerDeferredRoutine;
PopThermalZoneDpc;
KiTimerDispatch;
KiDpcDispatch;

下面以 ExpTimerDpcRoutine 函数来进行分析。

1
2
3
4
5
6
VOID __fastcall ExpTimerDpcRoutine (
IN PKDPC Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
)
  1. 该函数开头会校验参数 DeferredContext 是否是 Canonical 地址:

    • 如果是规范地址或者 0,则执行正常的 DPC 处理流程。
    • 如果不是规范地址,则将会跳转到 loc_14048981C
    1
    2
    3
    4
    5
    6
    7
    .text:000000014035E095                 mov     rax, rbx        // DeferredContext
    .text:000000014035E098 sar rax, 2Fh // 规范地址:FFFFFFFF`FFFFFFFF, -1
    .text:000000014035E098 // 不规范地址:FFFFFFFF`FFFFFFFE, -2
    .text:000000014035E09C mov r14d, 1
    .text:000000014035E0A2 add rax, r14
    .text:000000014035E0A5 cmp rax, r14 // CF | ZF == 0, jmp
    .text:000000014035E0A8 ja loc_14048981C // Non-Canonical
    注意: ja 指令不是简单用于比较大小,实际上是通过 rflags.CF 和 rflags.ZF 来判断是否跳转。

    2.png

    58.png

    3.png

  2. 然后进入到 __try 块里面。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    .text:000000014048981C loc_14048981C:                          
    .text:000000014048981C ; __unwind { // __C_specific_handler ; Non-Canonical
    .text:000000014048981C and [rsp+1B8h+var_184], 0
    .text:0000000140489821 mov byte ptr [rdi], 0
    .text:0000000140489824 mov rax, r15
    .text:0000000140489827 shr rax, 8
    .text:000000014048982B mov [rdi+20h], rax
    .text:000000014048982F mov [rsp+1B8h+var_47], rsi
    .text:0000000140489837 mov ecx, esi
    .text:0000000140489839 mov rax, rbx
    .text:000000014048983C rol rax, cl
    .text:000000014048983F mov [rsp+1B8h+var_97], rax
    .text:0000000140489847 mov rax, rdi
    .text:000000014048984A ror rax, cl
    .text:000000014048984D mov [rsp+1B8h+var_4F], rax
    .text:0000000140489855 xor [rdi+28h], r15
    .text:0000000140489859 xor [rdi+30h], rsi
    .text:000000014048985D
    .text:000000014048985D loc_14048985D:
    .text:000000014048985D
    .text:000000014048985D ; __try { // __finally(ExpTimerDpcRoutine$fin$2)
    .text:000000014048985D ; __try { // __finally(ExpTimerDpcRoutine$fin$0)
    .text:000000014048985D ; __try { // __except at loc_1404898AB
    .text:000000014048985D ; __try { // __except at loc_140489868
    .text:000000014048985D mov rcx, rbx
    .text:0000000140489860 call KiCustomAccessRoutine0
    .text:0000000140489865 nop
    .text:0000000140489865 ; } // starts at 14048985D

    可以看到,按照执行流程,会进入到 000000014048985D 的子 __try 里面调用 KiCustomAccessRoutine0 函数。

    这里需要对 KiCustomAccessRoutineX 函数和 KiCustomRecurseRoutineY 函数进行解释(0 <= X,Y <= 9)。

    上述的 11 个 DPC 函数中会调用硬编码好的 KiCustomAccessRoutineX 函数,然后该函数调用对应的 KiCustomRecurseRoutineX 函数。进入到 KiCustomRecurseRoutineX 函数后,就像进入到”俄罗斯轮盘游戏“(在左轮手枪塞入一颗子弹,然后随意转动转轮并开枪,在一定概率下子弹会被命中),如下图。

    16

    使用 AccessX 函数进入到 Recurse 轮盘。

    • AccessX 函数。

      函数会 (DeferredContext & 0x3) + 1,取参数低 2 位并加 1,带入到 Recurse 函数。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      .text:0000000140402FD0 KiCustomAccessRoutine0 proc near         
      .text:0000000140402FD0 ; __unwind { // KiCustomAccessHandler0
      .text:0000000140402FD0 sub rsp, 28h
      .text:0000000140402FD4 mov rdx, rcx
      .text:0000000140402FD7 and ecx, 3
      .text:0000000140402FDA inc ecx
      .text:0000000140402FDC ror rax, cl
      .text:0000000140402FDF xor r8, rax
      .text:0000000140402FE2 xor r9, rax
      .text:0000000140402FE5 xor r10, rax
      .text:0000000140402FE8 xor r11, rax
      .text:0000000140402FEB xor eax, eax
      .text:0000000140402FED call KiCustomRecurseRoutine0
      .text:0000000140402FF2 nop
      .text:0000000140402FF3 add rsp, 28h
      .text:0000000140402FF7 retn
      .text:0000000140402FF7 ; } // starts at 140402FD0
      .text:0000000140402FF7 KiCustomAccessRoutine0 endp
    • Recurse 函数。

      Recurse 轮盘函数里面如果 (DeferredContext & 0x3) + 1 递减为 0 时,就会从 rdx(DeferredContext) 里读取数据。本身 DeferredContext 就是 Non-Canonical 地址,所以这里会出发 #GP 异常。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      .text:0000000140402FB0 KiCustomRecurseRoutine0 proc near       ; CODE XREF: KiCustomRecurseRoutine9+8↑p
      .text:0000000140402FB0 ; KiCustomAccessRoutine0+1D↓p
      .text:0000000140402FB0 ; DATA XREF: ...
      .text:0000000140402FB0 sub rsp, 28h
      .text:0000000140402FB4 dec ecx
      .text:0000000140402FB6 jz short loc_140402FBD
      .text:0000000140402FB8 call KiCustomRecurseRoutine1
      .text:0000000140402FBD
      .text:0000000140402FBD loc_140402FBD: ; CODE XREF: KiCustomRecurseRoutine0+6↑j
      .text:0000000140402FBD mov eax, [rdx]
      .text:0000000140402FBF add rsp, 28h
      .text:0000000140402FC3 retn
      .text:0000000140402FC3 KiCustomRecurseRoutine0 endp

      注意观察,在上面的 __try 块,对应的 __exceptloc_140489868。在 loc_140489868 里就会开始对 PatchGuard 进行解密了。

      1
      2
      3
      4
      5
      .text:000000014048985D ;       __try { // __except at loc_140489868
      .text:000000014048985D mov rcx, rbx
      .text:0000000140489860 call KiCustomAccessRoutine0
      .text:0000000140489865 nop
      .text:0000000140489865 ; } // starts at 14048985D
  3. 异常处理块。

    找到对应的异常处理块如下,查看其异常处理过滤器 ExpTimerDpcRoutine$filt$1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    .text:0000000140489868 loc_140489868:                          
    .text:0000000140489868 ; __except(ExpTimerDpcRoutine$filt$1) // owned by 14048985D
    .text:0000000140489868 and [rsp+1B8h+var_F8], 0
    .text:0000000140489871 mov rax, [rsp+1B8h+var_47]
    .text:0000000140489879 mov [rsp+1B8h+var_F8], rax
    .text:0000000140489881 mov rdx, [rsp+1B8h+var_97]
    .text:0000000140489889 mov ecx, eax
    .text:000000014048988B ror rdx, cl
    .text:000000014048988E mov eax, [rdx]
    .text:0000000140489890 mov r14d, 1
    .text:0000000140489896 mov rbx, [rsp+1B8h+arg_8]
    .text:000000014048989E mov rdi, [rsp+1B8h+arg_0]
    .text:000000014048989E ; } // starts at 14048985D
  4. 接下来会对 PGContext 进行解密。解密代码可能驻留在异常过滤器、异常处理程序或终止处理程序中。定位解密代码的小技巧就是:解密代码中会使用 KiWaitAlwaysKiWaitNever 两个全局变量中的随机值。实际上这两个全局变量是用来解密 DPC 的(在异常过滤函数中是用来解密PGContext),在 PGContext 填充过程中保存了这两个全局变量,但是并没有看到用来加密 PGContext

1.2 第一层解密

解密一共分为两层

  • 第一层在 DPC 函数中(如 ExpTimerDpcRoutine 函数其中的一个异常处理过滤器)。
  • 第一层解密完成后就会调用 PGContext 中加密的 CmpAppendDllSection 函数,在 CmpAppendDllSection 函数中完成第二层解密。

在上一节 1.1 的异常过滤器 ExpTimerDpcRoutine$filt$1 中,会进行第一层解密

  1. 如下代码,会将解密后的 &pg_entry 保存在 [rbp+58h]。紧接着过滤器会对 pg_entry 进行验证。

    这里的 pg_entry 实际上就是 PGContext.CmpAppendDllSection,地址是随机的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    .text:00000001404140A0 ExpTimerDpcRoutine$filt$1:              
    .text:00000001404140A0 ; __except filter // owned by 14048985D
    .text:00000001404140A0 push rbp
    .text:00000001404140A2 sub rsp, 30h
    ...
    .text:00000001404141B1 mov rax, [rbp+0E8h]
    .text:00000001404141B8 xor rcx, rax
    .text:00000001404141BB mov [rbp+98h], rcx
    .text:00000001404141C2 mov rax, [rbp+98h]
    .text:00000001404141C9 mov rcx, 0FFFF800000000000h
    .text:00000001404141D3 or rax, rcx
    .text:00000001404141D6 mov [rbp+98h], rax
    .text:00000001404141DD mov rax, [rbp+98h]
    .text:00000001404141E4 mov [rbp+58h], rax // 计算得到 [rbp+58h],里面保存 pg_entry 的地址
  2. 由上一步可知, 解密后的 &pg_entry 保存在 [rbp+58h] 中。接下来会将函数 CmpAppendDllSection 8 字节写入 pg_entry,然后进行异或。紧接着再将函数 CmpAppendDllSection 4 字节(0x1131482E)重写入 pg_entry,最后调用 pg_entry

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    .text:00000001404144AA                 mov     rax, 85131481131482Eh // CmpAppendDllSection 前 8 字节
    .text:00000001404144B4 mov [rbp+48h], rax
    ...
    .text:00000001404144DE mov qword ptr [rbp+48h], 1131482Eh // CmpAppendDllSection 前4字节
    .text:00000001404144E6 mov rax, [rbp+48h] // rax = rcx = [rbp + 48h]
    .text:00000001404144EA mov rcx, [rbp+48h] // rax = rcx = [rbp + 48h]
    .text:00000001404144EE mov [rbp+48h], rcx // rax = rcx = [rbp + 48h]
    .text:00000001404144F2 mov rcx, [rbp+48h] // rax = rcx = [rbp + 48h]
    .text:00000001404144F6 mov rax, [rbp+58h] // rax = &pg_entry
    .text:00000001404144FA mov [rax], ecx // *(QWORD*)&pg_entry = 0x1131482E;
    .text:00000001404144FC mov eax, [rbp+8Ch] // [rbp+8Ch] == 0
    .text:0000000140414502 test al, 1
    .text:0000000140414504 jz short loc_14041451C
    ...
    .text:000000014041451C loc_14041451C:
    .text:000000014041451C mov rax, [rbp+58h]
    .text:0000000140414520 xor r9d, r9d
    .text:0000000140414523 xor r8d, r8d
    .text:0000000140414526 mov rdx, [rbp+50h]
    .text:000000014041452A mov rcx, [rbp+58h] // rax = &pg_entry
    .text:000000014041452A // rcx = &pg_entry
    .text:000000014041452A // rdx = [rbp + 0x50]
    .text:000000014041452A // r8 = 0
    .text:000000014041452A // r9 = 0
    .text:000000014041452E call _guard_dispatch_icall // jmp rax
    .text:000000014041452E // rax = &PGContext.CmpAppendDllSection
    .text:0000000140414533 nop

    可以看到这里对 &pg_entry 地址前 4 字节重写,然后就跳去指向 pg_entry

    这个 pg_entry 就是从 CmpAppendDllSection 拷贝到 PGContext 中的。下面将会执行该函数。

参考内容:

1.3 第二层解密CmpAppendDllSection

从上一篇内核补丁保护(二)初始化 3.1我们知道,CmpAppendDllSection 函数长度为 0xC8 字节,但是最后 4 字节是用 0x90(nop) 填充的 。从看雪文章《攻破 Window AMD64 平台的 PatchGuard - 搜索加密的 PatchGuard Context》知道,CmpAppendDllSection + 0xC4 保存着 PGContext_FirstSec 的长度。

17.png

注意:是解密得到 CmpAppendDllSection 函数,因为 PGContext 是加密的。

解密执行 CmpAppendDllSection 分为两部分:

  • 上半部分是自解密得到 CmpAppendDllSection
  • 下半部分是解密得出 PatchGuardEntryPointCheck PG 的核心函数)。

19.png

上半部分

上面已经分析到,pg_entry 的前 8 字节已经提前重写好,然后就跳转到 pg_entry 开始执行。

注意:如下图,此时的 pg_entry 函数仅有前 8 个字节,解密的秘钥存放在 rdx 寄存器中。 当第一条指令(4字节长度)执行结束后会解密出 8 字节数据,并写回到当前地址。这说明两点:

  1. 这是自解密代码,下面即将要执行的代码都会提前解密好,秘钥存放在 rdx 寄存器中。
  2. 页面必须具备可读、可写、可执行,否则会发生异常。

解密结果为:0xBC541CD31131482E ^ 0xB4052D9B560DCCFC = 0x08513148473C84D2。可以看到高 4 字节和 CmpAppendDllSection 前 4-7 字节完全符合。

18.png

CmpAppendDllSection 上半部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
INIT:0000000140A36190                                     CmpAppendDllSection proc near           
INIT:0000000140A36190 db 2Eh
INIT:0000000140A36190 2E 48 31 11 xor [rcx], rdx
INIT:0000000140A36194 48 31 51 08 xor [rcx+8], rdx
INIT:0000000140A36198 48 31 51 10 xor [rcx+10h], rdx
...
INIT:0000000140A361F0 xor [rcx+40h], rdx
INIT:0000000140A361F4 xor [rcx+48h], rdx
INIT:0000000140A361F8 sub rcx, 78h
INIT:0000000140A361FC xor [rcx], edx // 同该函数的第一条指令
INIT:0000000140A361FE mov rax, rdx
INIT:0000000140A36201 mov rdx, rcx
INIT:0000000140A36204 mov ecx, [rdx+0C4h]
INIT:0000000140A3620A test rax, rax // 正常情况下秘钥并不为 0,这里不会发生跳转
INIT:0000000140A3620D jz short loc_140A36220

说明:PG 检查时并不是直接执行复制到 PGContext 中的 CmpAppendDllSection 函数,而是需要执行的时候进行自解密得到 CmpAppendDllSection 函数。

下半部分

CmpAppendDllSection 下半部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
INIT:0000000140A361F8                 sub     rcx, 78h
INIT:0000000140A361FC xor [rcx], edx // 同该函数的第一条指令
INIT:0000000140A361FE mov rax, rdx
INIT:0000000140A36201 mov rdx, rcx
INIT:0000000140A36204 mov ecx, [rdx+0C4h] // 取到计算 PGContext_FirstSec 长度的 Index
INIT:0000000140A3620A test rax, rax // 正常情况下秘钥并不为 0,这里不会发生跳转
INIT:0000000140A3620D jz short loc_140A36220
INIT:0000000140A3620F loc_140A3620F: // 对整个PGContext从高地址往低地址解密
INIT:0000000140A3620F xor [rdx+rcx*8+0C0h], rax
INIT:0000000140A36217 ror rax, cl
INIT:0000000140A3621A btc rax, rax
INIT:0000000140A3621E loop loc_140A3620F
INIT:0000000140A36220
INIT:0000000140A36220 loc_140A36220: // CODE XREF: CmpAppendDllSection+7D↑j
INIT:0000000140A36220 mov eax, [rdx+7E8h]
INIT:0000000140A36226 add rax, rdx
INIT:0000000140A36229 sub rsp, 28h
INIT:0000000140A3622D call rax // PatchGuardEntryPoint 函数
INIT:0000000140A3622F add rsp, 28h
INIT:0000000140A36233 mov r8, [rax+110h]
INIT:0000000140A3623A lea rcx, [rax+798h]
INIT:0000000140A36241 mov edx, 1
INIT:0000000140A36246 jmp r8
INIT:0000000140A36246 ; ---------------------------------------------------------------------------
INIT:0000000140A36249 db 0Fh dup(90h)
INIT:0000000140A36249 CmpAppendDllSection endp

注意:PGContext_FirstSec 表示 PGContext 的第一部分,并不是完整的 PGContext。

从上面代码可以看到,解密完成后,实际上 CmpAppendDllSection + 0xC4 保存着用来计算 PGContext_FirstSec 总大小的索引值 index

即:PGContext_FirstSec.Length = Index*8+0xC0

  1. LOOP 循环。这里是将整个 PGContext_FirstSec 进行解密,从高地址向低地址进行解密。注意:这里每一轮解密都会使用 btc 修改密钥——btc rax, rax
  2. 调用 PatchGuardEntryPoint 函数。PatchGuardEntryPoint 函数是 Check PG 的核心函数。位于 PGContext_FirstSec + 0x7E8

PatchGuardEntryPoint 函数位于 INITKDBG 区段。

17.png

25.png

2 动态获取Pg_entry

从《内核补丁保护(一)初始化》知道,系统初始化时如果检测到有内核调试器存在就不会初始化 PGContext。所以要使被调试系统在调试状态下还能启用 PatchGuard 的方法:

在被调试系统开机时,先不打开 Windbg 调试器,等被调试设备启动开始转圈后(这时候大概PG已经完成初始化,并且BootDebug断点没有触发),然后立刻挂上调试器。这样就能保证在启用 PG 的情况下,还能存在调试器。记得保存快照

借助周壑 x64内核研究08_PatchGuard(2) 的方法触发 PatchGuard。方法是:在系统非分页内存池找到大小大于 0x8000 + 0xAA8 的内存块 PGContext(上一篇文章已经分析了,如下图),将其 PTE 的 NX 位置 1 触发异常动态得到 pg_entry 函数地址。

异常成功触发后(得到 pg_entry 函数地址 0xFFFF858BB8D0251D),但是在 Windows 19044 下,给该地址下硬件断点 ba 不会被命中(猜测在这之前 DR7 寄存器已被清空),如果下软件断点 bp 则直接异常蓝屏。尝试过以下两种方法,但是只有方法二成功。

  1. 方法一:用驱动下硬件断点。在驱动中写个死循环给 0xFFFF858BB8D0251D 下硬件断点,但是仅下一次断点也会触发异常蓝屏。
  2. 方法二:HOOK DPC 函数。分析上面获取 pg_entry 函数地址触发异常时,找到触发 PatchGuard 的位置,发现是 DPC 函数 nt!ExpCenturyDpcRoutine

14.png

方法一下硬件断点的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typedef NTSTATUS (*PNtGetThreadContext)(IN HANDLE, IN OUT PCONTEXT);
typedef NTSTATUS(*PNtSetThreadContext)(IN HANDLE, IN OUT PCONTEXT);

NTSTATUS HardBreak()
{
NTSTATUS bRet = STATUS_ACCESS_DENIED;
CONTEXT Context = { 0 };
UNICODE_STRING UnicodeZwGetThreadContext = { 0 };
UNICODE_STRING UnicodeZwGetThreadContext = { 0 };
PZwGetThreadContext pZwGetThreadContext;
PZwSetThreadContext pZwSetThreadContext;

RtlInitUnicodeString(&UnicodeZwGetThreadContext, L"ZwGetThreadContext");
RtlInitUnicodeString(&UnicodeZwSetThreadContext, L"ZwSetThreadContext");
pZwGetThreadContext = (PZwGetThreadContext)MmGetSystemRoutineAddress(&UnicodeZwGetThreadContext);
pZwSetThreadContext = (PZwSetThreadContext)MmGetSystemRoutineAddress(&UnicodeZwSetThreadContext);

Context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
bRet = pZwGetThreadContext(NtCurrentThread(), &Context);
Context.Dr0 = 0xFFFF858BB8D0251D;
Context.Dr7 |= 0x402;
Context.Dr7 &= 0xfff0ffff;
pZwSetThreadContext(NtCurrentThread(), &Context);

return 0;
}

使用周壑 x64内核研究08_PatchGuard(2) 的方法触发 PatchGuard。得到的堆栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
kd> kv
# Child-SP RetAddr : Args to Child : Call Site
00 fffff804`770b6d88 fffff804`74727fd2 : fffff804`770b6ef0 fffff804`745928e0 00000000`00000000 00000000`00000000 : nt!DbgBreakPointWithStatus
01 fffff804`770b6d90 fffff804`747275b6 : 00000000`00000003 fffff804`770b6ef0 fffff804`74622fc0 00000000`000000fc : nt!KiBugCheckDebugBreak+0x12
02 fffff804`770b6df0 fffff804`7460e1f7 : 00000000`00000000 00000000`00000000 8a000000`049e9863 fffff804`746201a5 : nt!KeBugCheck2+0x946
03 fffff804`770b7500 fffff804`7469a794 : 00000000`000000fc ffff858b`b8d0251d 8a000000`049e9863 fffff804`770b7770 : nt!KeBugCheckEx+0x107
04 fffff804`770b7540 fffff804`7468c2cf : 00000000`00000011 00000000`00000003 00000000`00000000 fffff804`770b7690 : nt!MiCheckSystemNxFault+0x12d7ec
05 fffff804`770b7580 fffff804`7444eacf : 8a000000`049e9863 00000000`00000011 fffff804`770b77f0 00000000`00000000 : nt!MiRaisedIrqlFault+0x142e43
06 fffff804`770b75d0 fffff804`7461c25e : 00000000`00000000 fffff804`742c5da4 ffffb181`2e9aba00 00000000`00000022 : nt!MmAccessFault+0x4ef
07 fffff804`770b7770 ffff858b`b8d0251d : fffff804`746283e3 00000000`00000000 00000000`00000000 fffff804`742eb2f0 : nt!KiPageFault+0x35e (TrapFrame @ fffff804`770b7770)
08 fffff804`770b7908 fffff804`746283e3 : 00000000`00000000 00000000`00000000 fffff804`742eb2f0 fffff804`770b7fd0 : 0xffff858b`b8d0251d
09 fffff804`770b7910 fffff804`745e3441 : fffff804`00000003 fffff804`770a3700 fffff804`7709e000 fffff804`770a4000 : nt!ExpCenturyDpcRoutine$fin$0+0x26d
0a fffff804`770b7970 fffff804`7461705f : fffff804`770a3700 fffff804`770b7f60 fffff804`770a3770 fffff804`770a3770 : nt!_C_specific_handler+0x1a1
0b fffff804`770b79e0 fffff804`7448cf64 : fffff804`770b87c0 fffff804`00000000 fffff804`770b87c0 fffff804`742eb2f0 : nt!RtlpExecuteHandlerForUnwind+0xf
0c fffff804`770b7a10 fffff804`745e3385 : fffff804`74294878 fffff804`00000001 fffff804`770a3770 fffff804`770a4000 : nt!RtlUnwindEx+0x2c4
0d fffff804`770b8130 fffff804`74616fdf : fffff804`742eb2f0 fffff804`770b8710 fffff804`745e32a0 00000000`00000000 : nt!_C_specific_handler+0xe5
0e fffff804`770b81a0 fffff804`7448ca77 : fffff804`770b8710 00000000`00000000 fffff804`770a3770 fffff804`7454158b : nt!RtlpExecuteHandlerForException+0xf
0f fffff804`770b81d0 fffff804`7448b676 : fffff804`770a3448 fffff804`770b8e20 fffff804`770a3448 ffff858b`b887914e : nt!RtlDispatchException+0x297
10 fffff804`770b88f0 fffff804`7460ef82 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : nt!KiDispatchException+0x186
11 fffff804`770b8fb0 fffff804`7460ef50 : fffff804`746201a5 ffff858b`bf363010 fffff804`760f3877 00000000`00000001 : nt!KxExceptionDispatchOnExceptionStack+0x12 (TrapFrame @ fffff804`770b8e70)
12 fffff804`770a3308 fffff804`746201a5 : ffff858b`bf363010 fffff804`760f3877 00000000`00000001 fffff804`74531af0 : nt!KiExceptionDispatchOnExceptionStackContinue
13 fffff804`770a3310 fffff804`7461bee0 : 00000000`00000000 fffff804`74012c9d 00000000`00000000 00000001`74610114 : nt!KiExceptionDispatch+0x125
14 fffff804`770a34f0 fffff804`7461836d : fffff804`7326b180 fffff804`7466e8b3 00000000`000020cd 00000000`00000000 : nt!KiGeneralProtectionFault+0x320 (TrapFrame @ fffff804`770a34f0)
15 fffff804`770a3680 fffff804`746182ad : fffff7fe`40016900 ffff8773`c142a624 00000000`89170ad2 ffff858b`bb8e8100 : nt!KiCustomRecurseRoutine2+0xd
16 fffff804`770a36b0 fffff804`746187ed : 00000000`00000003 fffff804`744e9d7b ffff858b`bb8e8328 00000000`00000000 : nt!KiCustomRecurseRoutine1+0xd
17 fffff804`770a36e0 fffff804`7461872d : ffff858b`bb8e8330 00000000`89170ad2 ffff858b`bb8e8300 fffff804`770a38a0 : nt!KiCustomRecurseRoutine0+0xd
18 fffff804`770a3710 fffff804`74618762 : 00000003`01010000 00000008`00000001 ffff858b`bb8e8010 00000000`00000000 : nt!KiCustomRecurseRoutine9+0xd
19 fffff804`770a3740 fffff804`7454158b : 00000000`00000003 00000000`89170ad2 00000000`00000000 00000000`ffffffff : nt!KiCustomAccessRoutine9+0x22
1a fffff804`770a3770 fffff804`74431a72 : 00000000`00000002 0a266942`5a5f252b fffff804`7326b180 00000000`00000080 : nt!ExpCenturyDpcRoutine+0x9b
1b fffff804`770a38e0 fffff804`74449369 : 00000000`00000000 fffff804`744937f5 00000000`00000000 00000000`000013a5 : nt!KiProcessExpiredTimerList+0x172
1c fffff804`770a39d0 fffff804`74611c8e : 00000000`00000000 fffff804`7326b180 fffff804`74f3ca00 ffff858b`bf66e080 : nt!KiRetireDpcList+0x9d9
1d fffff804`770a3c60 00000000`00000000 : fffff804`770a4000 fffff804`7709e000 00000000`00000000 00000000`00000000 : nt!KiIdleLoop+0x9e

#1a 号堆栈可以看到,PatchGuard 检查时在 DPC 函数 nt!ExpCenturyDpcRoutine 执行时,调用 nt!KiCustomAccessRoutine9 —> .. nt!KiCustomRecurseRoutine2 触发的异常。

然后使用上一篇的定位小技巧,静态分析 nt!ExpCenturyDpcRoutine 的每一个异常过滤器或 __finally 找到 KiWaitNever/KiWaitAlways。发现在 __finally(ExpCenturyDpcRoutine$fin$0),对应上 #09 号堆栈。此时也能看到 0xffff858bb8d0251d就是 pg_entry。所以方法二就是 HOOK ExpCenturyDpcRoutine$fin$0 函数。

3 HOOK ExpCenturyDpcRoutine$fin$0

3.1 寻找 HOOK 点

在 x64 下,不能使用 jmp/call 立即数地址,可以修改成 jmp/call 通用寄存器

如下图,ShellCode 一共 12 字节,分析了一下 ExpCenturyDpcRoutine$fin$0 函数。

20.png

观察 ExpCenturyDpcRoutine$fin$0 函数找一个 12 字节,尽量不要找有 jcc/call 的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.text:00000001404129AF                         ExpCenturyDpcRoutine$fin$0:             
.text:00000001404129AF ; __finally // owned by 1403319E3
.text:00000001404129AF 40 53 push rbx
.text:00000001404129B1 55 push rbp
.text:00000001404129B2 56 push rsi
.text:00000001404129B3 57 push rdi
.text:00000001404129B4 41 56 push r14
.text:00000001404129B6 48 83 EC 30 sub rsp, 30h
.text:00000001404129BA 48 8B EA mov rbp, rdx
.text:00000001404129BD 88 4D 50 mov [rbp+50h], cl
.text:00000001404129C0 84 C9 test cl, cl
.text:00000001404129C2 0F 84 9C 02 00 00 jz loc_140412C64
.text:00000001404129C8 8B 45 30 mov eax, [rbp+30h]
.text:00000001404129CB 83 F8 02 cmp eax, 2
.text:00000001404129CE 0F 85 6C 02 00 00 jnz loc_140412C40
.text:00000001404129D4 48 83 65 78 00 and qword ptr [rbp+78h], 0
.text:00000001404129D9 48 8B 8D FA 00 00 00 mov rcx, [rbp+0FAh]
.text:00000001404129E0 48 89 4D 78 mov [rbp+78h], rcx
.text:00000001404129E4 4C 8B 85 F2 00 00 00 mov r8, [rbp+0F2h]
.text:00000001404129EB 48 83 A5 80 00 00 00 00 and qword ptr [rbp+80h], 0
.text:00000001404129F3 48 8B 85 FA 00 00 00 mov rax, [rbp+0FAh]
.text:00000001404129FA 48 89 85 80 00 00 00 mov [rbp+80h], rax
.text:0000000140412A01 48 8B 95 AA 00 00 00 mov rdx, [rbp+0AAh]
...

如上找到如下 12 字节。

1
2
.text:00000001404129D4 48 83 65 78 00                          and     qword ptr [rbp+78h], 0
.text:00000001404129D9 48 8B 8D FA 00 00 00 mov rcx, [rbp+0FAh]

注意:这里的要跳转的目标地址是动态的,且驱动中无法关闭动态随机基址(项目属性—链接器—随机基址、固定基址),所以可以在驱动设置断点然后在 Windbg 中手动修改目标地址。

恢复快照后,得到 HOOK 点的地址如下:

1
2
3
4
kd> u fffff8047462819b
nt!ExpCenturyDpcRoutine$fin$0+0x25:
fffff804`7462819b 4883657800 and qword ptr [rbp+78h],0
fffff804`746281a0 488b8dfa000000 mov rcx,qword ptr [rbp+0FAh]

3.2 HOOK 代码

Main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#ifndef NTDDK
#define NTDDK
#include <ntddk.h>
#endif
#include <common.h>

ULONG64 g_PTE_BASE;
ULONG64 g_PDE_BASE;
ULONG64 g_PPE_BASE;
ULONG64 g_PXE_BASE;

EXTERN_C VOID _fastcall HookFuck(void);

ULONG64 get_pte_base()
{
PHYSICAL_ADDRESS physical_address;
ULONG64 pte_base = 0;
physical_address.QuadPart = __readcr3() & 0xfffffffffffff000; // 获取CR3寄存器,清除低12位
PULONG64 pxe_ptr = MmGetVirtualForPhysical(physical_address); // 获取其所在的虚拟地址 - 页表自映射
ULONG64 index = 0;
// 遍历比较
while ((pxe_ptr[index] & 0xfffffffff000) != physical_address.QuadPart) {
index++;
if (index >= 512) {
return 0;
}
}
// 计算pte基址
pte_base = ((index + 0x1fffe00) << 39);

g_PXE_BASE = (ULONG64)pxe_ptr & 0xFFFFFFFFFFFFF000; // PLM4E
g_PPE_BASE = (ULONG64)(g_PXE_BASE >> (9 + 12)) << 21; // PDPTE
g_PDE_BASE = (ULONG64)((ULONG64)pxe_ptr >> (9 * 2 + 12)) << 30;
g_PTE_BASE = ((ULONG64)pxe_ptr >> (9 * 3 + 12) << 39);

return pte_base;
}

VOID DriverUnload(PDRIVER_OBJECT driver)
{
DbgPrint("Driver is unloading...\r\n");
}

ULONG64 KeepPreAddress_1 = 0;
ULONG KeepPreAddress_2 = 0;
ULONG64 VA = 0xfffff8047462819b; //fffff804`7462819b

VOID HookTargetAddress()
{
// 将 HOOK 点的地址 R/W = 1
__try
{
KeepPreAddress_1 = *(PULONG64)VA;
KeepPreAddress_2 = *(PULONG)(VA + 8);

ULONG64 PLM4E = (((VA & 0x0000FFFFFFFFFFFF) >> 39) << 3) + (ULONG64)g_PXE_BASE;
ULONG64 PDPTE = (((VA & 0x0000FFFFFFFFFFFF) >> 30) << 3) + (ULONG64)g_PPE_BASE;
ULONG64 PDE = (((VA & 0x0000FFFFFFFFFFFF) >> 21) << 3) + (ULONG64)g_PDE_BASE;
ULONG64 PTE = (((VA & 0x0000FFFFFFFFFFFF) >> 12) << 3) + (ULONG64)g_PTE_BASE;

*(PULONG64)PLM4E = *(PULONG64)PLM4E | 2;
*(PULONG64)PDPTE = *(PULONG64)PDPTE | 2;
*(PULONG64)PDE = *(PULONG64)PDE | 2;
*(PULONG64)PTE = *(PULONG64)PTE | 2;
}
__except (1)
{
DbgPrint("Target address PTE Failed to modify!\n");
}

// 修改 HOOK 点,注入 ShellCode
__try
{
CR0 = __readcr0();
CR4 = __readcr4();

// CR.WP = 0,supervisor 权限的程序允许修改 only-read 页面
// 必须在 CR0.WP 被清零之前将 CR4.CET = 0, CET : 控制流强制技术
__writecr4(CR4 & 0xFFFFFFFFFF7FFFFF); // bit 23(CR4.CET) 清 0
__writecr0(CR0 & 0xFFFFFFFFFFFEFFFF); // bit 16(CR0.WP) 清 0

DbgBreakPoint(); // 手动将HOOK点地址修改为HookFuck()函数地址
*(PULONG64)VA = 0xF804810C1000BE48; //FFFFF804810C1000 48 BE 00 10 0C 81 04 F8 FF FF
*(PULONG)((ULONG64)VA + 8) = 0xD6FFFFFF;

// 只有在 CR0.WP = 1 时才可以将 CR4.CET = 1
__writecr0(CR0);
__writecr4(CR4);
}
__except (1)
{
DbgPrint("Target address Failed to modify with hook function!\n");
}

return;
}

NTSTATUS DriverEntry( PDRIVER_OBJECT driver, PUNICODE_STRING RegistryPath)
{
// DbgPrint("HookAddress = %llX\n", HookFuck); // FFFFF804810C1000
// DbgBreakPoint();

HookTargetAddress();

driver->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

HookFuck.asm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
option casemap :none

EXTERN VA:qword
EXTERN KeepPreAddress_1:qword
EXTERN KeepPreAddress_2:dword

.data
;Valid_1 qword ?

.CODE
HookFuck PROC
push rax;
push rcx;
push rdx;
push rbp;
pushfq;

; 将 HOOK 点原先的硬编码写回去
int 3;
xor edx, edx;
mov rcx, qword ptr [KeepPreAddress_1];
mov edx, dword ptr [KeepPreAddress_2];
mov rax, qword ptr [VA];
mov qword ptr [rax], rcx;
lea rax, qword ptr [rax+8h];
mov dword ptr [rax], edx;

popfq;
pop rbp;
pop rdx;
pop rcx;
pop rax;

; 执行被 HOOK 的代码
and qword ptr [rbp+78h],0;
mov rcx,qword ptr [rbp+0FAh];

ret;
HookFuck ENDP

END

驱动成功运行后,可以看到目标地址已经被 ShellCode HOOK 了:

1
2
3
4
kd> u fffff8047462819b
nt!ExpCenturyDpcRoutine$fin$0+0x25:
fffff804`7462819b 48be0010f18004f8ffff mov rsi,offset 20221226_GetPGContext!HookFuck (fffff804`80f11000)
fffff804`746281a5 ffd6 call rsi

3.3 解密得到CmpAppendDllSection

命中ShellCode

PatchGuard 检查时,当 ShellCode 被命中后,HookFuck 函数就会被执行。接下来单步调试,根据 1.2 章节的分析,解密得到 Pg_entry 入口地址和 CmpAppendDllSection 的解密秘钥。

单步到如下指令,此时 rax = rcx = r11 = &Pg_entry = 0xffff858bb8d0251d,密钥 rdx = 0xdca6e531adcf3a55

1
2
3
fffff804`746283d8 498bcb          mov     rcx,r11
fffff804`746283db 498bc3 mov rax,r11
fffff804`746283de e8dde8feff call nt!guard_dispatch_icall (fffff804`74616cc0)
  1. 0xffff858bb8d0251d 下硬件断点(不要下软件断点)。

    1
    2
    3
    kd> ba e1 ffff858bb8d0251d
    kd> bl
    0 e Disable Clear ffff858b`b8d0251d e 1 0001 (0001)

    继续运行后硬件断点即被命中。

  2. 查看 CmpAppendDllSection 解密上半部分。

    单步运行后,Pg_entry 被命中,下面就开始自解密。

    22.png

    每次自解密并写入 8 字节,而每次指令执行 4 字节,所以会提前解密好指令序列。

    23.png

  3. 下半部分(1)。上半部分解密结束后,下半部分会迎来一个循环。循环之前,首先会取 CmpAppendDllSection + 0xC4,即为 PGContext_FirstSec.Index

    此时的 PGContext_FirstSec.Index = 0x3914。此次 PGContext_FirstSec.Length = Index*8+0xC0 == 0x1C960

    1
    2
    3
    4
    5
    kd> r rcx
    rcx=0000000000003914
    ...
    ffff858b`b8d0259c 483184cac0000000 xor qword ptr [rdx+rcx*8+0C0h],rax ds:002b:ffff858b`b8d1ee7d=dca6e531adcf3a55
    ffff858b`b8d025a4 48d3c8 ror rax,cl

    注意:由于 PGContext 所处的内存一般会在两分半钟左右刷新一次,也就是这次 PatchGuard 检查没问题之后,会将完整的 PGContext 拷贝至新分配的非分页内存中,并且 PGContext 大小基地址都是动态变化的。

    此时将解密前的 PGContext_FirstSec Dump 到文件:

    1
    2
    kd> .writemem c:\Dump\dump  ffff858bb8d0251d L?1c96+1
    Writing 1c961 bytes..........................................................

    24.png

    解密的 LOOP 循环。这里是将 PGContext_FirstSec 进行解密,从高地址向低地址进行解密。

    注意:

    1. 这里每一轮解密都会使用 btc 修改密钥——ror rax,cl; btc rax, rax(LOOP中RCX-=1)。
    2. 第一轮解密的密钥和一开始解密 CmpAppendDllSection 的密钥是相同的,都是 0xdca6e531adcf3a55
  4. 下半部分的 LOOP 循环解密完成后,再 Dump 一次解密后的内存。

    可以看到,LOOP 循环解密后,CmpAppendDllSection 的代码又被完整解密出来了(但是其他内容是加密的)。

    1
    2
    3
    4
    5
    6
    7
    8
    kd> u ffff858bb8d0251d l30
    ffff858b`b8d0251d 2e483111 xor qword ptr cs:[rcx],rdx
    ffff858b`b8d02521 48315108 xor qword ptr [rcx+8],rdx
    ffff858b`b8d02525 48315110 xor qword ptr [rcx+10h],rdx
    ffff858b`b8d02529 48315118 xor qword ptr [rcx+18h],rdx
    ffff858b`b8d0252d 48315120 xor qword ptr [rcx+20h],rdx
    ffff858b`b8d02531 48315128 xor qword ptr [rcx+28h],rdx
    ...
  5. 下半部分(2)。这部分主要就是调用 PatchGuardEntryPoint 函数。

    1
    2
    3
    4
    5
    6
    ffff858b`b8d025ab e2ef            loop    ffff858b`b8d0259c
    ffff858b`b8d025ad 8b82e8070000 mov eax,dword ptr [rdx+7E8h] ds:002b:ffff858b`b8d02d05=0001a371
    ffff858b`b8d025b3 4803c2 add rax,rdx
    ffff858b`b8d025b6 4883ec28 sub rsp,28h
    ffff858b`b8d025ba ffd0 call rax // PatchGuardEntryPoint
    ...

    调用 PatchGuardEntryPoint 函数前的寄存器状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    kd> r
    rax=ffff858bb8d1c88e rbx=000000000000392d rcx=0000000000000000
    rdx=ffff858bb8d0251d rsi=0000000000000001 rdi=000000000000392d
    rip=ffff858bb8d025b6 rsp=fffff804770b7908 rbp=fffff804770a3770
    r8=0000000000000000 r9=0000000000000000 r10=ffff858bb8d0251d
    r11=0000000000000000 r12=fffff804770a3770 r13=fffff804770a3448
    r14=97d1c8152fd921c5 r15=fffff80474215000
    iopl=0 nv up ei ng nz na po nc
    cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000286
    ffff858b`b8d025b6 4883ec28 sub rsp,28h

    堆栈情况如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    kd> dq rsp
    fffff804`770b7908 fffff804`746283e3 00000000`00000000
    fffff804`770b7918 00000000`00000000 fffff804`742eb2f0
    fffff804`770b7928 fffff804`770b7fd0 fffff804`770b8210
    fffff804`770b7938 fffff804`74555895 fffff804`770b7fd0
    fffff804`770b7948 00000000`00000000 00000000`0032c58e
    fffff804`770b7958 00000000`0032c58b fffff804`74294878
    fffff804`770b7968 fffff804`745e3441 fffff804`00000003
    fffff804`770b7978 fffff804`770a3700 fffff804`7709e000

4 PatchGuardEntryPoint 函数分析

我们主要关注这个函数做的三件事:

  1. 清空 DR7 寄存器,屏蔽硬件断点。

    • IDTR.Base = [&pg_entry + 0x8E0] 获取新的 IDTR 寄存器基址,IDTR.Limit = 0x12F
    • 拷贝 CmpAppendDllSection 前 64 字节至 IDTR.Base + 0x10 的位置。
    • 清空 DR7 寄存器,屏蔽硬件断点。
  2. 验证多个关键函数是否被修改过,如 ExpWorkerThread

    如果这些关键函数已经被修改过,则会调用 KeBugCheck 触发蓝屏。

    如果检查结果成功,则会向初始化一个工作项 IO_WORKITEM 。该工作项关联的回调函数 WorkerRoutine 是从以下三个函数之一。

    • KiMachineCheckControl(从该数组中选择一个),如果是方法 ⑦ 初始化的 PG,则传给该函数的参数是 &PGcontext
    • FsRtlUninitializeSmallMcb,传给该函数的参数也是 &PGcontext。(多数情况下将挑选该函数
    • sub_140xxxxxx,该函数目前尚不明确。
  3. 初始化一个工作项 IO_WORKITEM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
v2 = a2;                                      // a1 = 0(循环结束条件)
// a2 = &pg_entry
if ( (*(_DWORD *)(a2 + 2448) & 0x110000) != 1114112 )
{
v3 = *(__m128i **)(a2 + 2272); // 新的 IDTR.base(ffff858bb8d0251d+8e0)
v4 = 38i64;
v5 = (__int64 *)v3;
v6 = 304;
do
{
*v5 = 0i64; // 往 v3 填充 304 字节 0
v6 -= 8;
++v5;
--v4;
}
while ( v4 );
for ( ; v6; --v6 ) // v6 == 0,不会执行 for 循环
{
*(_BYTE *)v5 = 0;
v5 = (__int64 *)((char *)v5 + 1);
} // 拷贝已解密的 pg_entry(CmpAppendDllSection) 前 64 字节
//
// 注意拷贝至目标地址第 16 字节开始的地方因为:
// 前 10 字节存放新的 IDTR.Base、IDTR.Limit,剩下 6 字节用来内存对齐
_mm_storeu_si128(v3 + 1, *(__m128i *)(v2 + 2120));
_mm_storeu_si128(v3 + 2, *(__m128i *)(v2 + 2136));
_mm_storeu_si128(v3 + 18, *(__m128i *)(v2 + 2152));
*(_WORD *)v115 = 303; // IDTR.limit(低 16 位)
*(_QWORD *)&v115[2] = v3; // IDTR.Base(64 位)
v3[1].m128i_i16[0] = v2 + 2168; // 共 64 字节
v3[1].m128i_i32[2] = (unsigned __int64)(v2 + 2168) >> 32;
v3[1].m128i_i16[3] = (unsigned int)(v2 + 2168) >> 16;
_disable();
if ( *(_DWORD *)(v2 + 2448) >= 0 )
{ // IDTR 寄存器:高 8 字节为 64 位基址,低 2 字节为表大小
__sidt(v116); // 保存 IDT 寄存器 80 位的值
__lidt(v115); // 加载 80 位到 IDTR 寄存器
__writedr(7u, 0i64); // 将 DR7 清零,屏蔽硬件中断
__lidt(v116);
}
else
{
__writedr(7u, 0i64); // 将 DR7 清零,屏蔽硬件中断
}
_enable();
}
...

Windbg 一些小技巧:

1
2
3
dt _TEB -ny LastError;
dt _TEB *LastError*;
~; //使用~命令列出当前进程的所有线程

5 执行 WorkItem

下面回到 CmpAppendDllSection 函数继续分析。上一小节已经分析了 PatchGuardEntryPoint 函数,目前代码执行到地址 INIT:0000000140A3622F

下面跳转 r8 就是去将工作项插入队列(ExQueueWorkltem/IoQueueWorkItem),然后让 System 进程的线程池中的某个工作线程去执行回调例程。

1
2
3
4
5
6
7
8
9
10
11
12
13
INIT:0000000140A36220 loc_140A36220:                          
INIT:0000000140A36220 mov eax, [rdx+7E8h] // 0x1a371
INIT:0000000140A36226 add rax, rdx // rax = ffff858b`b8d1c88e
INIT:0000000140A36229 sub rsp, 28h
INIT:0000000140A3622D call rax // PatchGuardEntryPoint 函数
INIT:0000000140A3622F add rsp, 28h
INIT:0000000140A36233 mov r8, [rax+110h]
INIT:0000000140A3623A lea rcx, [rax+798h]
INIT:0000000140A36241 mov edx, 1
INIT:0000000140A36246 jmp r8 // ExQueueWorkltem/IoQueueWorkItem 工作项
INIT:0000000140A36246 ; ---------------------------------------------------------------------------
INIT:0000000140A36249 db 0Fh dup(90h)
INIT:0000000140A36249 CmpAppendDllSection endp

6 FsRtlUninitializeSmallMcb

如上两节分析,在函数 PatchGuardEntryPoint 中初始化一个工作项 IO_WORKITEM,该 WorkItem 回调例程是三选一,大概率情况下一般回调例程会是 FsRtlUninitializeSmallMcb 函数。然后 jmp r8 去插入该工作项(ExQueueWorkltem/IoQueueWorkItem)。

PatchGuardEntryPoint 函数中会对部分重要的函数进行完整性检查,但是对 PGContext 内容的完整检查是由 FsRtlUninitializeSmallMcb调用 FsRtlMdlReadCompleteDevEx 函数完成的。这些完整性检查包括核心驱动的 IAT、GDT、IDT(包括中断向量表)、SSDT、CRx、MSR寄存器、内核全局变量、内核全局指针、进程模块链表、内核堆栈、对象类型、Local APIC、第三方通知回调、某些配置和 Flags、KPP Self 等。

FsRtlUninitializeSmallMcbFsRtlMdlReadCompleteDevEx 这些函数原型都在 INITKDBG 节中。

26.png

7 DPC 方式检查过程

25.png

26.png

8 其他触发PG检查的方式

8.1 系统线程方式

使用系统线程触发 PG 检查方式的概率不是很高。

PG_KiInitializePatchGuardContext 函数中使用方法三初始化 PGContext 时,在 PG_KiInitializePatchGuardContext 函数中调用 Pg_InitMethod3SystemThread 函数来初始化一个系统线程。

通过系统线程来触发 PG 检查适用于方法三初始的 PGContext。

初始化过程如下:

1
2
3
4
5
6
7
8
9
10
11
ExpLicenseWatchInitWorker --> PG_KiFilterFiberContext(pKiServiceTablesLocked) // 参数 KPRCB[0].HalReserved[6]
// 参数结构如下
typedef struct _KI_FILTER_FIBER_PARAM
{
CHAR code_prefetch_rcx_retn[4]; // prefetchw byte ptr [rcx]; retn;
CHAR padding[4]; // 用于对齐的4字节(Align)
PVOID pPsCreatesystemThread; // PsCreatesystemThread 函数的存根
PVOID Pg_Method3stubTocheckRoutine_sub_1405B9FB0; // 被创建的系统线程的回调函数
PVOID pKiBalanceSetManagerPeriodicDpc; // 指向一个全局的 KDPC 结构
}KI_FILTER_FIBER_PARAM, *PKI_FILTER_FIBER_PARAM;

PG_KiInitializePatchGuardContext 调用 Pg_InitMethod3SystemThread 函数,在函数 Pg_InitMethod3SystemThread 中使用 KI_FILTER_FIBER_PARAM.pPsCreatesystemThread 来创建一个系统线程,线程回调函数为 KI_FILTER_FIBER_PARAM.Pg_Method3stubTocheckRoutine_sub_1405B9FB0

通过分析线程回调函数 Pg_Method3stubTocheckRoutine_sub_1405B9FB0

  1. Pg_InitMethod3SystemThread 触发异常,在异常处理中调用 KI_FILTER_FIBER_PARAM.pPsCreatesystemThread 创建系统线程。

    线程回调函数的参数可以将其命名为 pg_StartContext

    1
    2
    3
    4
    5
    6
    7
    struct pg_StartContext
    {
    ULONG64 pEvent_0x00; // a pointer to the event
    ULONG64 bRandom_ShouldRunKeRundownApcQueues_0x08; // set at 0x1408A970B
    ULONG64 unknown_0x10;
    KEVENT event_0x18;
    };
  2. 在线程回调函数中 KeWaitForSingleObject(pEvent_0x00, 0, 0, 0, 0),等待没有设置超时时间,而该事件是在 PG_KiInitializePatchGuardContext 结束时通知的。

  3. 线程回调函数等待信号之后,就开始进行几乎类似于 DPC 触发 PG Check 的解密过程

    第一阶段使用到 KiWaitAlwaysKiWaitNever 参与解密,第二阶段就是 CmpAppendDlSection 自解密。

  4. 一旦验证结束,就会调用 KeDelayExecutionThread/KeWaitForSingleObject

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    if ( v32 )
    {
    do
    {
    if ( v35 )
    KeDelayExecutionThread(0, 0, &Interval);
    else
    KeWaitForSingleObject(v4, 0, 0, 0, &Interval);
    v31 = *v36;
    }
    while ( !*v36 );
    }
    v34 = *(_QWORD *)(v31 + 32);
    v9 = v8 ^ v31;
    *v36 = 0i64;
    v10 = v8 ^ v34;
    if ( v35 )
    KeDelayExecutionThread(0, 0, &Interval);
    else
    KeWaitForSingleObject(v4, 0, 0, 0, &Interval);

    这里的 Interval 等待时间约为 2 min~ 2 min 10 seconds

    1
    2
    3
    4
    5
    v2 = ExGenRandom(1i64);
    v32 = *(_QWORD *)(v1 + 8) == 1i64;
    Interval.QuadPart = -1200000000i64
    - (v2
    - 100000000 * ((unsigned __int64)(v2 * (unsigned __int128)0xABCC77118461CEFDui64 >> 64) >> 26));

8.2 内核 APC 方式

使用 PsEnumProcessThreads 来枚举 System 系统进程中的系统线程,如果哪一个线程回调函数等于 PopIrpWorkerControl,初始化则将该线程 ETHREAD 保存至 PGContext(将APC插入到该线程),然后将 ETHREAD 存放在该 KAPC 结构中。即 ETHREAD.StartAddress = PopIrpWorkerControl

该 APC 的 KernelRoutine = KiDispatchCallout,也就是说 KiDispatchCallout 为 APC 回调函数(PG Check 函数)。

实际上 System 进程中有好多个这样的线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
PROCESS ffff858bb88a7080
SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000
DirBase: 001ad002 ObjectTable: ffffdb8aefe4b700 HandleCount: 2080.
Image: System

kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
PROCESS ffff858bb88a7080
SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000
DirBase: 001ad002 ObjectTable: ffffdb8aefe4b700 HandleCount: 2080.
Image: System
...

THREAD ffff858bb8892400 Cid 0004.000c Teb: 0000000000000000 Win32Thread: 0000000000000000 WAIT: (Executive) KernelMode Non-Alertable
fffff80474e35a20 SynchronizationEvent
/***/ Win32 Start Address nt!PopIrpWorkerControl (0xfffff804745dec50) /****/
Stack Init ffffc3059da0dc90 Current ffffc3059da0d810
Base ffffc3059da0e000 Limit ffffc3059da08000 Call 0000000000000000
Priority 15 BasePriority 13 PriorityDecrement 32 IoPriority 2 PagePriority 5
Child-SP RetAddr Call Site
ffffc305`9da0d850 fffff804`744280b0 nt!KiSwapContext+0x76
ffffc305`9da0d990 fffff804`744275df nt!KiSwapThread+0x500
ffffc305`9da0da40 fffff804`74426e83 nt!KiCommitThreadWait+0x14f
ffffc305`9da0dae0 fffff804`745dec72 nt!KeWaitForSingleObject+0x233
ffffc305`9da0dbd0 fffff804`74486d25 nt!PopIrpWorkerControl+0x22
ffffc305`9da0dc10 fffff804`74615778 nt!PspSystemThreadStartup+0x55
ffffc305`9da0dc60 00000000`00000000 nt!KiStartSystemThread+0x28
...
kd> ? PopIrpWorkerControl
Evaluate expression: -8776960840624 = fffff804`745dec50
kd> u fffff804`745dec50
nt!PopIrpWorkerControl:
fffff804`745dec50 4053 push rbx
fffff804`745dec52 4883ec30 sub rsp,30h

APC 回调函数中解密类似于 DPC 触发 PG Check 的解密过程:第一阶段使用到 KiWaitAlwaysKiWaitNever 参与解密,第二阶段就是 CmpAppendDlSection 自解密。

8.3 全局变量(通知回调)