漏洞介绍
CVE-2021-31956是发生在NTFS.sys中一个提权漏洞,漏洞的成因是因为整形溢出导致绕过条件判断导致的。最后利用起来完成Windows提权
前置知识
在此之前可以大致了解一下关于NTFS
NTFS是一个文件系统具备3个功能 错误预警功能,磁盘自我修复功能和日志功能 NTFS是一个日志文件系统,这意味着除了向磁盘中写入信息,该文件系统还会为所发生的所有改变保留一份日志 当用户将硬盘的一个分区格式化为NTFS分区时,就建立了一个NTFS文件系统。NTFS文件系统同FAT32文件系统一样,也是用簇为存储单位。 从微软的官方文档中可以搜索到关于NTFS volume每个属性的介绍。比如文件的数据是一个属性;数据属性:$Data , 类推关于此漏洞的关键扩展属性就是: $EA
EA(Extended the attribute index)
漏洞点分析
首先这个函数可以通过ntoskrnl 系统调用来访问,此外还可以控制输出缓冲区的大小,如果扩展属性的大小没有对齐,此函数将计算下一个填充,下一个扩展属性将存储为32位对齐。(每个Ea块都应该被填充为32位对齐) 关于对齐的介绍于计算
(padding = ((ea_block_size + 3) & 0xFFFFFFFC) - ea_block_size )
涉及到一个4字节对齐的概念,这个在微软的官方文档也有提到的官方文档
然后开始代码分析
恢复一下此函数卡巴斯基的文章也存在部分参考
后边用到的结构体 typedef struct _FILE_FULL_EA_INFORMATION { ULONG NextEntryOffset;//下一个同类型结构的偏移,若是左后一个为0 UCHAR Flags; UCHAR EaNameLength;//eaname数组的长度 USHORT EaValueLength;//数组中每个ea值的长度 CHAR EaName[1]; } FILE_FULL_EA_INFORMATION, *PFILE_FULL_EA_INFORMATION; typedef struct _FILE_GET_EA_INFORMATION { ULONG NextEntryOffset; UCHAR EaNameLength; CHAR EaName[1]; } FILE_GET_EA_INFORMATION, * PFILE_GET_EA_INFORMATION;
进行函数的部分恢复,这样后续确认漏洞点的话就会比较明显
_QWORD *__fastcall NtfsQueryEaUserEaList( _QWORD *a1, FILE_FULL_EA_INFORMATION *eas_blocks_for_file, __int64 a3, __int64 User_Buffer, unsigned int User_Buffer_Length, FILE_GET_EA_INFORMATION *UserEaList, char a7) { int v8; // edi unsigned int v9; // ebx unsigned int padding; // r15d FILE_GET_EA_INFORMATION *GetEaInfo; // r12 ULONG NextEntryOffset; // r14d unsigned __int8 EaNameLength; // r13 FILE_GET_EA_INFORMATION *i; // rbx unsigned int v15; // ebx _DWORD *out_buf_pos; // r13 unsigned int ea_block_size; // r14d unsigned int v18; // ebx FILE_FULL_EA_INFORMATION *ea_block; // rdx char v21; // al ULONG v22; // [rsp+20h] [rbp-38h] unsigned int ea_block_pos; // [rsp+24h] [rbp-34h] BYREF _DWORD *v24; // [rsp+28h] [rbp-30h] struct _STRING DesEaName; // [rsp+30h] [rbp-28h] BYREF STRING SourceString; // [rsp+40h] [rbp-18h] BYREF unsigned int occupied_length; // [rsp+A0h] [rbp+48h] v8 = 0; *a1 = 0i64; v24 = 0i64; v9 = 0; occupied_length = 0; padding = 0; a1[1] = 0i64; while ( 1 ) { // 创建一个索引放入ealist成员,后续循环取值 GetEaInfo = (FILE_GET_EA_INFORMATION *)((char *)UserEaList + v9); *(_QWORD *)&DesEaName.Length = 0i64; DesEaName.Buffer = 0i64; *(_QWORD *)&SourceString.Length = 0i64; SourceString.Buffer = 0i64; DesEaName.Length = GetEaInfo->EaNameLength; DesEaName.MaximumLength = DesEaName.Length; DesEaName.Buffer = GetEaInfo->EaName; RtlUpperString(&DesEaName, &DesEaName); if ( !(unsigned __int8)NtfsIsEaNameValid(&DesEaName) ) break; NextEntryOffset = GetEaInfo->NextEntryOffset; EaNameLength = GetEaInfo->EaNameLength; v22 = GetEaInfo->NextEntryOffset + v9; for ( i = UserEaList; ; i = (FILE_GET_EA_INFORMATION *)((char *)i + i->NextEntryOffset) ) { if ( i == GetEaInfo ) { v15 = occupied_length; out_buf_pos = (_DWORD *)(User_Buffer + padding + occupied_length);// // 分配的内核池 if ( (unsigned __int8)NtfsLocateEaByName(// 通过名字查找EA信息 eas_blocks_for_file, *(unsigned int *)(a3 + 4), &DesEaName, &ea_block_pos) ) { ea_block = (FILE_FULL_EA_INFORMATION *)((char *)eas_blocks_for_file + ea_block_pos); ea_block_size = ea_block->EaValueLength + ea_block->EaNameLength + 9; if ( ea_block_size <= User_Buffer_Length - padding )// 此处其实有个防止溢出的大小的检查 { memmove(out_buf_pos, ea_block, ea_block_size);// 缓冲区溢出的漏洞点 *out_buf_pos = 0; goto LABEL_8; } } else { ea_block_size = GetEaInfo->EaNameLength + 9;// 通过名字没查到EA信息走的分支 if ( ea_block_size + padding <= User_Buffer_Length ) { *out_buf_pos = 0; *((_BYTE *)out_buf_pos + 4) = 0; *((_BYTE *)out_buf_pos + 5) = GetEaInfo->EaNameLength; *((_WORD *)out_buf_pos + 3) = 0; memmove(out_buf_pos + 2, GetEaInfo->EaName, GetEaInfo->EaNameLength); SourceString.Length = DesEaName.Length; SourceString.MaximumLength = DesEaName.Length; SourceString.Buffer = (PCHAR)(out_buf_pos + 2); RtlUpperString(&SourceString, &SourceString); v15 = occupied_length; *((_BYTE *)out_buf_pos + GetEaInfo->EaNameLength + 8) = 0; LABEL_8: v18 = ea_block_size + padding + v15; occupied_length = v18; if ( !a7 ) { if ( v24 ) *v24 = (_DWORD)out_buf_pos - (_DWORD)v24; if ( GetEaInfo->NextEntryOffset ) { v24 = out_buf_pos; User_Buffer_Length -= ea_block_size + padding; padding = ((ea_block_size + 3) & 0xFFFFFFFC) - ea_block_size;// padding对齐的计算 goto LABEL_26; } } LABEL_12: a1[1] = v18; LABEL_13: *(_DWORD *)a1 = v8; return a1; } } v21 = NtfsStatusDebugFlags; a1[1] = 0i64; if ( v21 ) NtfsStatusTraceAndDebugInternal(0i64, 2147483653i64, 919406i64); v8 = -2147483643; goto LABEL_13; } if ( EaNameLength == i->EaNameLength && !memcmp(GetEaInfo->EaName, i->EaName, EaNameLength) ) break; } if ( !NextEntryOffset ) { v18 = occupied_length; goto LABEL_12; } LABEL_26: v9 = v22; } a1[1] = v9; if ( NtfsStatusDebugFlags ) NtfsStatusTraceAndDebugInternal(0i64, 2147483667i64, 919230i64); *(_DWORD *)a1 = -2147483629; return a1; }
恢复之后,直接根据卡巴文章中说的定位到漏洞点memmove
从图中可以很明显看见漏洞的触发点,NtfsQueryEaUserEaList 此函数在处理文件的扩展属性列表时,将其检索到的值存储到了缓冲区内 然后代码检查输出缓冲区是否足够长,以满足扩展属性的填充,但它没有检测整数下溢。结果就导致了堆上缓冲区的溢出的发生。
漏洞的触发点是memmove这块,其实就是绕过了代码针对于ea_block_size的检查
ea_block_size <= User_Buffer_Length - padding ,这段虽然考虑到常见的溢出检查,检查了大小的边界,但是忘记考虑三个参数都是uit32无符号整数的这就导致无符号整数的作差的结果会绕过这个条件判断。
那么三个参数是如何来的,哪一个是用户态可控的,因为如果ea_block_size可控且User_Buffer_Length可控为0就可以轻松绕过检查,ea_block_size还可以正好导致溢出发生。
NtfsQueryEaUserEaList函数大致会做循环遍历文件的每个NTFS扩展属性(Ea-Extended the attribute index)然后从ea_block复制出来这些buffer到缓冲区 (ea_block_size的值)
ea_block_size的值又是由ea_block决定的(ea_block->EaValueLength + ea_block->EaNameLength + 9)
其实最后就是绕过这个检查 具体绕过思考
参考与ncc的计算方法,用数学公式表达一下方便(注:以下是根据代码转换成数学公式,只是个人觉得这么理解第一次比较好理解哈) ea_block_size <= User_Buffer_Length - padding 上边说过是绕过这个条件判断的检查 首先假设几个值 EaNameLength = x ,EaValueLength = y ,ea_block_size = z ,padding就是padding本身,User_Buffer_Length = f 那么首先能根据代码确定几个式子 z = x + y + 9 , 判断条件为 z <= f - padding 首先开始第一次循环从数组里取值 假设x = 5 ,y = 4 , 所以z = 5 + 4 + 9 = 18 ,padding = 0 此时如果 设其值为30(User_Buffer_Length -= ea_block_size + padding) 那么f = 30 - z + 0 = 12 然后计算padding = ((z + 3)& 0xFFFFFFFC) - z = 2 第二次从扩展属性取值,依旧 x = 5, y =4 ,z = 5 + 4 + 9=18 此时padding为2 f = 12那么 18 <= 12 - 2 这个条件不成立,这是正常的想进行溢出的流程 这是假设其值为30的情况也就是f稍大于z的情况,那么我们假设的值不是30是18呢 再来一遍 第一次循环取值 x = 5,y = 4 , z = 5 + 4 + 9 =18 不变,padding 依旧是0 18 <= 18 - 0这时候此时条件是满足,接着往下进行 设其值为18 (User_Buffer_Length -= ea_block_size + padding) 那么f = 18 - 18 + 0 =0 ,padding计算不变 因为觉得padding的值 z 并没有变化 padding = ((z + 3)& 0xFFFFFFFC) - z = 2 我们第二次扩展 x = 5 , y = 99 , z = 5 + 99 + 9 = 113 z <= f - padding 也就是 113 <= 0 - 2 ,因为是无符号整数,最后-2就会导致整数溢出 从而绕过了这个条件那么超出的大小就会被覆盖到相邻的内存导致溢出
代码中其实可以看见其会不断遍历ea_block数组里边的值,然后再根据FILE_GET_EA_INFORMATION 获取到文件里的EA信息,通过上述的分析我们已经知道如何过掉溢出的检查了
再来跟进一下后续的内存拷贝过程,发现函数其目的地址由参数传入于是查看引用,该参数是在NtfsCommonQueryEa函数中分配的内核池
NtfsCommonQueryEa -->ExAllocatePoolWithTag
分配的池空间PoolWithTag 到 NtfsQueryEaUserEaList -->User_Buffer --> out_buf_pos 最后memmove触发
漏洞触发利用
了解了漏洞触发点之后,下一步就是验证。
首先需要创建一个文件然后添加EA拓展属性 =>NtSetEaFile
该函数的第3个参数是一个FILE_FULL_EA_INFORMATION结构的缓冲区,用来指定Ea属性的值。所以我们可以利用EA属性来构造PAYLOAD, 然后使用NtQueryEaFile函数来触发NtQueryEaFile
查询一下能对EA 扩展属性进行操作的api
记一下这两个 ZwQueryEaFile , ZwSetEaFile 分别对应 NtSetEaFile , NtQueryEaFile
NTSTATUS ZwQueryEaFile( [in] HANDLE FileHandle, //文件句柄 [out] PIO_STATUS_BLOCK IoStatusBlock, [out] PVOID Buffer, //扩展属性缓冲区(FILE_FULL_EA_INFORMATION结构) [in] ULONG Length, //缓冲区大小 [in] BOOLEAN ReturnSingleEntry, [in, optional] PVOID EaList, //指定需要查询的扩展属性 [in] ULONG EaListLength, [in, optional] PULONG EaIndex, //指定需要查询的起始索引 [in] BOOLEAN RestartScan ); NTSTATUS ZwSetEaFile( [in] HANDLE FileHandle, [out] PIO_STATUS_BLOCK IoStatusBlock, [in] PVOID Buffer, [in] ULONG Length, );
ZwQueryEaFile 中的 Buffer 来源性如下图所示
然后分别进行Set与Query的调用来进行漏洞利用
CVE-2021-31956 漏洞利用是通过Windows Notification Facility(WNF)来实现任意内存读写原语。
WNF 是一个通知系统在整个系统中的主要任务就是用于通知,相当于通知中心。它可以在内核模式中使用,也可以在用户态被调用WNF
我们要明白上述的输出缓冲区buffer是从用户空间传入的,同时传入的还有这个缓冲区的长度。这意味着我们最终会根据缓冲区的大小控制内核空间的大小分配,触发漏洞的话还需要触发如上所述的溢出。
我们需要进行堆喷在内核进行我们想要的堆布局。
利用手法是WNF
WNF_STATE_DATA //用户可以定义的 NtCreateWnfStateName //创建WNF对象实例=>WNF_NAME_INSTANCE NtUpdateWnfStateData //写入数据存放在WNF_STATE_DATA NtQueryWnfStateData //读取写入的数据 NtDeleteWnfStateData //释放Create创建的对象
有限的地址读写
所以首先要通过NtCreateWnfStateName创建一个WNF对象实例
要利用漏洞溢出点Ntfs喷出来的堆块去覆盖WNF_STATE_DATA中的DataSize成员和AllocateSize成员。
然后可以利用NtQueryWnfStateData去进行读取,NtUpdateWnfStateData 去进行修改相邻WNF_NAME_INSTANCE数据,但是此时这里完成的有限的地址读写。
任意地址读写
利用相对内存写修改邻近的 WNF_NAME_INSTANCE结构的 StateData指针为任意内存地址,然后就可以通过NtQueryWnfStateData,NtUpdateWnfStateData来实现任意地址读写了。
最后可以通过NtDeleteWnfStateData可以释放掉这个对象。
提权
已经有了任意地址读写,然后就可以读取去遍历进程链表找到System进程
找到System进程后,接着去读取高进程的token,最后利用内存写,修改当前进程的token替换成我们读取到的System进程的token完成提权。效果如下