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

本文详细分析了Windows Patchguard如何检测基于Hypervisor的系统调用挂钩技术,包括KiErrata420Present和KiErrata1337Present两种检测方法的实现原理与反制策略,涉及MSR操作、IDT钩子和异常处理等底层技术。

Patchguard: 基于Hypervisor的内省检测 [第二部分]

没有给你的错误修正!

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

KiErrata420Present

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

其中一种较简单的LSTAR挂钩检测没有被赋予“错误修正”的迷因名称,也许它不够好——所以我们称它为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                             ; 禁用中断
        mov     r9d, 0C0000082h         ;
        mov     ecx, r9d                ;
        rdmsr                           ; 读取LSTAR MSR值
        shl     rdx, 32                 ;
        or      rax, rdx                ; 将LSTAR值存储在rax中
        lea     rdx, [rdi+87Ah]         ; 从pg上下文读取临时LSTAR值存储在rdx中
        mov     rbx, rax                ; rbx = 原始LSTAR值
        mov     rax, rdx                ; rax = 临时LSTAR值
        shr     rdx, 32                 ;
        wrmsr                           ; 写入临时LSTAR MSR值
        mov     r14d, 20000h            ;
        lea     rax, [rdi+87Ch]         ; rax = 执行syscall的存根
        mov     rsi, 0A3A03F5891C8B4E8h ; rsi = 用于混淆pg上下文指针的常量
        test    [rdi+994h], r14d        ; 测试是否应存储pg检查数据?
        jnz     short trigger_syscall   ; 如果不为零,跳过跟踪

        mov     r8, gs:KPCR.CurrentPrcb ;
        lea     rdx, [rdi+rsi]          ;
        mov     rcx, [rdi+4C0h]         ;
        mov     [rcx], rdx              ;
        mov     rcx, [rdi+4C8h]         ; 存储pg检查相关数据
        mov     [rcx], r8               ;
        mov     rcx, [rdi+4D0h]         ;
        mov     [rcx], r9               ;
        mov     rcx, [rdi+4D8h]         ;
        mov     qword ptr [rcx], 112h   ;

trigger_syscall:
        call    KeGuardDispatchICall    ; 分发调用到syscall指令存根

        test    [rdi+994h], r14d        ; 测试是否应跟踪pg检查?
        jnz     short restore_lstar     ; 如果不为零,跳过跟踪

        mov     rax, [rdi+4C0h]         ;
        mov     [rax], rsi              ;
        mov     rax, [rdi+4C8h]         ;
        mov     [rax], r13              ; 清除pg检查相关数据
        mov     rax, [rdi+4D0h]         ;
        mov     [rax], r13              ;
        mov     rax, [rdi+4D8h]         ;
        mov     [rax], r13              ;

restore_lstar:
        mov     rdx, rbx                ; 恢复原始LSTAR值
        mov     rax, rbx                ;
        shr     rdx, 32                 ;
        mov     ecx, 0C0000082h         ;
        wrmsr                           ; 写入原始LSTAR MSR值
        sti                             ; 重新启用中断

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

首先,让我们弄清楚写入LSTAR MSR的临时值是什么:

1
2
3
4
lea     rdx, [rdi+87Ah]         ; 从pg上下文读取临时LSTAR值存储在rdx中
mov     rbx, rax                ; rbx = 原始LSTAR值
mov     rax, rdx                ; rax = 临时LSTAR值
shr     rdx, 32                 ;

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

1
mov     byte ptr [r14+87Ah], 0C3h ; 存储RET指令

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

接下来,让我们弄清楚KeGuardDispatchICall中的这个调用是什么。正如你可能已经知道的,KeGuardDispatchICall通过分支到RAX中给定的指令指针来工作。所以让我们检查一下RAX的来源:

1
lea     rax, [rdi+87Ch]         ; rax = 执行syscall的存根

最后一步,确定在patchguard初始化例程中context+0x87C被写入的位置:

1
2
mov     eax, 050Fh
mov     [r14+87Ch], ax ; 存储SYSCALL指令

我们看到的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的写入,这在这种情况下会导致故障,因为在这种情况下执行syscall之前没有设置上下文。Hyperbone的LSTAR MSR挂钩就是这种实现的一个例子。

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

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

是的,你说得对。然而,在这种情况下,我们可以在之后恢复我们的挂钩,除非客户机自己创建自己的syscall挂钩实现。不幸的是,在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
    )
{
    // ...

    //
    // 隐藏我们的LSTAR系统调用挂钩处理程序地址。
    //
    case MSR_LSTAR:
        if (Vcpu->OriginalLSTAR) {
            MsrValue = Vcpu->OriginalLSTAR;
        } else {
            MsrValue = __readmsr(MSR_LSTAR);
        }
        break;

    // ...
}

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

    //
    // 让客户机覆盖我们的挂钩以避免可能的检测。
    //
    // 当且仅当客户机写入原始LSTAR时,我们将
    // MSR值替换为挂钩LSTAR值。
    //
    // 注意:我们这样做是为了绕过Patchguard的一个系统调用挂钩
    //      检测,其工作方式如下:
    //
    //  _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,无耻地借鉴了微软对其他酷炫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                                  ; 将GS基址与IA32_KERNEL_GS_BASE交换
        mov     gs:KPCR.UserRsp, rsp            ; 在处理器控制区域中存储用户模式堆栈
        mov     rsp, gs:KPCR.Prcb.RspBase       ; 从处理器控制区域设置内核堆栈

很酷,知道了这些细节,我们知道我们可以通过弄乱GS基址来在系统调用处理程序内部导致页面错误(#PF)。等等,什么?

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
KiErrata1337Present:
        swapgs                                  ; swapgs以模拟来自用户模式

        mov     ecx 0C0000102h                  ;
        xor     eax, eax                        ; 将KERNEL_GS_BASE MSR设置为零
        xor     edx, edx                        ;
        wrmsr                                   ;

        syscall                                 ; 执行syscall指令以触发错误

        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; // 填充临时IDT

_disable();             // 禁用中断
__sidt(&OriginalIdtr);  // 备份原始IDT
__lidt(&TempIdtr);      // 加载我们的临时挂钩IDT

// 挂钩页面错误处理程序。
TempIdt[PF] = PageFaultHookHandler;

// 触发会故意页面错误的系统调用!
KiErrata1337Present();  // 这必须足够精简,不会超时看门狗!

__lidt(&OriginalIdtr);  // 恢复原始IDT。
_enable();              // 重新启用中断。

我们的页面错误处理程序现在什么都不做,只是从中断返回。我们使用IRET指令来实现这一点。如果你还不熟悉IRET指令,请阅读它,因为当你实际从所有这些中创建检测时,理解它非常重要!

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

1
2
3
PageFaultHookHandler:
        add     rsp, 8                  ; 跳过堆栈上的错误代码
        iretq                           ; 从中断返回

现在我们设置了页面错误处理程序的挂钩,让我们修复我们的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         ; 读取原始GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; 备份原始GS_BASE MSR
        push    rax                     ;
        mov     ecx, 0C0000102h         ; 读取原始KERNEL_GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; 备份原始KERNEL_GS_BASE MSR
        push    rax                     ;

        swapgs                          ; swapgs以模拟来自用户模式

        xor     eax, eax                ;
        xor     edx, edx                ; 将KERNEL_GS_BASE MSR设置为零
        wrmsr                           ;

        syscall                         ; 执行syscall指令,立即执行swapgs

        mov     ecx, 0C0000102h         ;
        pop     rax                     ;
        pop     rdx                     ; 恢复原始KERNEL_GS_BASE MSR
        wrmsr                           ;
        mov     ecx, 0C0000101h         ;
        pop     rax                     ;
        pop     rdx                     ; 恢复原始GS_BASE MSR
        wrmsr                           ;

        ret                             ; 返回调用者

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

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

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

1
2
3
4
5
RCX ← RIP; (* 将包含下一条指令的地址 *)
RIP ← IA32_LSTAR;
R11 ← RFLAGS;
RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
// .... 迷因

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

1
2
3
4
PageFaultHookHandler:
        add     rsp, 8                  ; 跳过堆栈上的错误代码
        xchg    qword [rsp], rcx        ; 交换陷阱帧RIP与RCX中的syscall返回地址
        iretq                           ; 从中断返回

我们利用XCHG指令的优势来交换RCX中的syscall返回地址与陷阱帧中的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
// 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();             // 禁用中断
    __sidt(&OriginalIdtr);  // 备份原始IDT
    __lidt(&TempIdtr);      // 加载我们的临时挂钩IDT

    // 挂钩页面错误处理程序。
    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);

    // 触发会故意页面错误的系统调用!
    SyscallHandler
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计