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

赛博老王 的头像

·

·

·

1,944 次阅读

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

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

我开始好奇了——解析 DNS 能 花多大功夫?事实证明,编写一段 120 行精巧的 Ruby 语言代码组成的程序就可以做到,这并不是很困难。

所以,在这里有一个如何生成 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 查询请求

现在我尝试去解析一个 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 << buf.read(len)
  end
  domain.join('.')
end

这里会反复读取一个字节的数据,然后将该长度读入字符串,直到读取的长度为 0。

这里运行正常的话,我们在我们的 DNS 响应头第一次看见了域名(example.com)。

关于域名方面的麻烦:压缩!

但当 example.com 第二次出现的时候,我们遇到了麻烦 —— 在 Wireshark 中,它报告上显示输出的域的值为含糊不清的 2 个字节的 c00c。

这种情况就是所谓的 DNS 域名压缩,如果我们想解析任何 DNS 响应我们就要先把这个实现完。

幸运的是,这没那么难。这里 c00c 的含义就是:

  • 前两个比特(0b11.....)意思是“前面有 DNS 域名压缩!”
  • 而余下的 14 比特是一个整数。这种情况下这个整数是 12(0x0c),意思是“返回至数据包中的第 12 个字节处,使用在那里找的域名”

如果你想阅读更多有关 DNS 域名压缩之类的内容。我找到了相关更容易让你理解这方面内容的文章: 关于 DNS RFC 的释义。

步骤十:实现 DNS 域名压缩

因此,我们需要一个更复杂的 read_domain_name 函数。

如下所示:

domain = []
loop do
  len = buf.read(1).unpack('C')[0]
  break if len == 0
  if len & 0b11000000 == 0b11000000
    # weird case: DNS compression!
    second_byte = buf.read(1).unpack('C')[0]
    offset = ((len & 0x3f) << 8) + second_byte
    old_pos = buf.pos
    buf.pos = offset
    domain << read_domain_name(buf)
    buf.pos = old_pos
    break
  else
    # normal case
    domain << buf.read(len)
  end
end
domain.join('.')

这里具体是:

  • 如果前两个位为 0b11,那么我们就需要做 DNS 域名压缩。那么:
    • 读取第二个字节并用一点儿运算将其转化为偏移量。
    • 在缓冲区保存当前位置。
    • 在我们计算偏移量的位置上读取域名
    • 在缓冲区存储我们的位置。

可能看起来很乱,但是这是解析 DNS 响应的部分中最难的一处了,我们快搞定了!

一个关于 DNS 压缩的漏洞

有些人可能会说,有恶意行为者可以借助这个代码,通过一个带 DNS 压缩条目的 DNS 响应指向这个响应本身,这样 read_domain_name 就会陷入无限循环。我才不会改进它(这个代码已经够复杂了好吗!)但一个真正的 DNS 解析器确实会更巧妙地处理它。比如,这里有个 能够避免在 miekg/dns 中陷入无限循环的代码。

如果这是一个真正的 DNS 解析器,可能还有其他一些边缘情况会造成问题。

步骤十一:解析一个 DNS 查询

你可能在想:“为什么我们需要解析一个 DNS 查询?这是一个响应啊!”

但每一个 DNS 响应包含它自己的原始查询,所以我们有必要去解析它。

这是解析 DNS 查询的代码:

class DNSQuery
  attr_reader :domain, :type, :cls
  def initialize(buf)
    @domain = read_domain_name(buf)
    @type, @cls = buf.read(4).unpack('nn')
  end
end

内容不是太多:类型和类各占 2 个字节。

步骤十二:解析一个 DNS 记录

最让人兴奋的部分 —— DNS 记录是我们的查询数据存放的地方!即这个 “rdata 区域”(“记录数据字段”)就是我们会在 DNS 查询对应的响应中获得的 IP 地址所驻留的地方。

代码如下:

class DNSRecord
  attr_reader :name, :type, :class, :ttl, :rdlength, :rdata
  def initialize(buf)
    @name = read_domain_name(buf)
    @type, @class, @ttl, @rdlength = buf.read(10).unpack('nnNn')
    @rdata = buf.read(@rdlength)
  end

我们还需要让这个 rdata 区域更加可读。记录数据字段的实际用途取决于记录类型 —— 比如一个“A” 记录就是一个四个字节的 IP 地址,而一个 “CNAME” 记录则是一个域名。

所以下面的代码可以让请求数据更可读:

def read_rdata(buf, length)
  @type_name = TYPES[@type] || @type
  if @type_name == "CNAME" or @type_name == "NS"
    read_domain_name(buf)
  elsif @type_name == "A"
    buf.read(length).unpack('C*').join('.')
  else
    buf.read(length)
  end
end

这个函数使用了 TYPES 这个哈希表将一个记录类型映射为一个更可读的名称:

TYPES = {
  1 => "A",
  2 => "NS",
  5 => "CNAME",
  # there are a lot more but we don't need them for this example
}

read.rdata 中最有趣的一部分可能就是这一行 buf.read(length).unpack('C*').join('.') —— 像是在说:“嘿!一个 IP 地址有 4 个字节,就将它转换成一组四个数字组成的数组,然后数字互相之间用 ‘.’ 联个谊吧。”

步骤十三:解析 DNS 响应的收尾工作

现在我们正式准备好解析 DNS 响应了!

工作代码如下所示:

class DNSResponse
  attr_reader :header, :queries, :answers, :authorities, :additionals
  def initialize(bytes)
    buf = StringIO.new(bytes)
    @header = DNSHeader.new(buf)
    @queries = ([email protected]_questions).map { DNSQuery.new(buf) }
    @answers = ([email protected]_answers).map { DNSRecord.new(buf) }
    @authorities = ([email protected]_auth).map { DNSRecord.new(buf) }
    @additionals = ([email protected]_additional).map { DNSRecord.new(buf) }
  end
end

这里大部分内容就是在调用之前我们写过的其他函数来协助解析 DNS 响应。

如果 @header.num_answers 的值为 2,代码会使用了 ([email protected]_answers).map 这个巧妙的结构创建一个包含两个 DNS 记录的数组。(这可能有点像 Ruby 魔法,但我就是觉得有趣,但愿不会影响可读性。)

我们可以把这段代码整合进我们的主函数中,就像这样:

sock.send(make_dns_query("example.com", 1), 0) # 1 is "A", for IP address
reply, _ = sock.recvfrom(1024)
response = DNSResponse.new(reply) # parse the response!!!
puts response.answers[0]

尽管输出结果看起来有点辣眼睛(类似于 #<DNSRecord:0x00000001368e3118>),所以我们需要编写一些好看的输出代码,提升它的可读性。

步骤十四:对于我们输出的 DNS 记录进行美化

我们需要向 DNS 记录增加一个 .to_s 字段,从而让它有一个更良好的字符串展示方式。而者只是做为一行方法的代码在 DNSRecord 中存在。

def to_s
  "#{@name}tt#{@ttl}t#{@type_name}t#{@parsed_rdata}"
end

你可能也注意到了我忽略了 DNS 记录中的 class 区域。那是因为它总是相同的(IN 表示 “internet”),所以我觉得它是个多余的。虽然很多 DNS 工具(像真正的 dig)会输出 class。

大功告成!

via: https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/

作者:Julia Evans 选题:lujun9972 译者:Drwhooooo 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

1 条回复

  1. 来自陕西安康的 Firefox 115.0|GNU/Linux 用户 的头像
    来自陕西安康的 Firefox 115.0|GNU/Linux 用户

    竟然还能看到Ruby的文章

    来自西安

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注