從零開始,運用 Ruby 語言創建一個 DNS 查詢

大家好!前段時間我寫了一篇關於「如何用 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 查詢請求
步驟一:打開一個 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,我不想說太多,但是我要說的是,計算機網路的基礎單位是「 數據包 」(即一串位元組),而在這個程序中,我們要做的是計算機網路中最簡單的事情:發送 1 個數據包,並接收 1 個數據包作為響應。
所以 UDP 是一個傳遞數據包的最簡單的方法。
它是發送 DNS 查詢最常用的方法,不過你還可以用 TCP 或者 DNS-over-HTTPS。
步驟二:從 Wireshark 複製一個 DNS 查詢
下一步:假設我們都不知道 DNS 是如何運作的,但我們還是想儘快發送一個能運行的 DNS 查詢。獲取 DNS 查詢並確保 UDP 連接正常工作的最簡單方法就是複製一個已經正常工作的 DNS 查詢!
所以這就是我們接下來要做的,使用 Wireshark (一個絕贊的數據包分析工具)。
我的操作大致如下:
- 打開 Wireshark,點擊 「 捕獲 」 按鈕。
- 在搜索欄輸入
udp.port == 53
作為篩選條件,然後按下回車。 - 在我的終端運行
ping example.com
(用來生成一個 DNS 查詢)。 - 點擊 DNS 查詢(顯示 「Standard query A example.com」)。 (「A」:查詢類型;「example.com」:域名;「Standard query」:查詢類型描述)
- 右鍵點擊位於左下角面板上的 「 域名系統(查詢) 」。
- 點擊 「 複製 」 ——> 「 作為十六進位流 」。
- 現在
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 譯註: 硬編碼 指在軟體實現上,將輸出或輸入的相關參數(例如:路徑、輸出的形式或格式)直接以常量的方式撰寫在源代碼中,而非在運行期間由外界指定的設置、資源、數據或格式做出適當回應。)
那麼:請求頭是 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 譯註: 大端序 :指將高位位元組存儲在低地址,低位位元組存儲在高地址的方式。)
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 << 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 = (1..@header.num_questions).map { DNSQuery.new(buf) }
@answers = (1..@header.num_answers).map { DNSRecord.new(buf) }
@authorities = (1..@header.num_auth).map { DNSRecord.new(buf) }
@additionals = (1..@header.num_additional).map { DNSRecord.new(buf) }
end
end
這裡大部分內容就是在調用之前我們寫過的其他函數來協助解析 DNS 響應。
如果 @header.num_answers
的值為 2,代碼會使用了 (1..@header.num_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
。
大功告成!
這是我們最終的主函數:
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 響應時增加「授權」和「可追加」的選項
- 重試查詢
- 確保我們看到的 DNS 響應匹配我們的查詢(ID 信息必須是對的上的!)
另外如果我在這篇文章中出現了什麼錯誤,就 在推特和我聊聊吧。(我寫的比較趕所以可能還是會有些錯誤)
(題圖:MJ/449d049d-6bdd-448b-a61d-17138f8551bc)
via: https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/
作者:Julia Evans 選題:lujun9972 譯者:Drwhooooo 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive