DNS-over-HTTPS (DoH) 学习笔记


DNS-over-HTTPS (DoH)

Why DoH?

传统的 DNS 查询是明文传输的(UDP: 53),且彼时未虑及现代安全性的需要,未利用密码学等手段进行加密或验证。这意味着恶意用户可以拦截、窃取、篡改DNS查询,甚至精准跟踪用户活动。虽然后来的DNSSEC方案通过电子签名进行验证,强化了DNS的安全性,并能够抵御DNS投毒污染等篡改通信的手段,但其对于中间网络设备进行的监听仍然没有抵御能力(随后,监听者可以通过获取的通信数据知晓用户访问了哪一域名,而域名往往与具体的网站相关系)。此外,DNSSEC的起效要求现有的大量DNS解析服务的提供商(常为互联网服务提供商或第三方大型互联网机构)对已有的DNS服务器进行大范围修改等问题,其推进进程并不理想。而对于DNS over HTTPS,在正确部署服务端并妥善配置客户端的前提下,互联网服务提供商或其它中间网络设备无法解密(亦即无法获知请求的实际内容)或者篡改已经加密的HTTPS通信,故其能够有效保护互联网用户的安全及隐私;另一方面,其基于已经成熟并已广泛部署的HTTPS协议,客户端进行利用较为方便。

此外,一些网络服务提供商和公共 Wi-Fi 热点经常会操纵 DNS 查询,将用户重定向到他们自己的服务器上,这种行为可能导致安全问题。使用 DoH 可以防止这种操纵,并确保您正在连接到预期的网站。

DoH 提供了更高的安全性和隐私保护,更加可靠的 DNS 连接和更好的互联网体验。

DoH 的请求形式

DoH 请求形式是使用 HTTPS 协议进行的。在传统 DNS 查询中,DNS 请求使用 UDP 或 TCP 进行明文传输,而在使用 DoH 时,DNS 请求通过 HTTPS 请求进行加密传输。

具体来说,当计算机或设备需要解析域名时,它会向配置的 DoH 服务器发送一个 HTTPS POST 请求,请求内容包含 DNS 查询信息。DoH 服务器将查询结果作为 HTTPS 响应返回给计算机或设备,然后将其解析为 IP 地址,并允许该设备连接到所需的网站。

DoH 的请求和响应消息格式基于 RFC 84841 中定义的 DNS over HTTPS 规范。该规范定义了 DNS 查询和响应消息的格式,以及如何在 HTTPS 中使用它们。通常,DoH 服务器依靠 HTTP/2 多路复用技术并发处理多个查询,从而提高性能和效率。

Also see:

DNS Wire Format: https://www.rfc-editor.org/rfc/rfc1035

但为方便开发者使用,许多 DNS 服务(GoogleDNS, AlibabaDNS)支持使用 JSON API (GET) 的方法调用。

以 Google DNS 为例

HTTP 方法

  • GET

    使用 GET 方法可以缩短延迟时间,因为它可以更高效地缓存。 RFC 8484 GET 请求必须具有包含 Base64Url 编码的 DNS 消息的 ?dns= 查询参数。GET 方法是 JSON API 支持的唯一方法。

  • POST

    POST 方法仅受 RFC 8484 API 支持,并且使用二进制 DNS 消息,并在请求正文和 DoH HTTP 响应中显示 Content-Type application/dns-message

  • HEAD

    HEAD 目前不受支持,并返回 400 Bad Request 错误

其他方法会返回 501 Not Implemented 错误。

Python DoH 请求 (OpenDNS)

import dns.message
import requests
import base64
import json


def doh_req(doh_url, domain, rr) -> list:
    result = []
    message = dns.message.make_query(domain, rr)
    dns_req = base64.b64encode(message.to_wire()).decode("UTF8").rstrip("=")
    r = requests.get(doh_url + "?dns=" + dns_req,
                     headers={"Content-type": "application/dns-message"})
    for answer in dns.message.from_wire(r.content).answer:
        dns_response = answer.to_text().split()
        result.append(
            {"Query": dns_response[0], "TTL": dns_response[1], "RR": dns_response[3], "Answer": dns_response[4]})
    return result


if __name__ == '__main__':
    doh_url_test = "https://doh.opendns.com/dns-query"
    domain_test = "example.com"
    rr_test = "A"
    print(json.dumps(doh_req(doh_url_test, domain_test, rr_test)))

在 DoH 的世界里,最初的DoH实现并没有使用JSON API,而是使用HTTP/2作为通信的底层协议。一个原因可能是HTTP/2是一种更成熟和广泛使用的协议,非常适合高性能应用程序,例如DoH。另一个原因可能是在最初实现DoH时,JSON API尚未被广泛采用,并且可能不被认为是协议的适当选择。更何况在那个年代,高性能的 JSON 库还是个问题呢。

此外,值得注意的是,JSON API并不是协议或标准本身,而是使用JSON构建API的规范。虽然JSON是一种轻量级的数据格式,在Web开发中广泛使用,但根据性能、复杂性和与现有系统的互操作性等因素,它可能并不总是所有应用程序和用例的最佳解决方案。

好了,我们把目光转回 DoH 的 DNS 请求。从代码上看,它发送了一个简单的GET请求,其中dns为要解析的域名和所需的记录类型。当然了,这里我们还需要将Accept头指定为application/dns-json

它们最终会被编码为 DNS Wire Format 格式(外加一次 Base64):

DNS Wire Format: https://datatracker.ietf.org/doc/html/rfc1035

echo -n 'q80BAAABAAAAAAAAA3d3dwZnb29nbGUDY29tAAABAAE=' | base64 -d | \
curl -s -H 'content-type: application/dns-message' \
    --data-binary @- https://cloudflare-dns.com/dns-query | \
    hexdump -C

# Server Response:
00000000  ab cd 81 80 00 01 00 01  00 00 00 01 03 77 77 77  |.............www|
00000010  06 67 6f 6f 67 6c 65 03  63 6f 6d 00 00 01 00 01  |.google.com.....|
00000020  c0 0c 00 01 00 01 00 00  00 ab 00 04 d8 3a cd 64  |.............:.d|
00000030  00 00 29 05 ac 00 00 00  00 00 00                 |..)........|
0000003b

请求

example.com 为例:

b'\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01' 是一个 DNS 查询的二进制数据包,它代表了一个向 DNS 服务器查询 "example.com" 域名的请求。

具体来说,这个二进制数据包中包含了以下信息:

  • 0x0000: 标识着这是一个 DNS 查询报文。
  • 0x0100: 指定使用标准的递归查询方式。
  • 0x0001: 表示本次查询中包含一个问题(即要查询的域名)。
  • 0x0000: 在 DNS 报文中通常用于回答区域、授权区域和附加区域,但在查询报文中应为 0 。
  • 0x0000: 同上。
  • 0x0000: 同上。
  • 0x07example: 要查询的域名,其中 0x07 表示后面跟着的字符串有 7 个字符。
  • 0x03com: 要查询的域名的顶级域名,其中 0x03 表示后面跟着的字符串有 3 个字符。
  • 0x00: 表示域名查询结束,这个字节必须存在。
  • 0x0001: 要查询的资源记录类型为 A 类型,即查询目标主机的 IPv4 地址。
  • 0x0001: 要查询的资源记录类为 IN 类,即查询 Internet 上的地址。

总之,这个二进制数据包就是一个 DNS 查询请求,用于查询 "example.com" 这个域名的 IPv4 地址。

echo -ne '\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00'\
    '\x03www\x06google\x03com\x00'\
    '\x00\x01\x00\x01' | \
    hexdump -C
# Client Request:
00000000  ab cd 01 00 00 01 00 00  00 00 00 00 03 77 77 77  |.............www|
00000010  06 67 6f 6f 67 6c 65 03  63 6f 6d 00 00 01 00 01  |.google.com.....|
00000020

\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00它是用来声明一些有用信息的标题,比如IDflag:“我是一个查询”等等...

DNS Payload 逐 bit 的含义可以在 RFC 1035 中找到,Wireshark 也有高亮解释。

响应

Cloudflare 的回应显示www.google.comA记录的长度为4个字节的0x04,其IP为0xd83acd44

DNS64

现在,同时具有 IPv6 和 IPv4 连接的双栈网络很常见,但它们远远没有实现通用。为过渡到 IPv6 并部署仅使用 IPv6 的网络,网络运营商仍必须保留对仅使用 IPv4 的网络和服务的访问权限。有多种转换机制可以提供 IPv6 对 IPv4 的访问权限;NAT64 是许多网络运营商越来越受欢迎的选择。使用具有 IPv4-IPv6 翻译功能的 NAT64 网关,仅使用 IPv6 的客户端可以通过合成 IPv6 地址连接到仅使用 IPv4 的服务,这些地址的前缀是将其路由到 NAT64 网关的前缀。

DNS64 是一种 DNS 服务,可针对仅使用 IPv4 的目的地返回使用这些合成 IPv6 地址的 AAAA 记录(DNS 中包含 A 而非 AAAA 记录)。这样,纯 IPv6 客户端就可以使用 NAT64 网关,而无需进行任何其他配置。Google 公共 DNS64 使用预留的 NAT64 前缀 64:ff9b::/96 以全局服务的形式提供 DNS64。

EDNS 客户端子网 (ECS)

OpenDNS ClientSubnetOption: https://github.com/opendns/dnspython-clientsubnetoption

https://support.opendns.com/hc/en-us/articles/227987667-Installing-and-Using-dnspython-clientsubnetoption

Reference

  • https://developers.google.com/speed/public-dns/docs/doh?hl=zh-cn
  • https://github.com/opendns/doh-client
  • https://xz.aliyun.com/t/4161

文章作者: sfc9982
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 sfc9982 !
  目录