深入解析PatchGuard:基于Hypervisor的内省检测技术(第二部分)

本文详细分析了Windows PatchGuard如何检测基于Hypervisor的系统调用钩子,包括KiErrata420Present和KiErrata1337Present两种检测技术,并提供了绕过方案和完整的概念验证代码实现。

Patchguard: Detection of Hypervisor Based Introspection [P2]

No Errata For U!

如果您尚未阅读,请先阅读第一部分,其中概述了Patchguard使用的三个巧妙技巧。

KiErrata420Present

可以使用Hypervisor拦截LSTAR MSR以捕获读写操作。这是在大多数现代x86操作系统中挂钩系统调用的最常见和最高效的方法。然而,与我在线阅读的内容相反,如果不妥善处理,这可能会带来许多潜在的Hypervisor检测向量。通过在特权代码中使用一些巧妙技巧,我们可以可靠地确定LSTAR MSR上是否存在钩子,也就是说,如果Hypervisor中尚未实施适当的预防措施。从Windows 10 1903版本18362开始,微软添加了几种LSTAR钩子检测技术。

其中一种较简单的LSTAR钩子检测并未被赋予“errata”的迷因名称,也许它还不够好🙁——所以我们称它为KiErrata420Present(可能与微软内部称呼相差不远?)。

我在下面概述了检测过程:

 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
KiErrata420Present:
        cli                             ; disable interrupts
        mov     r9d, 0C0000082h         ;
        mov     ecx, r9d                ;
        rdmsr                           ; read LSTAR MSR value
        shl     rdx, 32                 ;
        or      rax, rdx                ; store LSTAR value in rax
        lea     rdx, [rdi+87Ah]         ; store temp LSTAR value in rdx read from pg context
        mov     rbx, rax                ; rbx = original LSTAR value
        mov     rax, rdx                ; rax = temp LSTAR value
        shr     rdx, 32                 ;
        wrmsr                           ; write temporary LSTAR MSR value
        mov     r14d, 20000h            ;
        lea     rax, [rdi+87Ch]         ; rax = stub to execute syscall
        mov     rsi, 0A3A03F5891C8B4E8h ; rsi = constant to obfuscate pg context pointer
        test    [rdi+994h], r14d        ; test if should store pg check data?
        jnz     short trigger_syscall   ; if nz, skip tracing

        mov     r8, gs:KPCR.CurrentPrcb ;
        lea     rdx, [rdi+rsi]          ;
        mov     rcx, [rdi+4C0h]         ;
        mov     [rcx], rdx              ;
        mov     rcx, [rdi+4C8h]         ; store pg check related data
        mov     [rcx], r8               ;
        mov     rcx, [rdi+4D0h]         ;
        mov     [rcx], r9               ;
        mov     rcx, [rdi+4D8h]         ;
        mov     qword ptr [rcx], 112h   ;

trigger_syscall:
        call    KeGuardDispatchICall    ; dispatch call to syscall instruction stub

        test    [rdi+994h], r14d        ; test if pg check should be traced?
        jnz     short restore_lstar     ; if nz, skip tracing

        mov     rax, [rdi+4C0h]         ;
        mov     [rax], rsi              ;
        mov     rax, [rdi+4C8h]         ;
        mov     [rax], r13              ; wipe pg check related data
        mov     rax, [rdi+4D0h]         ;
        mov     [rax], r13              ;
        mov     rax, [rdi+4D8h]         ;
        mov     [rax], r13              ;

restore_lstar:
        mov     rdx, rbx                ; restore original LSTAR value
        mov     rax, rbx                ;
        shr     rdx, 32                 ;
        mov     ecx, 0C0000082h         ;
        wrmsr                           ; write original LSTAR MSR value
        sti                             ; reenable interrupts

这个检查确实非常简单。它临时用自己临时的系统调用处理程序覆盖系统的LSTAR MSR值,并在之后恢复原始的LSTAR MSR值。我怎么确定这一点?让我们进一步深入探究。

首先,让我们找出写入LSTAR MSR的临时值是什么:

1
2
3
4
lea     rdx, [rdi+87Ah]         ; store temp LSTAR value in rdx read from pg context
mov     rbx, rax                ; rbx = original LSTAR value
mov     rax, rdx                ; rax = temp LSTAR value
shr     rdx, 32                 ;

如我们所见,写入的临时LSTAR值是RDI+0x87A处的地址。对patchguard回调稍有了解,我们知道RDI寄存器保存当前“patchguard上下文”的临时地址。利用这些知识,我们可以轻松确定在patchguard初始化例程中context+0x87A处写入的内容:

1
mov     byte ptr [r14+87Ah], 0C3h ; store RET instruction

太好了,这是返回指令的操作码,非常有趣!

接下来,让我们找出KeGuardDispatchICall处的调用是什么。如您所知,KeGuardDispatchICall通过分支到RAX中给出的指令指针来工作。那么让我们检查一下RAX的来源:

1
lea     rax, [rdi+87Ch]         ; rax = stub to execute syscall

最后一步,确定在patchguard初始化例程中context+0x87C处写入的内容:

1
2
mov     eax, 050Fh
mov     [r14+87Ch], ax ; store SYSCALL instruction

我们看到的050Fh是什么意思?那就是SYSCALL指令的操作码!我想我们现在已经知道发生了什么。但让我们用一些伪代码简化一下:

1
2
3
4
5
6
_disable();
OriginalSyscall64 = __readmsr(MSR_LSTAR);
__writemsr(MSR_LSTAR, &PgContext->DummySyscallHandler); // C3 -> ret
KeGuardDispatchICall(&PgContext->Syscall); // 0F 05 -> syscall
__writemsr(MSR_LSTAR, OriginalSyscall64);
_enable();

简洁!它只是执行SYSCALL指令,然后立即从处理程序返回。

这对大多数利用LSTAR钩子的Hypervisor非常有效,并且更擅长烦扰那些尽力阻止客户机篡改LSTAR MSR的Hypervisor。在许多幼稚的LSTAR MSR钩子实现中,开发人员会完全禁止对LSTAR MSR的写入,这在这种情况下会导致故障,因为在这种情况下执行系统调用之前上下文未设置。Hyperbone的LSTAR MSR钩子就是这种实现的一个例子。

这对Hypervisor开发人员来说是一个令人沮丧的问题。然而,他们应该担心,因为对于Hypervisor开发人员来说,这个简单问题有一个相当简单的解决方案。解决方案是让客户机覆盖LSTAR MSR,并有效地影子化原始值。

那么这意味着客户机可以强制我们取消钩子???

是的,你说得对。然而,在这种情况下,我们可以在之后恢复我们的钩子,并且在几乎所有其他情况下也是如此,除非客户机自己创建了他们的系统调用钩子实现。不幸的是,在Windows中,patchguard有一个单独的检查来断言LSTAR MSR的值未被篡改。因此,实际上,没有客户机软件会在Windows上永久覆盖您宝贵的LSTAR MSR,除非他们禁用了patchguard,这完全是可能的,但也非常容易被捕获。此外,这些情况都可以在VMM中监控并根据需要规避。

对于这种情况,我们可以这样规避检测:

 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
VMM_EVENT_STATUS
HVAPI
VmmHandleMsrRead(
    _In_ PVIRTUAL_CPU Vcpu
    )
{
    // ...

    //
    // Hide our LSTAR syscall hook handler address.
    //
    case MSR_LSTAR:
        if (Vcpu->OriginalLSTAR) {
            MsrValue = Vcpu->OriginalLSTAR;
        } else {
            MsrValue = __readmsr(MSR_LSTAR);
        }
        break;

    // ...
}

VMM_EVENT_STATUS
HVAPI
VmmHandleMsrWrite(
    _In_ PVIRTUAL_CPU Vcpu
    )
{
    // ...

    //
    // Let the guest overwrite our hook to avoid possible detection.
    //
    // If and only if the guest is writing the original LSTAR, we replace
    // the MSR value with the hook LSTAR value.
    //
    // N.B. We do this to get around one of PatchGuard's syscall hook
    //      detections which works like this:
    //
    //  _disable();
    //  OriginalSyscall64 = __readmsr(MSR_LSTAR);
    //  __writemsr(MSR_LSTAR, &PgCtx->PgSyscallDummy); // C3 -> ret
    //  KeGuardDispatchICall(&PgCtx->SyscallOpcode1); // 0F 05 -> syscall
    //  __writemsr(MSR_LSTAR, OriginalSyscall64);
    //  _enable();
    //
    case MSR_LSTAR:
        if (MsrValue == Vcpu->OriginalLSTAR) {
            MsrValue = Vcpu->HookLSTAR;
        }
        __writemsr(MSR_LSTAR, MsrValue);
        break;

    // ...
}

我们通过在读取和写入LSTAR MSR时影子化原始LSTAR值,完全解决了这个问题。

KiErrata1337Present

运用一些批判性思维,我利用从Patchguard派生的一些技巧,想出了自己相当离经叛道的LSTAR检测方法。我称这个为KiErrata1337Present,无耻地源自微软迷因“errata”命名方案,用于他们其他很酷的patchguard检查。

那些研究过现代64位系统调用处理程序在Linux和/或Windows中的人可能已经注意到它们以SWAPGS指令开始和(有时)结束。SWAPGS指令交换当前GS基址寄存器(IA32_GS_BASE)值与包含在MSR地址C0000102H(IA32_KERNEL_GS_BASE)中的内核GS基址寄存器值。

系统调用处理程序中紧接SWAPGS指令的指令是一个分段MOV指令。以下是KiSystemCall64的一瞥:

1
2
3
4
KiSystemCall64 proc near
        swapgs                          ; swap GS base with IA32_KERNEL_GS_BASE
        mov     gs:KPCR.UserRsp, rsp    ; store user mode stack in processor control region
        mov     rsp, gs:KPCR.Prcb.RspBase ; set the kernel stack from processor control region

很酷,知道这些细节后,我们知道我们可以搞乱GS基址以在系统调用处理程序内部引起页面错误(#PF)。等等,什么?

WTF 为什么你会想要故意页面错误???

你这么想是理智的。我们想要在系统调用处理程序内部错误的原因是为了读取系统调用处理程序的真实RIP。这是一个非常重要的细节!

好吧,让我们尝试故意生成一个页面错误(#PF):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
KiErrata1337Present:
        swapgs                          ; swapgs to emulate coming from user mode

        mov     ecx 0C0000102h          ;
        xor     eax, eax                ; set KERNEL_GS_BASE MSR to zero
        xor     edx, edx                ;
        wrmsr                           ;

        syscall                         ; execute the syscall instruction to trigger fault

        ret

砰!我们页面错误了——顺便说一句,这是件好事!

然而,一开始就有几个问题:Windows中的原始页面错误处理程序只会爆炸并错误检查,如果我们不恢复原始的GS基址和内核GS基址值,操作系统也会在下一个上下文切换中爆炸。所以我们需要临时挂钩中断描述符表(IDT),并备份GS基址,太简单了! 临时挂钩中断描述符表IDT的步骤如下:

  1. 禁用中断
  2. 保存原始IDT
  3. 加载我们的临时IDT
  4. 做你的事
  5. 恢复原始IDT
  6. 重新启用中断

以下是一些实现上述步骤的伪代码,包括我们需要的页面错误#PF异常钩子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
TempIdtr.Limit = sizeof(TempIdt) - 1;
TempIdtr.Base = (UINT64)&TempIdt[0];
for (IdtEntry in KPCR->IdtBase)
    TempIdt[i] = IdtEntry; // Fill in temporary IDT

_disable();             // Disable interrupts
__sidt(&OriginalIdtr);  // Backup original IDT
__lidt(&TempIdtr);      // Load our temporary hook IDT

// Hook page fault handler.
TempIdt[PF] = PageFaultHookHandler;

// Trigger syscall that will purposely page fault!
KiErrata1337Present();  // This must be lean enough not to timeout watchdog!

__lidt(&OriginalIdtr);  // Restore the original IDT.
_enable();              // Re-enable interrupts.

我们的页面错误处理程序目前什么都不做,只是从中断返回,我们测试时这样做。我们使用IRET指令来实现这一点。如果您还不熟悉IRET指令,请阅读有关它的内容,因为理解它对于之后我们实际创建检测非常重要!

这是我们无聊的页面错误钩子处理程序:

1
2
3
PageFaultHookHandler:
        add     rsp, 8                  ; skip fault code on stack
        iretq                           ; return from interrupt

现在我们设置了一个页面错误处理程序的钩子,让我们修复我们的KiErrata1337Present例程以备份和恢复原始的GS基址:

 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
KiErrata1337Present:
        mov     ecx, 0C0000101h         ; read original GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; backup original GS_BASE MSR
        push    rax                     ;
        mov     ecx, 0C0000102h         ; read original KERNEL_GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; backup original KERNEL_GS_BASE MSR
        push    rax                     ;

        swapgs                          ; swapgs to emulate coming from user mode

        xor     eax, eax                ;
        xor     edx, edx                ; set KERNEL_GS_BASE MSR to zero
        wrmsr                           ;

        syscall                         ; execute syscall instruction which executes swapgs immediately

        mov     ecx, 0C0000102h         ;
        pop     rax                     ;
        pop     rdx                     ; restore original KERNEL_GS_BASE MSR
        wrmsr                           ;
        mov     ecx, 0C0000101h         ;
        pop     rax                     ;
        pop     rdx                     ; restore original GS_BASE MSR
        wrmsr                           ;

        ret                             ; return back to caller

它工作了!现在来看多汁的检测部分!

就像我之前提到的,我们想要在系统调用处理程序中引起错误的全部原因是为了在错误时从机器陷阱帧读取RIP。那部分很容易。但是如果我们处于页面错误处理程序中,如何跳回我们的KiErrata1337Present例程?嗯,幸运的是,SYSCALL指令为我们保存了一个返回地址,这实际上是为了它的对应物SYSRET。当SYSCALL指令执行时,它会将下一条指令的地址存储在RCX寄存器中。

我们可以看到SYSCALL指令的操作如下:

1
2
3
4
5
RCX ← RIP; (* Will contain address of next instruction *)
RIP ← IA32_LSTAR;
R11 ← RFLAGS;
RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
// .... memes

那么我们如何返回?简单,我们覆盖机器帧上的RIP地址。希望你现在理解IRET的工作原理,如果你还不熟悉的话,因为现在是我们利用它的操作来完成检测的时候。让我们聪明一点,使用XCHG指令一石二鸟:

1
2
3
4
PageFaultHookHandler:
        add     rsp, 8                  ; skip fault code on stack
        xchg    qword [rsp], rcx        ; xchg trap frame RIP with syscall return address in RCX
        iretq                           ; return from interrupt

我们利用XCHG指令的优势,将RCX中的系统调用返回地址与陷阱帧中的RIP交换。这使我们能够有效地将真实的系统调用处理程序地址存储在RCX中,并且仍然分支到我们的SYSCALL指令之后的下一条指令。

差不多就是这样。那是一个疯狂的方法,不是吗?将所有内容整合在一起看起来像这样:

 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
// detect.c

VOID
DoTheThing(
    VOID
    )
{
    KIDTENTRY64 TempIdt[19];
    X64_DESCRIPTOR TempIdtr;
    PVOID SyscallHandler;

    TempIdtr.Limit = sizeof(TempIdt) - 1;
    TempIdtr.Base = (UINT64)&TempIdt[0];
    RtlCopyMemory(TempIdt, KeGetPcr()->IdtBase, TempIdtr.Limit + 1);

    _disable();             // Disable interrupts
    __sidt(&OriginalIdtr);  // Backup original IDT
    __lidt(&TempIdtr);      // Load our temporary hook IDT

    // Hook page fault handler.
    TempIdt[X86_TRAP_PF].OffsetLow = (UINT16)(UINTN)PageFaultHookHandler;
    TempIdt[X86_TRAP_PF].OffsetMiddle = (UINT16)((UINTN)PageFaultHookHandler >> 16);
    TempIdt[X86_TRAP_PF].OffsetHigh = (UINT32)((UINTN)PageFaultHookHandler >> 32);

    // Trigger syscall that will purposely page fault!
    SyscallHandler = KiErrata1337Present();

    __lidt(&OriginalIdtr);  // Restore the original IDT.
    _enable();             
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计