Skip to content

搞英语 → 看世界

翻译英文优质信息和名人推特

Menu
  • 首页
  • 作者列表
  • 独立博客
  • 专业媒体
  • 名人推特
  • 邮件列表
  • 关于本站
Menu

从头开始在 Ruby 中进行 DNS 查询

Posted on 2022-11-07

你好!不久前,我写了一篇关于如何在 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(一个令人难以置信的数据包分析工具)

我用来做这个的步骤大致是:

  1. 打开 Wireshark 并单击“捕获”
  2. 输入udp.port == 53作为过滤器(在搜索栏中)
  3. 在我的终端中运行ping example.com (生成 DNS 查询)
  4. 单击 DNS 查询(“标准查询 A example.com”)
  5. 右键单击左下窗格中的“域名系统(查询”)
  6. 单击“复制”->“作为十六进制流”
  7. 现在我的剪贴板上有“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确保我们的程序正在运行并且正在发送有效数据。我是怎么做到的:

  1. 在终端选项卡中运行sudo tcpdump -ni any port 53 and host 8.8.8.8
  2. 在不同的终端选项卡中,运行这个 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/

本站文章系自动翻译,站长会周期检查,如果有不当内容,请点此留言,非常感谢。
  • Abhinav
  • Abigail Pain
  • Adam Fortuna
  • Alberto Gallego
  • Alex Wlchan
  • Answer.AI
  • Arne Bahlo
  • Ben Carlson
  • Ben Kuhn
  • Bert Hubert
  • Bits about Money
  • Brian Krebs
  • ByteByteGo
  • Chip Huyen
  • Chips and Cheese
  • Christopher Butler
  • Colin Percival
  • Cool Infographics
  • Dan Sinker
  • David Walsh
  • Dmitry Dolzhenko
  • Dustin Curtis
  • Elad Gil
  • Ellie Huxtable
  • Ethan Marcotte
  • Exponential View
  • FAIL Blog
  • Founder Weekly
  • Geoffrey Huntley
  • Geoffrey Litt
  • Greg Mankiw
  • Henrique Dias
  • Hypercritical
  • IEEE Spectrum
  • Investment Talk
  • Jaz
  • Jeff Geerling
  • Jonas Hietala
  • Josh Comeau
  • Lenny Rachitsky
  • Liz Danzico
  • Lou Plummer
  • Luke Wroblewski
  • Matt Baer
  • Matt Stoller
  • Matthias Endler
  • Mert Bulan
  • Mostly metrics
  • News Letter
  • NextDraft
  • Non_Interactive
  • Not Boring
  • One Useful Thing
  • Phil Eaton
  • Product Market Fit
  • Readwise
  • ReedyBear
  • Robert Heaton
  • Ruben Schade
  • Sage Economics
  • Sam Altman
  • Sam Rose
  • selfh.st
  • Shtetl-Optimized
  • Simon schreibt
  • Slashdot
  • Small Good Things
  • Taylor Troesh
  • Telegram Blog
  • The Macro Compass
  • The Pomp Letter
  • thesephist
  • Thinking Deep & Wide
  • Tim Kellogg
  • Understanding AI
  • 英文媒体
  • 英文推特
  • 英文独立博客
©2025 搞英语 → 看世界 | Design: Newspaperly WordPress Theme