从零开始,运用 Ruby 语言创建一个 DNS 查询

2023年 10月 24日 90.2k 0

大家好!前段时间我写了一篇关于“如何用 Go 语言建立一个简易的 DNS 解析器”的帖子。

那篇帖子里我没写有关“如何生成以及解析 DNS 查询请求”的内容,因为我觉得这很无聊,不过一些伙计指出他们不知道如何解析和生成 DNS 查询请求,并且对此很感兴趣。

我开始好奇了——解析 DNS 能

所以,在这里有一个如何生成 DNS 查询请求,以及如何解析 DNS 响应报文的速成教学!我们会用 Ruby 语言完成这项任务,主要是因为不久以后我将在一场 Ruby 语言大会上发表观点,而这篇博客帖的部分内容是为了那场演讲做准备的。😃

(我尽量让不懂 Ruby 的人也能读懂,我只使用了非常基础的 Ruby 语言代码。)

最后,我们就能制作一个非常简易的 Ruby 版本的 dig 工具,能够查找域名,就像这样:

$ ruby dig.rb example.com
example.com    20314    A    93.184.216.34

整个程序大概 120 行左右,所以 并不 算多。(如果你想略过讲解,单纯想去读代码的话,最终程序在这里:dig.rb。)

我们不会去实现之前帖中所说的“一个 DNS 解析器是如何运作的?”,因为我们已经做过了。

那么我们开始吧!

如果你想从头开始弄明白 DNS 查询是如何格式化的,我将尝试解释如何自己弄明白其中的一些东西。大多数情况下的答案是“用 Wireshark 去解包”和“阅读 RFC 1035,即 DNS 的规范”。

生成 DNS 查询请求

步骤一:打开一个 UDP 套接字

我们需要实际发送我们的 DNS 查询,因此我们就需要打开一个 UDP 套接字。我们会将我们的 DNS 查询发送至 8.8.8.8,即谷歌的服务器。

下面是用于建立与 8.8.8.8 的 UDP 连接,端口为 53(DNS 端口)的代码。

require 'socket'
sock = UDPSocket.new
sock.bind('0.0.0.0', 12345)
sock.connect('8.8.8.8', 53)

关于 UDP 的说明

关于 UDP,我不想说太多,但是我要说的是,计算机网络的基础单位是“数据包packet”(即一串字节),而在这个程序中,我们要做的是计算机网络中最简单的事情:发送 1 个数据包,并接收 1 个数据包作为响应。

所以 UDP 是一个传递数据包的最简单的方法。

它是发送 DNS 查询最常用的方法,不过你还可以用 TCP 或者 DNS-over-HTTPS。

步骤二:从 Wireshark 复制一个 DNS 查询

下一步:假设我们都不知道 DNS 是如何运作的,但我们还是想尽快发送一个能运行的 DNS 查询。获取 DNS 查询并确保 UDP 连接正常工作的最简单方法就是复制一个已经正常工作的 DNS 查询!

所以这就是我们接下来要做的,使用 Wireshark (一个绝赞的数据包分析工具)。

我的操作大致如下:

  • 打开 Wireshark,点击 “捕获capture” 按钮。
  • 在搜索栏输入 udp.port == 53 作为筛选条件,然后按下回车。
  • 在我的终端运行 ping example.com(用来生成一个 DNS 查询)。
  • 点击 DNS 查询(显示 “Standard query A example.com”)。 (“A”:查询类型;“example.com”:域名;“Standard query”:查询类型描述)
  • 右键点击位于左下角面板上的 “域名系统(查询)Domain Name System (query)”。
  • 点击 “复制Copy” ——> “作为十六进制流as a hex stream”。
  • 现在 b96201000001000000000000076578616d706c6503636f6d0000010001 就放到了我的剪贴板上,之后会用在我的 Ruby 程序里。好欸!
  • 步骤三:解析 16 进制数据流并发送 DNS 查询

    现在我们能够发送我们的 DNS 查询到 8.8.8.8 了!就像这样,我们只需要再加 5 行代码:

    hex_string = "b96201000001000000000000076578616d706c6503636f6d0000010001"
    bytes = [hex_string].pack('H*')
    sock.send(bytes, 0)
    # get the reply
    reply, _ = sock.recvfrom(1024)
    puts reply.unpack('H*')

    [hex_string].pack('H*') 意思就是将我们的 16 位字符串转译成一个字节串。此时我们不知道这组数据到底是什么意思,但是很快我们就会知道了。

    我们还可以借此机会运用 tcpdump ,确认程序是否正常进行以及发送有效数据。我是这么做的:

  • 在一个终端选项卡下执行 sudo tcpdump -ni any port 53 and host 8.8.8.8 命令
  • 在另一个不同的终端指标卡下,运行 这个程序(ruby dns-1.rb
  • 以下是输出结果:

    $ sudo tcpdump -ni any port 53 and host 8.8.8.8
    08:50:28.287440 IP 192.168.1.174.12345 > 8.8.8.8.53: 47458+ A? example.com. (29)
    08:50:28.312043 IP 8.8.8.8.53 > 192.168.1.174.12345: 47458 1/0/0 A 93.184.216.34 (45)

    非常棒 —— 我们可以看到 DNS 请求(”这个 example.com 的 IP 地址在哪里?“)以及响应(“在93.184.216.34”)。所以一切运行正常。现在只需要(你懂的)—— 搞清我们是如何生成并解析这组数据的。

    步骤四:学一点点 DNS 查询的格式

    现在我们有一个关于 example.com 的 DNS 查询,让我们了解它的含义。

    下方是我们的查询(16 位进制格式):

    b96201000001000000000000076578616d706c6503636f6d0000010001

    如果你在 Wireshark 上搜索,你就能看见这个查询它由两部分组成:

    • 请求头:b96201000001000000000000
    • 语句本身:076578616d706c6503636f6d0000010001

    步骤五:制作请求头

    我们这一步的目标就是制作字节串 b96201000001000000000000(借助一个 Ruby 函数,而不是把它硬编码出来)。

    (LCTT 译注:硬编码hardcode 指在软件实现上,将输出或输入的相关参数(例如:路径、输出的形式或格式)直接以常量的方式撰写在源代码中,而非在运行期间由外界指定的设置、资源、数据或格式做出适当回应。)

    那么:请求头是 12 个字节。那些个 12 字节到底意味着什么呢?如果你在 Wireshark 里看看(亦或者阅读 RFC-1035),你就能理解:它是由 6 个 2 字节大小的数字串联在一起组成的。

    这六个数字分别对应查询 ID、标志,以及数据包内的问题计数、回答资源记录数、权威名称服务器记录数、附加资源记录数。

    我们还不需要在意这些都是些什么东西 —— 我们只需要把这六个数字输进去就行。

    但所幸我们知道该输哪六位数,因为我们就是为了直观地生成字符串 b96201000001000000000000

    所以这里有一个制作请求头的函数(注意:这里没有 return,因为在 Ruby 语言里,如果处在函数最后一行是不需要写 return 语句的):

    def make_question_header(query_id)
      # id, flags, num questions, num answers, num auth, num additional
      [query_id, 0x0100, 0x0001, 0x0000, 0x0000, 0x0000].pack('nnnnnn')
    end

    上面内容非常的短,主要因为除了查询 ID ,其余所有内容都由我们硬编码写了出来。

    什么是 nnnnnn?

    可能能想知道 .pack('nnnnnn') 中的 nnnnnn 是个什么意思。那是一个向 .pack() 函数解释如何将那个 6 个数字组成的数据转换成一个字节串的一个格式字符串。

    .pack 的文档在 这里,其中描述了 n 的含义其实是“将其表示为” 16 位无符号、网络(大端序)字节序’”。

    (LCTT 译注:大端序Big-endian:指将高位字节存储在低地址,低位字节存储在高地址的方式。)

    16 个位等同于 2 字节,同时我们需要用网络字节序,因为这属于计算机网络范畴。我不会再去解释什么是字节序了(尽管我确实有 一幅自制漫画尝试去描述它)。

    测试请求头代码

    让我们快速检测一下我们的 make_question_header 函数运行情况。

    puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*")

    这里运行后输出 true 的话,我们就成功了。

    好了我们接着继续。

    步骤六:为域名进行编码

    下一步我们需要生成 问题本身(“example.com 的 IP 是什么?”)。这里有三个部分:

    • 域名(比如说 example.com
    • 查询类型(比如说 A 代表 “IPv4 Address”)
    • 查询类(总是一样的,1 代表 INternet)

    最麻烦的就是域名,让我们写个函数对付这个。

    example.com 以 16 进制被编码进一个 DNS 查询中,如 076578616d706c6503636f6d00。这有什么含义吗?

    如果我们把这些字节以 ASCII 值翻译出来,结果会是这样:

    076578616d706c6503636f6d00
     7 e x a m p l e 3 c o m 0

    因此,每个段(如 example)的前面都会显示它的长度(7)。

    下面是有关将 example.com 翻译成 7 e x a m p l e 3 c o m 0 的 Ruby 代码:

    def encode_domain_name(domain)
      domain
        .split(".")
        .map { |x| x.length.chr + x }
        .join + ""
    end

    除此之外,,要完成问题部分的生成,我们只需要在域名结尾追加上(查询)的类型和类。

    步骤七:编写 make_dns_query

    下面是制作一个 DNS 查询的最终函数:

    def make_dns_query(domain, type)
      query_id = rand(65535)
      header = make_question_header(query_id)
      question =  encode_domain_name(domain) + [type, 1].pack('nn')
      header + question
    end

    这是目前我们写的所有代码 dns-2.rb —— 目前仅 29 行。

    接下来是解析的阶段

    现在我尝试去解析一个 DNS 查询,我们到了硬核的部分:解析。同样的,我们会将其分成不同部分:

    • 解析一个 DNS 的请求头
    • 解析一个 DNS 的名称
    • 解析一个 DNS 的记录

    这几个部分中最难的(可能跟你想的不一样)就是:“解析一个 DNS 的名称”。

    步骤八:解析 DNS 的请求头

    让我们先从最简单的部分开始:DNS 的请求头。我们之前已经讲过关于它那六个数字是如何串联在一起的了。

    那么我们现在要做的就是:

    • 读其首部 12 个字节
    • 将其转换成一个由 6 个数字组成的数组
    • 为方便起见,将这些数字放入一个类中

    以下是具体进行工作的 Ruby 代码:

    class DNSHeader
      attr_reader :id, :flags, :num_questions, :num_answers, :num_auth, :num_additional
      def initialize(buf)
        hdr = buf.read(12)
        @id, @flags, @num_questions, @num_answers, @num_auth, @num_additional = hdr.unpack('nnnnnn')
      end
    end

    注: attr_reader 是 Ruby 的一种说法,意思是“使这些实例变量可以作为方法使用”。所以我们可以调用 header.flags 来查看@flags变量。

    我们也可以借助 DNSheader(buf) 调用这个,也不差。

    让我们往最难的那一步挪挪:解析一个域名。

    步骤九:解析一个域名

    首先,让我们写其中的一部分:

    def read_domain_name_wrong(buf)
    domain = []
    loop do
    len = buf.read(1).unpack('C')[0]
    break if len == 0
    domain

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论