【DNS 解析】DNS 解析过程分析

域名系统(DNS)

引言

什么是域名?域名系统又是什么?

让我们先来看看百度百科对域名域名系统给出的解释的一部分内容:

网域名称(英语:Domain Name,简称:Domain),简称域名、网域,是由一串用点分隔的字符组成的互联网上某一台计算机或计算机组的名称,用于在数据传输时标识计算机的电子方位。
由于IP地址具有不方便记忆并且不能显示地址组织的名称和性质等缺点,人们设计出了域名,并通过网域名称系统(DNS,Domain Name System)来将域名和IP地址相互映射,使人更方便地访问互联网,而不用去记住能够被机器直接读取的IP地址数串。域名系统(英文:Domain Name System,缩写:DNS)是互联网的一项服务。它作为将域名和IP地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。DNS使用UDP端口53。当前,对于每一级域名长度的限制是63个字符,域名总长度则不能超过253个字符。

可以这么理解:域名可以方便大家记忆,DNS 目的是为了实现域名和主机地址之间的转换而存在的系统。

在部分情况下,域名不仅仅起到便于记忆的作用,服务端还会对其进行区分标记,如 a.example.com 和 b.example.com 可能是同一个 IP 的不同页面,由 Web 服务器进行展示,这时候直接访问 IP 地址就可能出现与期望不符的现象,所以我认为不能简单地说域名是代替 IP 地址进行访问。

DNS 解析的过程

在考虑如何完成这部分内容之前我参考了腾讯云社区中其他一些文章,也发现了一些问题(也可能是我的理解有问题),其中包括了腾讯认证的 IMWeb 前端团队这方面的文章,该团队的文章《DNS 解析》对 DNS 解析的整个过程进行了阐述,在这里我就简单通过对这篇文章的分析来讲述解析这一过程吧。

文章截图

简单概括一下改文章中域名解析过程:

  1. 查找浏览器缓存
  2. 查找系统缓存
  3. 查找路由器缓存
  4. 查找ISP DNS 缓存
  5. 递归搜索

首先浏览器缓存是没有太大问题的,各家浏览器都有自己的缓存机制。

第二是系统缓存,确实系统会根据 TTL 缓存域名解析的结果,但文中所表述的 hosts 文件作为缓存是不正确的,在 Windows 操作系统下是由 DnsClient 服务负责实现的,在 Linux 系统下由 NSCD 类服务实现的。

第三路由器缓存,我本人对此表示疑惑(对不起,也许是我的路由器太弱了),虽然从道理上来说路由器确实可以劫持 DNS 请求进行解析缓存和返回,但是否真如此还得进一步讨论。

最后的查找 ISP DNS 缓存和递归搜索那就更值得怀疑了,首先我设置的 DNS 解析地址不一定是 ISP 所提供的地址,我大可选择一些公共 DNS ,谷歌、微软、腾讯等公司都对外提供公共 DNS 解析,并且并不是所有的 DNS 服务器都采用递归方式进行请求,迭代请求方式也是常见的。

这篇文章暂且分析到这里,在此对该团队该文章的严谨性提出质疑。

这里不得不再提的是 DNS 的迭代查询方式和递归查询方式,实际情景可能会较为复杂,甚至出现不同方式混合的情况,这里简单的进行如下不严谨的表述:

迭代解析方式中客户端依次访问不同级别的域名解析服务器进行查询

递归请求中由服务器完成解析直接返回给客户端(客户端仅发送一起解析请求即可完成解析)

主机记录和 TTL

在腾讯云 DNS 解析的常见问题文档中有了详细的陈述,这里直接引用:

要指向主机服务商提供的 IP 地址,选择类型 A;要指向一个域名,选择类型 CNAME。
A 记录:地址记录,用来指定域名的 IPv4 地址(例如 8.8.8.8),如果需要将域名指向一个 IP 地址(外网地址),就需要添加 A 记录。 CNAME 记录: 如果需要将域名指向另一个域名,再由另一个域名提供 IP 地址,就需要添加 CNAME 记录。
NS 记录:域名服务器记录,如果需要把子域名交给其他 DNS 服务商解析,就需要添加 NS 记录。
AAAA 记录:用来指定主机名(或域名)对应的 IPv6 地址(例如 ff06:0:0:0:0:0:0:c3)记录。
MX 记录:如果需要设置邮箱,让邮箱能收到邮件,就需要添加 MX 记录。
TXT 记录:如果希望对域名进行标识和说明,可以使用 TXT 记录,绝大多数的 TXT 记录是用来做 SPF 记录(反垃圾邮件)。
SRV 记录:SRV 记录用来标识某台服务器使用了某个服务,常见于微软系统的目录管理。主机记录处格式为:服务的名字.协议的类型。例如 _sip._tcp 。
隐、显性 URL 记录:将一个域名指向另外一个已经存在的站点,就需要添加 URL 记录。

其他和具体的解释参见下一部分内容

关于 TTL 的部分:

TTL 即 Time To Live,缓存的生存时间。指地方 DNS 缓存您域名记录信息的时间,缓存失效后会再次到 DNSPod 获取记录值。我们默认的600秒是最常用的。

DNS 请求

那么 DNS 请求是怎样的?

主要参考是 RFC 1034 Domain names - concepts and facilitiesRFC 1035 Domain names - implementation and specification, RFC 1035 有更多的细节,1034 更为简洁。

该部分仅针对上述两个规范进行讨论,其他的 DNS 拓展(如 RFC 6891、1183、1706、2536、2539 )规范不在该部分的分析范围内,后续可以进一步了解,对于在实例中出现的内容简单带过不作深入探讨。

约定

  • 数据传输以 8 位构成的字节进行分割,每个单元内左侧为高位,如 1 0 1 0 1 0 1 0 表示十进制数 170
  • 不区分大小写,但奇偶校验必须完全匹配

TPYE(类型)

类型

含义

A

1

主机地址

NS

2

NS 服务器

MD

3

邮件目的地(已废弃 - 使用 MX)

MF

4

邮件转发器(已废弃 - 使用 MX)

CNAME

5

别名

SOA

6

鉴权区域开始标志

MB

7

邮箱域名(实验)

MG

8

邮件组成员(实验)

MR

9

邮件重命名域名(实验)

NULL

10

空 RR(实验)

WKS

11

服务描述

PTR

12

域名指针

HINFO

13

主机信息

MINFO

14

邮箱或邮件列表信息

MX

15

邮件交换

基本格式

基本格式

包含:请求头(Header),请求(Question),回复(Answer),认证(Authority),附加(Additional)几个部分,在传输过程中,请求头必须存在,请求头内部表明了是否包括其他几个部分内容。

请求头格式

请求头格式
  • ID 标识
  • QR 0:查询 1:响应
  • Opcode 0:标准查询 1:反向查询 2:状态请求 3-15:保留
  • AA: 授权回答
  • TC 报文截断
  • RD 是否进行递归请求
  • RA 是否支持递归
  • Z 保留
  • RCODE 应答码
  • QDCOUNT 请求部分中的条目数。
  • ANCOUNT 响应部分中的资源记录数。
  • NSCOUNT 认证部分中名称服务器资源记录的数量。
  • ARCOUNT 附加记录部分中的资源记录数量。

计数均为16位无符号整数

应答码:

应答

0

无错误

1

格式错误,服务器无法解释查询

2

服务器故障

3

名称错误,针对权威 NS 查询中引用的域名不存在

4

未实现,不支持的查询类型

5

拒绝查询

6-15

保留

请求查询数据格式

查询数据格式
  • QNAME 请求名
  • QTYPE 查询类型
  • QCKASS 查询类,如IN

请求名:按 . 将域名进行分隔,将每一片的长度作为分隔符记录在该片之前,以 0 结尾,无需填充

如: cloud.tencent.com 分为 cloud tencent com 三部分,长度分别为 5,7,3,最终请求名为: \\x05cloud\\x07tencent\\x03com

类:

类型

说明

IN

1

the Internet

CS

2

the CSNET class (已过时)

CH

3

the CHAOS class

HS

4

Hesiod

响应、认证、附加数据格式

响应格式
  • NAME 名称
  • TYPE 类型
  • CLASS
  • TTL 生存时间
  • RDLENGTH 附加数据长度(字节)
  • RDDATA 附加数据

数据压缩

附加字段(RDDATA)中对于重复的域名可以进行压缩,其格式为:

压缩指针

由于约定但记录不超过 63 字符,所以其可作为指针,纸箱请求开始后的第 x 个字节开始以 0 结尾的内容。

DNS 请求实例

脱离了实例只看结构很难了解,想要了解 DNS 请求的内容和过程还是需要一步一步进行尝试。

此处以 腾讯公共 DNS 119.29.29.29 作为 DNS 服务器,请求解析 im.qq.comweb.tdh6.top 两个域名为例,使用 Python 3.10 编程支持,利用 socket 库进行请求。

构造请求

首先明确所需要进行请求的基本格式,Header 和 Question 是必须的,Answer 无法预知也无需在查询请求中包含,Authority 和 Additional 也无需指定内容。

查询请求头

根据请求头包含的内容进行构建:

request_data = b""  # 初始化

request_data += b"\\0\\1"  # ID,请求标志,可以随意设置
request_data += b"\\1\\0"  # Flags,(00000001 00000000B,RD = 1,其他 = 0),查询,标准查询,不截断报文,请求递归

request_data += b"\\0\\1"  # Question 计数
request_data += b"\\0\\0"  # Answer 计数
request_data += b"\\0\\0"  # Authority 计数
request_data += b"\\0\\0"  # Additional 计数

除请求头外,仅包括 1 条 查询请求。

查询请求

查询请求包括三个部分:查询名称,查询类型,查询类,在此实例中查询类型为 1 A 记录,查询类为1 the Internet 。

对需要查询的域名需要提前处理:

fdm = "im.qq.com".split(".")  # 以 . 分隔域名
dm = b""
for i in fdm: # 构造请求名
    dm += bytes([len(i)])
    dm += i.encode()
dm += b"\\0"  # 以 0 结尾

处理后的域名为:

b'\\x02im\\x02qq\\x03com\\x00'

构造查询请求:

request_data += dm  # 域名
request_data += b"\\0\\1"  # A记录
request_data += b"\\0\\1"  # 查询类,1,IN,the Internet

至此,请求构造完毕,完整请求内容:

b'\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x02im\\x02qq\\x03com\\x00\\x00\\x01\\x00\\x01'

对请求进行 Base 64 URL 编码结果为

b'AAEBAAABAAAAAAAAAmltAnFxA2NvbQAAAQAB'

注:虽然此处没有 Base 64 编码后的请求之后会使用到

发送请求

通过 Socket 库建立 UDP 连接,发送请求内容并获取返回

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)  # udp
s.connect(("119.29.29.29", 53))
s.send(request_data)
raw_res = s.recv(1024)
res = raw_res

接收到的请求为:

b'\\x00\\x01\\x81\\x80\\x00\\x01\\x00\\x01\\x00\\x00\\x00\\x00\\x02im\\x02qq\\x03com\\x00\\x00\\x01\\x00\\x01\\xc0\\x0c\\x00\\x01\\x00\\x01\\x00\\x00\\x00<\\x00\\x04\\xa3\\xb1\\\\\\x12'

解析请求

对获取到的数据进行解析处理,即可获得所需内容,虽可直接使用 DnsPython 库,但此处以简单的方式进行分析。

响应请求头

响应头的长度是固定(12 字节)的,对响应头进行提取并读取内容

head = raw_res[:12]

# \\x00\\x01\\x81\\x80\\x00\\x01\\x00\\x01\\x00\\x00\\x00\\x00

rid = head[:2]  # 00 01
Flags = head[2:4]  # 10000001 10000000 QR = RD = RA = 1,其他 = 0
QCount = head[4:6]  # 00 01
RCount = head[6:8] # 00 01
ACount = head[8:10] # 00 00
AddCount = head[10:12] # 00 00

Rcode 为 0,表明请求成功,无错误产生,相应内容包括查询部分和响应(Answer)部分。

查询(Question 部分)

去除响应头后剩余的请求内容起始为 Question 查询内容,该结构如前所述,NAME 是边长的内容,以 0 结尾,之后的类型和类是固定长度,所以以 0 位分隔可提取出 RNAME,进而提取完成的 Question 部分。

查询部分和发送的内容完全一致,不再进行展示,提取后去除查询部分内容

res = res[res.index(b'\\00') + 4:]

响应(Answer 部分)

根据响应结构对内容进行提取

    res_name = res[:res.index(b'\\00')]   # 提取RNAME
    res = res[res.index(b'\\00'):]
    while res.startswith(b"\\x00\\x00"):  # 虽然无需补 0,但少数 DNS 服务器还是会进行补齐,这里进行处理
        res = res[1:]

    # \\x00\\x01\\x00\\x01\\x00\\x00\\x00<\\x00\\x04\\xa3\\xb1\\\\\\x12
    res_type = res[:2]  # 类型 00 01,A 记录
    res_class = res[2:4]  # 类 1 IN, the Internet
    res_ttl = res[4:8]  # TTL,60
    res_rdlen = res[8:10]  # 附加字段长度 4
    res_rdlen = (res_rdlen[0] * 16 * 16 + res_rdlen[1])  # 简单处理提取长度数值
    res_rddata = res[10:10 + res_rdlen]  # 附加字段 \\xa3\\xb1\\\\\\x12

对于 A 记录,其附加字段为解析的 IP,按位转为整型并使用 . 连接即可获得解析后的结果

        for j in res_rddata:
            ans.append(str(j))
        print(".".join(ans), end="\\t(A)\\n")
163.177.92.18	(A)

处理完成后剩余请求长度为 0。

改变请求解析 web.tdh6.top 这一没有 A 记录但有 CNAME 记录的域名,进行对比:

b'\\x00\\x01\\x81\\x80\\x00\\x01\\x00\\x04\\x00\\x00\\x00\\x00\\x03web\\x04tdh6\\x03top\\x00\\x00\\x01\\x00\\x01\\xc0\\x0c\\x00\\x05\\x00\\x01\\x00\\x00\\x02X\\x00\\x1c\\x03web\\x04tdh6\\x03top\\x03cdn\\x05dnsv1\\x03com\\x00\\xc0*\\x00\\x05\\x00\\x01\\x00\\x00\\x02X\\x00\\x1f\\x084kzi99cn\\x06slt-dk\\x05sched\\x06tdnsv8\\xc0A\\xc0R\\x00\\x01\\x00\\x01\\x00\\x00\\x00<\\x00\\x04v\\xb4<\\x14\\xc0R\\x00\\x01\\x00\\x01\\x00\\x00\\x00<\\x00\\x04\\xb7Bg\\x92'

RCOUNT 值为 4,表明有 4 条相应荷载,根据前述内容去除响应头和 Question 部分后进行分析:

由于附加字段中出现了 0xc0 开头的内容,表明使用了压缩方法,进行压缩,需要找到指针指向的内容,同时考虑到极端情况可能出现多层指针,故采用嵌套函数进行分析:

def decom(datas, raw_res) -> bytes:
    s = b""
    datas = datas.split(b"\\00")[0]
    while len(datas) > 0:
        tlen = datas[0]
        if tlen & 0xc0 == 0xc0:
            offset = (datas[0] * 16 * 16 + datas[1]) & 0x3fff # 读取偏移值
            d = decom(raw_res[offset:], raw_res)  # 嵌套检索
            s += d
            datas = datas[2:]
            pass
        else:
            s += datas[1:1 + datas[0]] + b"."
            datas = datas[1 + datas[0]:]
    if s.endswith(b"."):
        s = s[:-1]
    return s

响应为:

web.tdh6.top.cdn.dnsv1.com	(CNAME)
4kzi99cn.slt-dk.sched.tdnsv8.com	(CNAME)
118.183.8.76	(A)
183.66.103.146	(A)

有此处两个 IP 对应了同一个域名,分析响应的 RNAME 部分不难发现

DNS Queries over HTTPS (DoH)

安全 DNS

通过上述内容不难发现,传统的 DNS 查询和响应通过 UDP 或 TCP 发送,没有加密,因此存在被监视、篡改的风险,HTTPS 作为利用 TLS 的常用传输协议,利用其传输 DNS 请求是 DNS Queries over HTTPS,目前火绒、谷歌等浏览器已经支持该项功能,先对其进行简单的分析和实例展示。

谷歌浏览器设置安全 DNS

进行 DoH 请求

在前述内容中对请求进行了 Base 64 编码,通过该编码可进行 DOH 请求。

腾讯云公共解析已经停止了对 DoH 的支持,但可以利用公共解析功能找到 DoH 地址,或采用公共 DNS

以 IBM 公共 DNS 9.9.9.9 为例,通过请求 https://9.9.9.9/dns-query?dns={Base 64 编码后的 DNS 请求}即可进行 DOH 请求,例如上述例子的解析为:

https://9.9.9.9/dns-query?dns=AAEBAAABAAAAAAAAAmltAnFxA2NvbQAAAQAB

返回的内容以二进制的形式返回,内容为 DNS 解析的结果,通过相同的方式可对其进行分析,快速实现:

import requests
import base64
import socket  # 导入 socket 模块
import dns.message

dd = b""
for i in "im.qq.com".split("."):
    dd += bytes([len(i)])
    dd += i.encode()
dd += b"\\0"
request_data = b""
# 请求头
request_data += b"\\0\\1"
request_data += b"\\1\\0"
request_data += b"\\0\\1"  # Question 计数
request_data += b"\\0\\0"  # Answer 计数
request_data += b"\\0\\0"
request_data += b"\\0\\0"
# Question
request_data += dd  # 域名
request_data += b"\\0\\1"  # A记录
request_data += b"\\0\\1"  # IN


r = requests.get("https://9.9.9.9/dns-query", params={"dns": base64.urlsafe_b64encode(request_data).replace(b"=", b"")})
raw_res = r.content
d = dns.message.from_wire(raw_res)
print(d.answer[0])

执行结果:

im.qq.com. 600 IN A 203.205.254.62

补充的一点点 DoH 分析

DNS Queries over HTTPS,通过 HTTPS 来进行 DNS 传输,在编写此文时,推荐的规范为 RFC8484 DNS Queries over HTTPS (DoH),其前生目前共计 14 个版本,当前版本与 2018 年 10 月被提出,2019 年 1 月最后更改。

DNS Over HTTPS 是利用有 TLS 保护的 HTTP 传输进行标准 DNS 解析的过程,规范中指出,传输需使用 HTTP/2方式进行传输,其传输可利用 HTTP 提供的压缩、认证、缓存、重定向、代理等特性,每一次 DNS 请求对应一次独立的 HTTP 请求。在解析过程中,标准 DNS 请求进行 Base 64 url 编码(RFC4648)通过 GET 或 POST 方式向 DoH 服务器进行请求(DoH 服务器必须同时支持 GET 和 POST 方法),使用 POST 请求时,还需要在请求头中明确 Content-Type,DoH 请求的 Mime 为 application/dns-message

DNS over TLS

DNS over TLS 要早于 DoH 的使用,目前推荐的规范为 RFC 7858RFC 8310,DoT 的提出面向的是 DNS 请求的传输层安全性,规范中规定了默认情况下 DoT 使用端口为 853 的TCP 连接进行数据传输,其不允许数据明文传输,需使用 TLS 加密通信。服务器和客户端之间不应每次请求后立即断开连接,应将其保留一段时间以供下一次查询使用,只要资源足够,应保持 TCP 长时间连接,并要求遵循 RFC7766 DNS Transport over TCP - Implementation Requirements 中所提出的最佳实践以避免资源枯竭和拒绝服务攻击。

在通过 TCP 方式进行 DNS 请求过程中,需要将请求体长度放置于数据的开始,Dns over TLS 也有同样的要求。

DoT 实例

import ssl
import socket
import dns.message

domname = "im.qq.com"
server = "9.9.9.9"

# 构造请求
request_data = dns.message.make_query(domname, "A").to_wire()

# 计算长度
msg_len = len(request_data)
msg_head = bytes([msg_len])
if len(msg_head) == 1:
    msg_head = b"\\0" + msg_head
request_data = msg_head + request_data

# 建立 SSL 连接
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.verify_mode = ssl.CERT_REQUIRED
context.check_hostname = False
context.load_default_certs()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
ss = context.wrap_socket(s)
ss.connect((server, 853))

# 发送请求
ss.send(request_data)
raw_res = ss.recv(1024)[2:]  # 此处暂时忽略返回数据的长度

# 解析数据
d = dns.message.from_wire(raw_res)
print(d.answer[0])

# 关闭请求
ss.close()

利用 DNS 实现内容屏蔽

通过控制 DNS 解析结果,可以很好地,定向阻断某些域名,为网络环境提供保障。

使用腾讯云 公共解析 服务可以轻松实现利用 DNS 对内容的屏蔽,同时其支持 DoH,为网络安全提供进一步保障,其内置多种屏蔽模板可以轻松使用。

公共解析屏蔽模板

其提供的黑名单、白名单功能可以进行个性化配置。同时提供解析记录、设备区分功能,能满足大多数情况下的解析需求。

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
【DNS 解析】DNS 解析过程分析
可以这么理解:域名可以方便大家记忆,DNS 目的是为了实现域名和主机地址之间的转换而存在的系统。
<<上一篇
下一篇>>