你好!不久前,我写了一篇关于如何在 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 RFC”。
第 1 步:打开一个 UDP 套接字
我们需要实际发送查询,因此我们需要打开一个 UDP 套接字。我们会将查询发送到 Google 的 DNS 服务器8.8.8.8
。
这是设置与8.8.8.8
端口 53(DNS 端口)的 UDP 连接的代码。
require 'socket' sock = UDPSocket.new sock.bind('0.0.0.0', 12345) sock.connect('8.8.8.8', 53)
关于 UDP 的快速说明
在这里我不会过多地谈论 UDP,但我会说计算机网络的基本单元是“数据包”(数据包是一串字节),在这个程序中我们要做您可以用计算机网络做的最简单的事情——发送 1 个数据包并接收 1 个数据包作为响应。
所以UDP是一种以最简单的方式发送数据包的方式。
这是发送 DNS 查询的最常用方式,但您也可以改用 TCP 或 DNS-over-HTTPS。
第 2 步:从 Wireshark 复制 DNS 查询
下一步:假设我们不知道 DNS 是如何工作的,但我们希望尽快发送有效的查询。获取 DNS 查询并确保我们的 UDP 连接正常工作的最简单方法是复制一个已经工作的!
这就是我们要做的,使用 Wireshark(一个令人难以置信的数据包分析工具)
我用来做这个的步骤大致是:
- 打开 Wireshark 并单击“捕获”
- 输入
udp.port == 53
作为过滤器(在搜索栏中) - 在我的终端中运行
ping example.com
(生成 DNS 查询) - 单击 DNS 查询(“标准查询 A example.com”)
- 右键单击左下窗格中的“域名系统(查询”)
- 单击“复制”->“作为十六进制流”
- 现在我的剪贴板上有“b96201000001000000000000076578616d706c6503636f6d0000010001”,可以在我的 Ruby 程序中使用。万岁!
第 3 步:解码十六进制流并发送 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*')
正在将我们的十六进制字符串转换为字节字符串。在这一点上,我们真的不知道这些数据意味着什么,但我们会在一秒钟内到达那里。
我们还可以借此机会使用tcpdump
确保我们的程序正在运行并且正在发送有效数据。我是怎么做到的:
- 在终端选项卡中运行
sudo tcpdump -ni any port 53 and host 8.8.8.8
- 在不同的终端选项卡中,运行这个 Ruby 程序(
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”)。所以一切正常。现在我们只需要,你知道,弄清楚如何自己生成和解码这些数据。
第 4 步:了解一下 DNS 查询是如何格式化的
现在我们有一个针对example.com
的 DNS 查询,让我们了解一下它的含义。
这是我们的查询,格式为十六进制。
b96201000001000000000000076578616d706c6503636f6d0000010001
如果你在 Wireshark 中四处寻找,你会看到这个查询有 2 个部分:
- 标题(
b96201000001000000000000
) - 问题(
076578616d706c6503636f6d0000010001
)
第5步:制作标题
我们在这一步的目标是生成字节字符串b96201000001000000000000
,但使用 Ruby 函数而不是硬编码。
所以:标头是 12 个字节。那 12 个字节是什么意思?如果您查看 Wireshark(或阅读RFC 1035 ),您会看到它是 6 个 2 字节数字连接在一起。
这6个数字分别对应于查询ID、标志,然后是数据包中的问题、答案记录、权威记录和附加记录的数量。
不过,我们不需要担心所有这些东西是什么——我们只需要输入 6 个数字。
幸运的是,我们确切地知道要输入哪 6 个数字,因为我们的目标是从字面上生成字符串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 位无符号,网络(大端)字节顺序”。
16位相当于2个字节,我们需要使用网络字节序,因为这是计算机网络。我现在不打算解释字节顺序(尽管我确实有一个漫画试图解释它)
测试头代码
让我们快速测试一下我们的make_question_header
函数是否有效。
puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*")
这打印出“真实”,所以我们赢了,我们可以继续前进。
第五步:对域名进行编码
接下来我们需要生成问题(“ example.com
的 IP 是什么?”)。这有 3 个部分:
- 域名(例如“example.com”)
- 查询类型(例如“ A ”代表“IPv4 地址”
- 查询类(始终相同,1 代表IN代表IN ternet)
其中最难的部分是域名,所以让我们编写一个函数来实现这一点。
example.com
在 DNS 查询中以十六进制编码为076578616d706c6503636f6d00
。这意味着什么?
好吧,如果我们将字节转换为 ASCII,它看起来像这样:
076578616d706c6503636f6d00 7 example 3 com 0
因此,每个段(例如example
)前面都有它的长度(例如 7)。
这是将example.com
转换为7 example 3 com 0
的 Ruby 代码:
def encode_domain_name(domain) domain .split(".") .map { |x| x.length.chr + x } .join + "\0" end
除此之外,要完成生成问题部分,我们只需要将类型和类附加到域名的末尾。
第 6 步:编写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 名称”。
第 7 步:解析 DNS 标头
让我们从最简单的部分开始:DNS 标头。我们已经讨论过它是如何将 6 个数字连接在一起的。
所以我们需要做的就是
- 读取前 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
快速 Ruby 注释: 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
这会重复读取 1 个字节,然后将该长度读入字符串,直到长度为 0。
这很好用,我们第一次在 DNS 响应中看到域名 ( example.com
)。
域名问题:压缩!
但是第二次出现example.com
时,我们遇到了麻烦——在 Wireshark 中,它说域被神秘地表示为 2 个字节c00c
。
这就是所谓的DNS 压缩,如果我们想要解析任何 DNS 响应,我们将不得不实现它。
幸运的是,这并不难。所有c00c
都在说:
- 前 2 位 (
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('.')
基本上发生的事情是:
- 如果前 2 位是
0b11
,我们需要进行 DNS 压缩。然后:- 读取第二个字节并进行一些算术运算以将其转换为偏移量
- 将当前位置保存在缓冲区中
- 在我们计算的偏移量处读取域名
- 恢复我们在缓冲区中的位置
这有点混乱,但它是解析 DNS 响应中最复杂的部分,所以我们差不多完成了!
第 10 步:解析 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 个字节。
第 11 步:解析 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”记录,它是一个 4 字节的 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 个字节,所以将其转换为数组4个数字,然后用“.”加入那些。
第 12 步:完成解析 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
构造来创建一个包含 2 个 DNS 记录的数组。(这可能有点Ruby 的魔力,但我认为这很有趣,而且希望不会太难读)
我们可以将这段代码集成到我们的 main 函数中,如下所示:
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>
)。所以我们需要编写一些漂亮的打印代码来使其可读。
第 13 步:漂亮地打印我们的 DNS 记录
我们需要向 DNS 记录添加一个.to_s
字段,以使它们具有良好的字符串表示形式。这只是DNSRecord
中的 1 行方法:
def to_s "#{@name}\t\t#{@ttl}\t#{@type_name}\t#{@parsed_rdata}" end
您可能还注意到我遗漏了 DNS 记录的class
字段。那是因为它总是相同的(IN 表示“internet”),所以我觉得它是多余的。大多数 DNS 工具(如 real dig
)都会打印出该类。
我们完成了!
这是我们最终的main
功能:
def main # connect to google dns sock = UDPSocket.new sock.bind('0.0.0.0', 12345) sock.connect('8.8.8.8', 53) # send query domain = ARGV[0] sock.send(make_dns_query(domain, 1), 0) # receive & parse response reply, _ = sock.recvfrom(1024) response = DNSResponse.new(reply) response.answers.each do |record| puts record end
我认为这没什么好说的——我们连接,发送查询,打印出每个答案,然后退出。成功!
$ ruby dig.rb example.com example.com 18608 A 93.184.216.34
您可以在此处将最终程序视为要点: dig.rb 。如果需要,您可以为其添加更多功能(例如漂亮打印其他查询类型或选项以打印出 DNS 响应的“权限”和“附加”部分!“)。
如果我在这篇文章的某个地方犯了错误,你也可以在 Twitter 上告诉我——我写得很快,所以我可能有问题。
原文: https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/