怎么提权?——前置知识
Windows内核交互
和LINUX类似,它也有ioctl这种的api,只不过windows内核封装了很多东西,提供了一大坨各种各样功能的api。另外,ntdll这个负责和windows内核通信的模块(可以理解成windows上的glibc)里有很多非公开的函数(是的他们有符号,只是没法用include从头文件引入而已),我们通过GetProcAddress
这个接口其实能获取到各种函数的地址。
虽然有KASLR但其实没用,因为windows有一个接口叫做NtQuerySystemInformation
几乎会告诉我们所有内核相关的地址信息…太有feature了。
其实是用NtQuerySystemInformation
可以按参数选定类别查信息,直接就能查到所有进程的handle以及管理他们的eproc结构体地址,PID为4的是一个系统进程,有高权限,而我们当前进程也可以用api查。然后我们的准备工作就完成了。
Windows进程权限
在Windows内核提权中,用户态的PEB信息没什么用,重要的是内核中的EPROCESS.每个进程在内核中都有一个EPROCESS结构体进行管理,里面存了UID和一个用于权限鉴别的token,和当前用户挂钩。如果我们能够篡改掉这个token我们的进程就有更高的权限了,类似于commit_creds(prepare_cred(0))
这种。但token是这么一个结构:
1 | //0x8 bytes (sizeof) |
类似于cookie,这个东西自己伪造不太现实,不过我们可以偷。和准备工作中说的一样,我们如果能从系统权限的EPROCESS偷一个高权限token复制进来就好了,这就要求我们有内核地址空间写的权限。
内核地址空间写入
首先有一个好消息,那就是我们Windows也有自己的write:任意虚拟地址写入的APINtWriteVirtualMemory
。这个函数会经过ntdll!NtWriteVirtualMemory->nt!NtWriteVirtualMemory
,而最后内核的函数是对MiWriteVirtualMemory
的包装。
但是想写不是那么容易的,通过windbg调试可以知道,如果是用户空间发起的写请求,地址会被windows严格的筛一遍,有三四个检查好像,包括直接和0x7fff..000
硬比较,也就是用户地址空间肯定是全ban的。视频截图中可以看到:
那么有没有办法能绕过呢?还是看这个包装后的内部系统调用,它用gs:[188h]
取了当前的thread,然后检查了一个叫做PreviousMode的字段。这个字段标识一个线程在发起系统调用请求的时候来自于用户还是内核,1表示用户0表示内核。如果它来自内核,那么写入就不会对地址进行那一大堆检查。因此我们要做的就是想办法在我们当前进程的线程kthread结构体中为previousmode字段写入0,这也是核心漏洞点:任意地址写0
漏洞分析
题目驱动分析
漏洞点在任意地址写0,先以这次的招新赛的青春版驱动为例,ida打开,很简单,观察驱动初始化函数发现除了构建和析构以外还有个疑似ioctl的函数,这个函数在驱动对象结构体的DriverObject->MajorFunction[14]
注册,是IO_DEVICE_CONTROL
函数(没错和libc类似也是用宏定义函数功能和注册位置)。不得不说,IDA对windows的支持确实好。
1 | NTSTATUS __fastcall init_driver(PDRIVER_OBJECT DriverObject) |
IOCTL函数如下:
1 | __int64 __fastcall func(__int64 a1, IRP *a2) |
首先我们看到这个LowPart是0x223450的时候调用的函数是对参数解引用两次后任意地址写0(这其实就是cve26229的青春版,只是那个要逆向一个真实的服务,这里就简化了而已)
1 | ... |
一些windows驱动相关的知识
-
IO设备在Windows以一个类似栈的结构堆叠起来,io请求从上到下处理,处理完毕就返回要么就传递给下一层(具体实现不懂,大概这么个思想)
-
windows将每一个io请求打包成一个IRP结构传入设备栈
-
它的if判断条件似乎是文件的读偏移(IDA F5是这么说的,不过反正最后poc传ioctl代码的时候也传一下就行,无所谓)
-
交互其实可以用Ntdll泄露拿函数或者是直接用包装好的也行:
DeviceIOControl()
下面说一下传入的那个写0的地址我们怎么控制。这个参数IDA识别出是IRP请求的AssociateIRP域下的masterirp。这个AssociateIRP其实是个联合体:
1 | struct _IRP{ |
在驱动程序创建的时候,如果是METHOD_BUFFERED类型,就会启用第三个域,也就是一个buffer指针
参考自https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/buffer-descriptions-for-i-o-control-codes
对于此传输类型,IRP 提供指向 Irp-AssociatedIrp.SystemBuffer> 处缓冲区的指针。 此缓冲区表示在调用 DeviceIoControl 和 IoBuildDeviceIoControlRequest 时指定的输入缓冲区和输出缓冲区。 驱动程序将数据从此缓冲区中传输出去,然后传入此缓冲区。对于输入数据,缓冲区大小由驱动程序IO_STACK_LOCATION结构中的 Parameters.DeviceIoControl.InputBufferLength 指定。 对于输出数据,缓冲区大小由驱动程序IO_STACK_LOCATION结构中的 Parameters.DeviceIoControl.OutputBufferLength 指定。
系统为单个输入/输出缓冲区分配的空间大小是两个长度值中的较大值。
从微软文档能看到,这里其实就是我们在IOCTL的时候传入的输入输出缓冲区。因此只要我们在用户态把缓冲区写好成指向我们拿到的_KTHREAD的PreviousMode,写0后就能利用任意地址写NtWriteVirtualMemory把System(pid 4)的token偷过来,然后重新将PreviousMode写成用户,调用system("cmd")
即可拿到system shell。
CVE-2024-26229
https://bbs.kanxue.com/thread-282185.htm
那么既然都懂了,我们简单看看实际环境下的漏洞复现是怎么样的。原理不用重复,这个CVE用的是Windows上的csc.sys驱动或者说服务。
csc.sys驱动是一个处理客户端缓存(Client-Side Caching)和提供离线文件功能的系统驱动(windows默认启用)。csc.sys允许用户在断网的情况下继续访问和操作网络文件,当用户在没有网络连接的情况下对这些文件进行更改时,这些更改首先影响的是本地缓存的副本;一旦网络连接恢复,CSC.sys 会负责将这些本地更改同步回网络位置,确保网络上的数据与本地的副本保持一致。
这个驱动有一个处理文件的函数csc!CscDevFcbXXXControlFile
存在问题(只是IOCTL变成文件操作码,用NtFsControlFile
控制,逻辑和参数都基本一样)。在传入操作码0x001401a3后会有如下逻辑调用:
1 | if ( *(_DWORD *)(a1 + 0x20C) == 0x1401A3 ) |
此处第二个写0利用的是我们传入的input buffer(和这道题一模一样),因此只需要:
1 | status = pNtFsControlFile( |
即可实现写0.
POC
VisualStudio 2022,参考自Github上的CVE-2024-26229 Poc. 前面的部分基本没改,查了EPROCESS的token偏移和Ktrhead的PreviousMode偏移在win11 22H2下都是对的,没变化,直接用就好了。
1 | // CVE-2024-26229 reuse |
总结
年轻人的第一个Windows内核CVE(也是第一个CVE)复现,即便是这种生产环境高危的漏洞,本质逻辑也就是那样。只是在发现读写原语(read/write primitive)利用点的区别,提权路径也就是那些了,最后都得偷token,linux都得commit_cred。