Windows内核

安全通讯

乱写Ring3的代码让Ring0崩掉???

例如Ring3给Ring0传一个地址,然后Ring0去访问这块内存,内核直接读写用户内存地址,就会造成系统崩溃。

那么如何去预防?

1.禁止内核直接访问用户层的内存地址(这能避免大部分漏洞)

非要用要保证:
1.检查三环地址是否有效(ProbeRead/ProbeWrite)
2.保证进程不产生切换(严格检测)

但是不访问三环地址,很难进行通讯

微软就提供了通讯方式(解决缓冲区安全问题)

1.缓冲区方式

2.直接方式

3.其他方式(直接读写用户层的内存地址 Irp->UserBuffer就是直接读写用户层地址,这个很危险)

如何设置?

在创建设备完,要设置缓冲区通讯方式,如果不设置,默认用其他方式,也就是直接访问用户层的内存地址:

1720924746091

设置DEVICE_OBJECT结构体里面的Flags,有缓冲区通讯和直接通讯

1
2
3
4
5
6
7
8
9
10
status = IoCreateDevice(
DriverObject, // 驱动程序对象
0, // 设备扩展大小
&DeviceName, // 设备名称
FILE_DEVICE_UNKNOWN, // 设备类型
0, // 设备特征
FALSE, // 非独占设备
&DeviceObject // 返回的设备对象指针
);
DriverObject->DeviceObject->Flags |= DO_BUFFERED_IO;

这个设置对三环没有任何影响,三环不需要关心内核具体怎么存取这些数据

然后缓冲区是存在

1
Irp->AssociatedIrp.SystemBuffer

这块缓冲区不可能不可读,不可写,不用ProbeForWrite/ProbeForRead判断

DO_BUFFERED_IO:

1720925960327

当应用程序请求 I/O 操作时,操作系统会在内核模式下分配一个缓冲区。这块缓冲区是用于保存从用户模式传入或传出的数据。这些缓冲区是在内核地址空间中分配的,确保在整个 I/O 操作期间内核模式的驱动程序都可以访问。

数据拷贝

  • 写操作:对于写操作,系统会将用户模式下的数据拷贝到内核分配的缓冲区中。这样做的好处是确保用户模式的数据在整个 I/O 操作过程中不会被修改,从而提高数据的安全性。
  • 读操作:对于读操作,系统会在驱动程序完成数据传输后,将内核缓冲区中的数据拷贝回用户模式缓冲区。这样可以确保驱动程序只在需要时访问数据,减少潜在的安全漏洞。

I/O 完成:当 I/O 操作完成时,操作系统会处理缓冲区中的数据并通知应用程序。这一步确保数据的一致性和完整性。因为数据在传输过程中是独立的缓冲区,任何对用户模式数据的更改都不会影响已经传输的数据。

同步与互斥:在多线程环境中,使用缓冲 I/O 可以避免直接访问共享资源,从而减少并发冲突的风险。内核会管理这些缓冲区,确保同一时间只有一个线程可以访问特定的缓冲区,确保数据的安全性。

错误处理:在数据传输过程中,任何错误都会被及时捕获和处理。由于数据首先被拷贝到内核缓冲区,可以在实际 I/O 操作前进行检查和验证,确保数据的合法性和完整性。

问题: 这样内核不也访问了用户层的地址吗?例如将用户层的数据拷贝到缓冲区,这个难道不是访问了用户层地址??为什么说没有直接访问呢??

答:内核确实需要访问用户模式的地址,以将数据从用户模式缓冲区拷贝到内核缓冲区,或从内核缓冲区拷贝到用户模式缓冲区。然而,这种访问是一次性和受控的

在缓冲 I/O 中,内核对用户模式缓冲区的访问仅限于一次性的数据拷贝操作。数据一旦拷贝到内核缓冲区,后续的所有操作都是在内核缓冲区中进行的,这样可以减少对用户模式缓冲区的反复访问,降低安全风险

在数据拷贝过程中,可以进行数据验证。例如,在写操作时,内核可以在将数据拷贝到内核缓冲区之前验证数据的合法性和完整性。这样可以防止恶意用户提供的无效数据对系统造成影响。

在缓冲 I/O 模式下,用户模式和内核模式的内存空间是隔离的。在数据拷贝过程中,内核会进行适当的内存保护检查,确保内核访问用户模式缓冲区时不会越界或访问无效内存地址。

缺点是:如果是大量数据,那么开销会比较大

代码示例

一旦 DriverEntry 成功完成并返回,驱动才算完全加载完成,系统和用户模式应用程序才可以与驱动的设备对象进行交互,从而触发并执行这些 IRP 例程函数。换句话说,必须等 DriverEntry 函数完成初始化后,IRP 例程函数才能正常工作

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
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) 
{
// 初始化设备对象和驱动对象
PDEVICE_OBJECT DeviceObject = NULL;
NTSTATUS status = IoCreateDevice(
DriverObject,
0,
&DeviceName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&DeviceObject
);
if (!NT_SUCCESS(status)) return status;

// 设置 DO_BUFFERED_IO 标志
DeviceObject->Flags |= DO_BUFFERED_IO;
return STATUS_SUCCESS;
}

NTSTATUS IRP_CALL_IRP_MJ_DEVICE_CONTROL(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp);
NTSTATUS status = STATUS_SUCCESS;
ULONG controlCode = irpSp->Parameters.DeviceIoControl.IoControlCode;

// 通过 Irp->AssociatedIrp.SystemBuffer 获取数据
if (controlCode == IOCTL_READ_DATA)
{
PVOID buffer = Irp->AssociatedIrp.SystemBuffer;
// 对 buffer 数据进行处理
}

IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}

DO_DIRECT_IO

1720926968048

让三环直接去操作三环地址,但是又要保证安全,如何做到呢??

Irp->UserBufferDO_DIRECT_IO 模式下使用的 MmGetSystemAddressForMdlSafe 有一些关键的区别。虽然它们都涉及访问用户模式缓冲区,但它们的使用方式和内存管理机制不同。

Irp->UserBuffer 是一个指向用户模式缓冲区的指针,在某些情况下可以直接访问该缓冲区。这种方式通常用于不涉及大量数据传输的简单 I/O 操作。

当使用 DO_DIRECT_IO 标志时,操作系统通过 MDL(内存描述符列表)机制管理用户模式缓冲区。 MDL 包含了缓冲区的起始地址、长度以及对应的物理页信息。 MmGetSystemAddressForMdlSafe 用于将 MDL 描述的用户缓冲区映射到内核地址空间,允许内核直接访问。

主要特点:

  1. 使用 MDLDO_DIRECT_IO 使用 MDL 来描述用户模式缓冲区。这使得内核能够处理较大块的数据传输。
  2. 高效传输:通过直接映射用户缓冲区到内核地址空间,减少了数据拷贝,提高了传输效率。
  3. 内存保护:MDL 确保用户缓冲区在整个 I/O 操作期间不会被分页出内存,从而保证内核可以安全地访问这些页面。
  4. 映射和解除映射MmGetSystemAddressForMdlSafe 函数用于将 MDL 描述的用户缓冲区映射到内核地址空间。完成 I/O 操作后,内存映射会被解除,确保资源得到正确释放。

而这个MDL结构在Irp里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct _MDL {
struct _MDL *Next;//指向下一个 MDL 结构体的指针,允许将多个 MDL 链接在一起,形成一个 MDL 链表。这在描述分散的内存区域时非常有用。
CSHORT Size;//表示 MDL 结构体的大小,包括 MDL 头和所有的页面框架数组。
CSHORT MdlFlags;//标志字段,包含了描述 MDL 当前状态和属性的位。常见的标志有:
//MDL_MAPPED_TO_SYSTEM_VA:MDL 已映射到系统虚拟地址空间。
//MDL_SOURCE_IS_NONPAGED_POOL:MDL 描述的内存来自非分页池。
struct _EPROCESS *Process;//指向创建 MDL 的进程结构体(EPROCESS)。这对于用户模式内存描述很重要,因为它关联了内存所有权。
PVOID MappedSystemVa; //指向缓冲区在系统虚拟地址空间中的地址。如果内存已经映射到系统地址空间,这个字段会被设置。
PVOID StartVa; //缓冲区的起始虚拟地址,通常是页面对齐的地址。

//理解为将StartVa映射到MappedSystemVa

ULONG ByteCount;//缓冲区的大小(以字节为单位),即 MDL 描述的内存区域的总字节数。
ULONG ByteOffset; //缓冲区起始地址到页面边界的偏移量。这对于正确计算内存地址很重要。
} MDL, *PMDL;

当然我们不需要自己去MDL自己去拿映射地址,我们可以通过一个API叫做:MmGetSystemAddressForMdlSafe。这个API可以获取映射后的地址,直接对这块内存进行操作就行

在实际内核驱动开发优先使用缓冲区Buffer

代码示例

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
#include <ntddk.h>

#define IOCTL_READ_DATA CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_IN_DIRECT, FILE_ANY_ACCESS)
#define IOCTL_WRITE_DATA CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_OUT_DIRECT, FILE_ANY_ACCESS)

// 驱动卸载例程
void DriverUnload(PDRIVER_OBJECT DriverObject)
{
UNICODE_STRING symbolicLinkName = RTL_CONSTANT_STRING(L"\\??\\YourDeviceLinkName");
IoDeleteSymbolicLink(&symbolicLinkName);
IoDeleteDevice(DriverObject->DeviceObject);
}

// 设备控制例程 (处理 IRP_MJ_DEVICE_CONTROL)
NTSTATUS DeviceControlHandler(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp);
NTSTATUS status = STATUS_SUCCESS;
ULONG controlCode = irpSp->Parameters.DeviceIoControl.IoControlCode;
ULONG inBufferLength = irpSp->Parameters.DeviceIoControl.InputBufferLength;
ULONG outBufferLength = irpSp->Parameters.DeviceIoControl.OutputBufferLength;
PVOID inputData = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); // 获取输入缓冲区地址
PVOID outputData = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); // 获取输出缓冲区地址

if (inputData == NULL || outputData == NULL)
{
status = STATUS_INSUFFICIENT_RESOURCES;
}
else if (controlCode == IOCTL_READ_DATA)
{
const char *dataToSend = "Hello from Kernel!";
ULONG dataLength = (ULONG)strlen(dataToSend) + 1;

if (outBufferLength >= dataLength)
{
RtlCopyMemory(outputData, dataToSend, dataLength); // 将数据写入映射的输出缓冲区
Irp->IoStatus.Information = dataLength;
status = STATUS_SUCCESS;
}
else
{
status = STATUS_BUFFER_TOO_SMALL;
}
}
else if (controlCode == IOCTL_WRITE_DATA)
{
KdPrint(("Data from user: %s\n", (char*)inputData)); // 处理用户传递的数据
status = STATUS_SUCCESS;
}

Irp->IoStatus.Status = status;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}

// DriverEntry 函数
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\YourDeviceName");
UNICODE_STRING symbolicLinkName = RTL_CONSTANT_STRING(L"\\??\\YourDeviceLinkName");
PDEVICE_OBJECT DeviceObject = NULL;
NTSTATUS status;

status = IoCreateDevice(
DriverObject,
0,
&deviceName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&DeviceObject
);

if (!NT_SUCCESS(status))
{
return status;
}

DeviceObject->Flags |= DO_DIRECT_IO; // 设置 DO_DIRECT_IO 标志
status = IoCreateSymbolicLink(&symbolicLinkName, &deviceName);
if (!NT_SUCCESS(status))
{
IoDeleteDevice(DeviceObject);
return status;
}

DriverObject->DriverUnload = DriverUnload;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DeviceControlHandler;

return STATUS_SUCCESS;
}

用户层代码模板:

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
#include <windows.h>
#include <stdio.h>

#define IOCTL_READ_DATA CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_IN_DIRECT, FILE_ANY_ACCESS)
#define IOCTL_WRITE_DATA CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_OUT_DIRECT, FILE_ANY_ACCESS)

int main()
{
HANDLE hDevice = CreateFile(
L"\\\\.\\YourDeviceLinkName",
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);

if (hDevice == INVALID_HANDLE_VALUE)
{
printf("Failed to open device\n");
return 1;
}

// 向驱动写数据
char writeData[] = "Hello from User!";
DWORD bytesReturned;
BOOL success = DeviceIoControl(
hDevice,
IOCTL_WRITE_DATA,
writeData,
sizeof(writeData),
NULL,
0,
&bytesReturned,
NULL
);

if (success)
{
printf("Data written to driver successfully.\n");
}
else
{
printf("Failed to write data to driver.\n");
}

// 从驱动读数据
char readData[128] = {0};
success = DeviceIoControl(
hDevice,
IOCTL_READ_DATA,
NULL,
0,
readData,
sizeof(readData),
&bytesReturned,
NULL
);

if (success)
{
printf("Data read from driver: %s\n", readData);
}
else
{
printf("Failed to read data from driver.\n");
}

CloseHandle(hDevice);
return 0;
}

控制中指定输入输出缓冲区

在IRP_MJ_DEVICE_CONTROL中,CTL_CODE是可以指定输入或者输出在哪一个Buffer的

用缓冲区的方式,但是SystemBuffer只有一个,也就是共用,反正输入进来读完就没用了,输出还可以继续使用这个SystemBuffer,不冲突的。

1720945067910

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
switch (METHOD_FROM_CTL_CODE(ioControlCode)) {
case METHOD_BUFFERED: //用缓冲区的方式,但是SystemBuffer只有一个,也就是共用,反正输入进来读完就没用了,输出还可以继续使用这个SystemBuffer,不冲突的
inputBuffer = Irp->AssociatedIrp.SystemBuffer;
outputBuffer = Irp->AssociatedIrp.SystemBuffer;
break;

case METHOD_IN_DIRECT: //输入用缓冲区,输出用直接方式(映射)
inputBuffer = Irp->AssociatedIrp.SystemBuffer;
if (Irp->MdlAddress)
{
outputBuffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
}
break;

case METHOD_OUT_DIRECT://输入用缓冲区,输出用直接方式(映射),这是坑点
inputBuffer = Irp->AssociatedIrp.SystemBuffer;
if (Irp->MdlAddress)
{
outputBuffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
}
break;

case METHOD_NEITHER: //全是三环地址,有较大的安全隐患
inputBuffer = irpSp->Parameters.DeviceIoControl.Type3InputBuffer;
outputBuffer = Irp->UserBuffer;
break;

default:
status = STATUS_INVALID_DEVICE_REQUEST;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}

值得注意的是METHOD_IN_DIRECT和METHOD_OUT_DIRECT都是一样的,输入用缓冲区,输出用直接方式(映射),这是坑点

内存池的概念:

ExAllocatePoo;

NonPagedPool 非分页内存池
PagedPool 分页内存池

打个比方,教室座位有限,但是学生多,非分页内存池相当于老师,谁让出座位都行,但是老师不行。分页内存池相当于学生,是可以让出座位的。

非分页内存池当不用的时候,数据是不会从内存放到磁盘上的,但是分页内存池是可以暂时放在磁盘下的、

所以申请内存的时候,我们尽量申请分页内存,因为非分页内存比较宝贵。

三环用户层不允许申请非分页内存

现在我们写的驱动代码并不重要,所以我们要把这个代码放在非分页内存上

如何做呢?需要用到一个关键字

#pragma alloc_text 是一个编译指令,用于指定特定函数或代码段在内存中的分配位置。它主要用于内核驱动开发,帮助开发者将代码段放置在特定的内存区域,以优化内存使用或满足特定的内存布局需求。

1
#pragma alloc_text(section_name, function1, function2, ...)

section_name:指定代码段将被放置的内存区段的名称。

function1, function2, ...:指定要放置在该内存区段中的函数。

具体区段

一些常见的区段名称和用途:

  • INIT:用于初始化代码。驱动程序加载完成后,这些代码通常会被卸载。
  • PAGE:用于分页代码。可以将不常用的代码分页到磁盘。
  • PAGEDCODE:用于分页代码,和 “PAGE” 类似。
  • NONPAGEDCODE:用于非分页代码,确保这些代码始终驻留在物理内存中。

所以一般我们都放在”PAGE”,默认是非分页代码

发现编译链接出来的驱动程序多了一个PAGE节,这就是分页代码,非分页代码就放在.text节里面

1720948119354

但是INIT节是干啥的?这玩意是为了更多的节省内存,具体来说,DriverEntry代码就在这里,跑完就从内存里面释放掉,这部分代码就是放在INIT节里面。

安全编程规范

1.不要使用 MmIsAddressValid函数,这个函数对于校验内存是否合法具有局限性

首先,他只能判断一个字节

攻击者只需要传递第一个字节在有效页,而第二个字节在无效页的内存就会导致系统崩溃,例如0x7000是有效页,0x8000是无效页,攻击者传入0x7fff,那么读写这块地址就会崩溃

2.如果要使用三环地址,一定要在try except 使用,并用ProbeForRead检查是否合法

3.留心长度为0的缓存,为NULL的缓存指针和缓存对齐,缓存长度为0的问题:

内存校验函数ProbeForRead和ProbeForWrite函数有一个很悲剧的特性,就是当ProbeForxxx的参数Length为空的时候,这两个函数不会做任何工作,所以一定要检查长度!

4.缓存指针为空的问题:

不要使用诸如下面代码来判断用户态参数:

1
2
3
4
if(UserBuffer==NULL)
{
goto bypass_request;
}

Windows操作系统是运行用户态申请一个地址为0的内存的,攻击者可以利用这个特性来绕过检查或者保护

5.不要直接调用该Nt开头的API

因为它们更底层,但是往往检查不严格

采用Zw开头的API替代,Zw底层是调用Nt的API,但是多了检测,增加安全性

三环掉API例如CreateFile传到内核其实也是调用的NtCreateFile,但是三环的API会对参数进行检测,所以安全性还是不错的