返回 2026-07-09
⚙️ 工程

另一种控制流防护检查:验证与调用的结合The other kind of control flow guard check: The combined validate and call

微软开发者博客深入探讨了 Windows 控制流防护(CFG)机制中的另一种检查方式:将验证与调用合二为一的机制。这种二合一的优化设计旨在提升系统安全性并减少间接分支带来的性能开销。文章剖析了该机制在底层架构中的工作原理及其如何有效防止恶意代码劫持程序的控制流。

Raymond Chen

前段时间,我讨论了如何从控制流防护检查中提取函数指针。我给出了 LdrpValidateUserCallTarget 的代码,但该函数还有另一个版本,将验证与调用结合在了一起。我猜想这个版本存在的原因是,在验证完函数指针后,你几乎总是要调用它,所以不如把这两个操作结合起来。

但这确实意味着必须改变调用约定,因为需要为最终的调用设置寄存器,这意味着验证并调用操作的参数不能与调用约定使用的寄存器重叠。(听起来很熟悉?)

这是一个 x86-64 版本。

    mov     r11, [ntdll!....]
    mov     r10,rax
    shr     r10,9
    mov     r11,qword ptr [r11+r10*8]
    mov     r10,rax
    shr     r10,3
    test    al,0Fh
    jne     @1
    bt      r11,r10
    jae     @2
    jmp     rax
@1: btr     r10,0
    bt      r11,r10
    jae     @3
@2: or      r10,1
    bt      r11,r10
    jae     @3
    jmp     rax
@3: xor     r10d, r10d
    jmp     bad

让我们把它和仅验证版本放在一起对比一下:

    mov     rdx,qword ptr [ntdll!....]
    mov     rax,rcx
    shr     rax,9
    mov     rdx,qword ptr [rdx+rax*8]
    mov     rax,rcx
    shr     rax,3
    test    cl,0Fh
    jne     @1
    bt      rdx,rax
    jae     @2
    ret
@1: btr     rax,0
    bt      rdx,rax
    jae     @3
@2: or      rax,1
    bt      rdx,rax
    jae     @3
    ret
@3: mov     rax,rcx
    xor     r10d,r10d
    jmp     bad
    mov     r11, [ntdll!....]
    mov     r10,rax
    shr     r10,9
    mov     r11,qword ptr [r11+r10*8]
    mov     r10,rax
    shr     r10,3
    test    al,0Fh
    jne     @1
    bt      r11,r10
    jae     @2
    jmp     rax
@1: btr     r10,0
    bt      r11,r10
    jae     @3
@2: or      r10,1
    bt      r11,r10
    jae     @3
    jmp     rax
@3:
    xor     r10d, r10d
    jmp     bad

两者的逻辑是一样的;仅仅是使用的寄存器不同。

仅验证版本通过 rcx 接收地址,并将 rax 和 rdx 用作临时寄存器。验证并调用版本通过 rax 接收地址,并将 r10 和 r11 用作临时寄存器。(检测到坏指针时还有一点微小的不同:验证并调用版本的 rax 寄存器中已经存放了该坏指针,因此不需要再做任何操作将其移入其中。)

验证并调用版本将其参数和临时寄存器移至 x86-64 Windows 调用约定未使用的寄存器上,这样它就能以一条 jmp rax 指令结束,在所有函数参数完好无损的情况下跳转至已验证的函数。

对于 AArch64,情况类似。

    adrp        xip0,ntdll!....
    ldr         xip0,[xip0,#0x598]

    lsr         xip1,x15,#6
    tst         x15,#0xF
    ldrb        wip1,[xip0,xip1]
    ubfx        xip0,x15,#3,#3
    bne         @2

    lsr         xip1,xip1,xip0
    tbz         wip1,#0,@3
@1: ret

@2: and         xip0,xip0,#-2
    lsr         xip1,xip1,xip0
    tbz         wip1,#0,@4
@3: tbnz        wip1,#1,@1
@4: mov         xip0,#0
    b           @5
@5: b           bad
    adrp        xip0,ntdll!....
    ldr         xip0,[xip0,#0x598]

    lsr         xip1,x9,#6
    tst         x9,#0xF
    ldrb        wip1,[xip0,xip1]
    ubfx        xip0,x9,#3,#3
    bne         @2

    lsr         xip1,xip1,xip0
    tbz         wip1,#0,@3
@1: br          x9

@2: and         xip0,xip0,#-2
    lsr         xip1,xip1,xip0
    tbz         wip1,#0,@4
@3: tbnz        wip1,#1,@1
@4: mov         xip0,#1
    mov         x15,x9
    b           bad

同样,代码序列是相同的;区别仅在于寄存器的使用。(以及检测到坏调用时的代码序列。)仅验证版本在 x15 中接收地址,而验证并调用版本在 x9 中接收地址。(两者都使用 xip0 和 xip1 作为临时寄存器。)验证并调用版本以一条 b r9 指令结束,直接跳转至已验证的地址,而不是返回。

同样,你可以从转移后的寄存器中提取出坏指针。对于 x86-64 的验证并调用版本,它是 rax;对于 AArch64 的验证并调用版本,它是 r9。

作者

Raymond 参与了 Windows 的演进已超过 30 年。2003 年,他创办了一个名为 The Old New Thing 的网站,其受欢迎程度远远超出了他的想象,这种发展至今仍让他感到有些胆战心惊。该网站还催生了一本书,巧合的是书名也叫《The Old New Thing》(Addison Wesley 出版社 2007 年出版)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账户上讲故事,但这些故事并不传达任何有用的信息。

需要完整排版与评论请前往来源站点阅读。