最近会花一些时间多调试几个最新的CVE漏洞,初步学习一下CVE漏洞分析和漏洞利用的技巧。
环境搭建
操作系统:Windows10; 版本:1903, 18362.30
下载地址:ed2k://|file|cn_windows_10_business_editions_version_1909_x64_dvd_0ca83907.iso|5275090944|9BCD5FA6C8009E4D0260E4B23008BD47|/
POC地址:
python
远程RCE
:https://github.com/chompie1337/SMBGhost_RCE_PoC.git
本地提权,启动cmd:https://github.com/danigargu/CVE-2020-0796/releases
其他要求:
关闭windows自带的defender
以及防火墙,开启445端口。
smb协议基础:
双机调试:
之前已经搭建好了,但是换了个虚拟机又忘了怎么搭了。这里记录一下,以后可以参考:
- 在虚拟机编辑虚拟机设置中,添加串行端口,选择使用命名管道,管道名设置为:
\\.\\pipe\com_1
;注意这里如果硬件里有打印机需要把打印机移除。
在调试主机打开windbg,选择
kernel debug
,这里选择管道,将管道名设置为上面一致。在虚拟机中,以管理员权限打开
cmd
,输入设置端口1,命令如下:bcdedit /dbgsettings serial baudrate:115200 debugport:1
;该处的“1”,对应com口1。复制一个开机选项,命名为“DebugEntry”,可任意命名。命令如下:
bcdedit /copy {current} /d DebugEntry
增加一个开机引导项
bcdedit /displayorder {current} {ID}
注:这个ID要填写上一条命令生成的一串数字或字母。
激活debug
bcdedit /debug {ID} ON
注:ID以生成的数字或字母串代替。
启动windbg ,windbg 处于等待状态。重启虚拟机,选择“
DebugEntry[debug]
”作为启动项。
漏洞分析
cve-2020-0796漏洞存在于Windows驱动srv2.sys
中,如果使用上面我提供的系统,则 srv2.sys
的 md5
为 4be9228e2b5fc780be48697c17a741e3
。Windows SMB v3.1.1 版本增加了传输接受压缩数据的功能,如下图所示(图来自 ADLab):
其中,SMB Compression Transform Header
的结构如下:
- Protocolld:4字节,固定为 0x424D53fc;
- OriginalComressedSegmentSize:4字节,原始的未压缩数据大小;
- CompressionAlgorithm:2字节,压缩算法
- Flags:2字节;
- Offset/Length:根据Flags的取值为Offset,或者为 Length,Offset标识数据包中压缩数据相对于当前结构的偏移。
当接收到含有压缩数据的数据包时,smb
会调用 srv2!Srv2DecomperssData
函数,如下所示:
1 | __int64 __fastcall Srv2DecompressData(__int64 smb_packet) |
解释一下,总体处理逻辑:
- 传入参数为整个
smb_packet
,获取其中的SMB Compression Transform Header
数据,这里我命名为smb_ct_header
- 从
smb_ct_header
的第8个字节处,取出CompressionAlgorithm
,命名为c_alg
,随后的smb_compress_alg
这里是根据下面与c_alg
进行比较,猜测这里也是存储了compression algorithm
; - 随后就是调用
srvnet!SrvNetAllocateBuffer
为解压缩的数据分配存储空间,参数1为OriginalComressedSegmentSize
+offset
,注意这里为 unsigned int类型,存在整数溢出漏洞; - 返回值为
alloc_buf
,随后就会调用srvnet!SmbCompressionDecompress
对压缩数据进行解压缩,参数1为压缩算法,参数2为压缩数据所在地址,参数3为压缩数据大小,参数4为解压缩数据在alloc_buf
存储的位置(这里为什么是alloc_buf+24
,下面会讲),参数5为原始数据的大小,参数6为将会存储解压缩后数据的大小; - 比较解压缩数据大小和原始数据大小是否一致,若一致则调用
memmove
将smb_ct_header
拷贝到alloc_buf+0x18
指向的地址处(这一步很关键,记住这里是对 alloc_buffer+0x18这个指向的地址进行了赋值),若不一致则释放alloc_buf
; - 最后是用
alloc_buf
代替原有的smb_packet
。
这里,从poc
中我们可以看到构造的 smb_ct_header
如下:
1 | const uint8_t buf[] = { |
其中 OriginalComressedSegmentSize
为 0xffffffff
,而 offset
为 0x10
,那么 OriginalComressedSegmentSize
+offset
如果转换为 unsigned int
为 0xf
。那么这里将会申请的 alloc_buf
就将按照 0xf
来申请,但是我们解压缩数据将会远远超出 alloc_buf
的大小,这里就存在堆溢出漏洞。
下面,分析一下 alloc_buf
将会是多大,srvnet!SrvNetAllocateBuffer
如下:
1 | PSLIST_ENTRY __fastcall SrvNetAllocateBuffer(unsigned __int64 size, __int64 a2) |
这里,重点看一下内存分配策略,主要分为3类,其中小于 1Mb时将会取用SrvNetBufferLookasides[0]
分配,Lookaside
列表用于有效地为驱动程序保留一组可重用的、固定大小的缓冲区。lookaside
列表的功能之一是定义一个自定义的分配/释放函数,用于管理缓冲区。查看SrvNetBufferLookasides
数组的引用,发现其是在SrvNetCreateBufferLookasides
函数中初始化:
1 | __int64 SrvNetCreateBufferLookasides() |
这里重点关注PplCreateLookasideList
的第5个参数 (1 << (v3 + 12)) + 256
,这个参数就是初始化 Lookasides
表的大小,我们可以自行计算出来,可得 Lookasides
表中的9个buff
大小依次为 : [‘0x1100’, ‘0x2100’, ‘0x4100’, ‘0x8100’, ‘0x10100’, ‘0x20100’, ‘0x40100’, ‘0x80100’, ‘0x100100’]。由于我们申请的 size
为 0xf
,所以这里就会返回 0x1100
大小的堆空间。
而 PplCreateLookasideList
分配空间,仍然是调用 SrvNetAllocateBufferFromPool
:
1 | __int64 __fastcall SrvNetBufferLookasideAllocate(__int64 a1, __int64 a2) |
而SrvNetAllocateBufferFromPool
使用ExAllocatePoolWithTag
函数在NonPagedPoolNx
池中分配一个缓冲区,然后用数据填充一些结构。分配的缓冲区的布局如下:
这里我们需要重点关注的除了用户数据区,就是 分配头结构。函数返回的 alloc_buf
就是 return_buffer
的地址,其与用户数据区的偏移为 0x1100
,并且 return_buf+0x18
处指向了用户数据区。这里也就能解释上面为什么解压缩数据存储在 alloc_buf+24
处了。
分配完内存后,就开始对压缩数据进行解压缩,其中主要调用得是系统函数 RtlDecompressBufferEx2
:
1 | v14 = final_original_size; |
这里,exp
中使用的其实是 RtlCompressBuffer
,参数是一一对应的,就不需要我们花太多时间逆向。但是这里有一点很关键的点是,当 RtlDecompressBufferEx2
函数执行成功后,会将 v14
指向的地址赋值为 v15
,而v14
和v15
分别为 final_original_size
、smb_ocp_size
。
1 | err = RtlCompressBuffer(COMPRESSION_FORMAT_XPRESS, buffer, buffer_size, |
这里看一下EXP
中原始数据是什么:
1 | memset(buffer, 'A', 0x1108); |
原始数据为 a*0x1108+(ktoken+0x40)
,总大小为 0x1110
, 那么当我们解压缩数据后,存储在 alloc_buf
中的位置如下:
原始数据将会拷贝到 buffer+0x60
位置处(因为 offset+(return_buffer+0x18)=buffer+0x60),然后将会覆盖到 buffer+0x1170
位置处。此时 return_buffer+0x18
位置处将会被覆盖为 token_addr+0x40
的地址。那么就显而易见,如果我们还能再次调用修改解压缩数据的函数,那么就能够去修改 token_addr+0x40
的数据。这里就必须得提到我们上面解压缩数据时,使用的 memmov
函数 其刚好就是将 smb_ct_header
的值拷贝到了 alloc_buffer+0x18
指向的地址,修改的大小为 offset
0x10,也即通过这里我们就能够修改权限数据了。*但是,别忘记我们执行 memmov
有一个判断,即 final_original_size
和 smb_ocp_size
是否相当,按理说 当解压后 final_original_size
应该为 0x1110
,其和 smb_ocp_size
0xffffffff
肯定是不相等的。但是:上面解压缩时已经提到了,解压缩成功时其会将 final_original_size
赋值为 smb_ocp_size
。这里,就是整个漏洞触发最为精妙之处,绕过了重重限制。**
这里,需要先学习一下 token_addr
是什么,以及Windows
本地提权的方法。
先来看一下 token_addr
在 exp 里是怎么生成的。
1 | ktoken = get_process_token(); |
1 | ULONG64 get_process_token() { |
可以看到,这里是直接使用 OpenProcessToken
获取了当前程序的权限令牌。通过 自定义函数 get_handle_addr
来获取token
地址,方法是通过搜寻内核对象,由j00ru提出(tql,膜)。 我们只需要修改 token
地址的值,就能够修改进程的权限了。这里由于我们是本地提权,所以就直接使用了 OpenProcessToken
来获取令牌地址。如果是要RCE
,将还会有更复杂的变化。
那么我们修改token_addr
那个位置和修改为什么值,这里可以参考如下文章:
在Windows系统中,Token是管理权限的关键,若是能够通过”合法”的途径制作一块高权限的令牌,便也达到了提权的目的。
内核中TOKEN对象结构中有一个很重要的SEP_TOKEN_PRIVILEGES结构,其中的每一位都代表一种权限,如下:
在决定一个Token
所表示的权限时,SEP_TOKEN_PRIVILEGES
结构中的Enable
的值是真正起作用的。可见似乎可以通过改写TOKEN+0x48
处的值来改变权限。SEP_TOKEN_PRIVILEGES
结构中最重要的是SeDebugPrivilege
权限。只要具有该权限,就可以调试系统进程,也就具有注入代码到系统进程并远程执行的权限,等于有了管理员权限。所以一定得保证该标志位为1。
这里我们从token_addr+0x40
处开始覆盖,覆盖了 0x10
个字节,也就是 刚好将 TOken->Enable
覆盖为了 0xff
,这里就直接使我们当前进程拥有了从 1到35的所有权限。那么我们后续就可以进行shellode
注入,来实现恶意功能。
这种方法应该是最基础的windows提权方法,其实挺鸡肋的。因为如果我们能够直接使用 OpenPorcessToken 获取token_addr,那么当然就能够使用 AdjustTokenPrivileges来修改Token。而且由于
token
位于内核地址中,所以修改token
时需要一个本身就具有系统权限执行的程序来修改。但是这样做最简单的好处就是绕过smep
。
当我们将当前程序成功提权后,就是将shellode
注入到其他进程来执行恶意指令了:
1 | void inject(void) { |
这段代码的思路很好分析,先是通过遍历当前系统所有系统,找到 winlogon
服务的进程号,随后打开该进程获得进程句柄,使用 VirtualAllocEx
函数在 winlogon
中创建 0x1000
的可读可写可执行buffer
。调用 WriteProcessMemory
将 shellcode
写入 该地址段中。最后调用 CreateRemoteTgread
来执行 我们的shellcode
。(该方法由 Bryan Alexander(dronesec)提出)。
这样整个poc
就全部执行完毕,执行结果是我们以 系统权限弹出了 cmd
命令行。
调试分析
首先配置双机调试环境,随后在windbg
里使用 bm srv2!Srv2DecompressData
来对 srv2.sys
下载函数断点。然后在虚拟机里运行poc.exe
。在windbg
里就会在 srv2!Srv2DecompressData
断下。
我们首先步入到如下指令,这里就是取我们的 smb_compression_header
数据的地址,我们可以看到此时 rax
指向的地址的值就是我们输入的 smb_compression_header
数据,其中第4-8字节处为 0xffffffff
为 original size
,第12个字节处为 offset
为 0x10
。offset
之后跟着的是 compression data
。
随后跟进到 SrvNetAllocateBuffer
函数,查看参数分别为 0xf
和0x0
。
我们查看分配的堆结构:
我们返回的地址是alloc_buf
,其偏移 0x18
处存储了用户数据区的地址。
当执行完解压缩函数后,当前内存结构里的那个用户数据区指针已经被我们改为了 ktoken+0x40
的地址。
这说明,我们已经能够构造 任意地址改写漏洞。接下里,分析一下在 windbg
里获得 ktoken
结构体。
首先输入 !process 0 0
查看当前系统的所有进程:
找到漏洞程序 cve-2020-0796-local.exe
所在的进程地址,输入 dt _EPROCESS addr
来查看该进程的数据结构,在 0x360
处我们能找到 !token
结构。
token
字段是以_EX_FAST_REF
来声明的而不是期望的_TOKEN
结构。_EX_FAST_REF
结构是一种技巧,它依赖于一种假定,在16字节的边界上需要将内核数据结构对齐到内存中。这意味着一个指向token或其他任何内核对象的指针最低的4个位永远都是0(十六进制就是最后一个数永远为0)。Windows因此可以自由的使用该指针的低4位用于其他目的。从_EX_FAST_REF
中获取实际的指针只需要简单的修改最后的一位十六进制数位0即可。通过程序实现的话,就将最低4位的值与0值按位与,可以通过dt _TOKEN
或更好的!token
扩展命令来显示一个token:
自此,我们可以发现 token_addr
的地址为 0xffffdd0e405167f0
,加上 0x40
就是 0xffffdd0e40516830
,我们使用 SEP_TOKEN_PRIVILEGES
结构查看该值如下,此时 enabled
的值为 0x8
,权限较低:
我们查看最终修改后的权限结构,已经被我们的 smb_ct_header+0x10
处的数据覆盖
自此,提权完成。如果要后续分析shellcode
注入,在内核调试下需要陷入到用户态程序调试比较麻烦,这里就不做分析。
最终结果。弹出了系统权限的 cmd
命令行:
抓包分析
远程RCE
漏洞触发其实都差不都,只是将我们之前的
这里RCE
脚本里面有几个很关键的技术:
如何在远程泄露地址,并通过
tcp
回传给我们;漏洞触发后,我们如何持续性的控制程序流,执行后续的各种步骤?
如何远程提权?
我们将
kernel shellcode
和usermode shellcode
放到哪个内存空间?如何控制 ip 跳转到 shellcode去执行;
如何绕过 Windows CFG检查。
这里可以参考作者 ricercasecurity 的解释文章:https://ricercasecurity.blogspot.com/2020/04/ill-ask-your-body-smbghost-pre-auth-rce.html,以及一篇[译文](https://www.anquanke.com/post/id/203683)。
我也学习了很久,但是囿于对于其中很多概念和方法得不了解,不能够深入细致的理解其中很多方法,希望后面能够足部学习到其中的各种知识点,再来看这一个十分精巧的EXP吧。
参考
Windows SMBv3 CVE-2020-0796 漏洞分析和漏洞复现
Windows SMB Ghost(CVE-2020-0796)漏洞分析
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/01/12/CVE-2020-0796调试分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!