從零開始,運(yùn)用 Ruby 語(yǔ)言創(chuàng)建一個(gè) DNS 查詢
大家好!前段時(shí)間我寫了一篇關(guān)于“如何用 Go 語(yǔ)言建立一個(gè)簡(jiǎn)易的 DNS 解析器”的帖子。
那篇帖子里我沒(méi)寫有關(guān)“如何生成以及解析 DNS 查詢請(qǐng)求”的內(nèi)容,因?yàn)槲矣X(jué)得這很無(wú)聊,不過(guò)一些伙計(jì)指出他們不知道如何解析和生成 DNS 查詢請(qǐng)求,并且對(duì)此很感興趣。
我開始好奇了——解析 DNS 能
所以,在這里有一個(gè)如何生成 DNS 查詢請(qǐng)求,以及如何解析 DNS 響應(yīng)報(bào)文的速成教學(xué)!我們會(huì)用 Ruby 語(yǔ)言完成這項(xiàng)任務(wù),主要是因?yàn)椴痪靡院笪覍⒃谝粓?chǎng) Ruby 語(yǔ)言大會(huì)上發(fā)表觀點(diǎn),而這篇博客帖的部分內(nèi)容是為了那場(chǎng)演講做準(zhǔn)備的。??
(我盡量讓不懂 Ruby 的人也能讀懂,我只使用了非?;A(chǔ)的 Ruby 語(yǔ)言代碼。)
最后,我們就能制作一個(gè)非常簡(jiǎn)易的 Ruby 版本的 dig
工具,能夠查找域名,就像這樣:
$ ruby dig.rb example.com
example.com 20314 A 93.184.216.34
整個(gè)程序大概 120 行左右,所以 并不 算多。(如果你想略過(guò)講解,單純想去讀代碼的話,最終程序在這里:dig.rb。)
我們不會(huì)去實(shí)現(xiàn)之前帖中所說(shuō)的“一個(gè) DNS 解析器是如何運(yùn)作的?”,因?yàn)槲覀円呀?jīng)做過(guò)了。
那么我們開始吧!
如果你想從頭開始弄明白 DNS 查詢是如何格式化的,我將嘗試解釋如何自己弄明白其中的一些東西。大多數(shù)情況下的答案是“用 Wireshark 去解包”和“閱讀 RFC 1035,即 DNS 的規(guī)范”。
生成 DNS 查詢請(qǐng)求
步驟一:打開一個(gè) UDP 套接字
我們需要實(shí)際發(fā)送我們的 DNS 查詢,因此我們就需要打開一個(gè) UDP 套接字。我們會(huì)將我們的 DNS 查詢發(fā)送至 8.8.8.8
,即谷歌的服務(wù)器。
下面是用于建立與 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)
關(guān)于 UDP 的說(shuō)明
關(guān)于 UDP,我不想說(shuō)太多,但是我要說(shuō)的是,計(jì)算機(jī)網(wǎng)絡(luò)的基礎(chǔ)單位是“數(shù)據(jù)包packet”(即一串字節(jié)),而在這個(gè)程序中,我們要做的是計(jì)算機(jī)網(wǎng)絡(luò)中最簡(jiǎn)單的事情:發(fā)送 1 個(gè)數(shù)據(jù)包,并接收 1 個(gè)數(shù)據(jù)包作為響應(yīng)。
所以 UDP 是一個(gè)傳遞數(shù)據(jù)包的最簡(jiǎn)單的方法。
它是發(fā)送 DNS 查詢最常用的方法,不過(guò)你還可以用 TCP 或者 DNS-over-HTTPS。
步驟二:從 Wireshark 復(fù)制一個(gè) DNS 查詢
下一步:假設(shè)我們都不知道 DNS 是如何運(yùn)作的,但我們還是想盡快發(fā)送一個(gè)能運(yùn)行的 DNS 查詢。獲取 DNS 查詢并確保 UDP 連接正常工作的最簡(jiǎn)單方法就是復(fù)制一個(gè)已經(jīng)正常工作的 DNS 查詢!
所以這就是我們接下來(lái)要做的,使用 Wireshark (一個(gè)絕贊的數(shù)據(jù)包分析工具)。
我的操作大致如下:
- 打開 Wireshark,點(diǎn)擊 “捕獲capture” 按鈕。
- 在搜索欄輸入
udp.port == 53
作為篩選條件,然后按下回車。 - 在我的終端運(yùn)行
ping example.com
(用來(lái)生成一個(gè) DNS 查詢)。 - 點(diǎn)擊 DNS 查詢(顯示 “Standard query A example.com”)。 (“A”:查詢類型;“example.com”:域名;“Standard query”:查詢類型描述)
- 右鍵點(diǎn)擊位于左下角面板上的 “域名系統(tǒng)(查詢)Domain Name System (query)”。
- 點(diǎn)擊 “復(fù)制Copy” ——> “作為十六進(jìn)制流as a hex stream”。
- 現(xiàn)在
b96201000001000000000000076578616d706c6503636f6d0000010001
就放到了我的剪貼板上,之后會(huì)用在我的 Ruby 程序里。好欸!
步驟三:解析 16 進(jìn)制數(shù)據(jù)流并發(fā)送 DNS 查詢
現(xiàn)在我們能夠發(fā)送我們的 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 位字符串轉(zhuǎn)譯成一個(gè)字節(jié)串。此時(shí)我們不知道這組數(shù)據(jù)到底是什么意思,但是很快我們就會(huì)知道了。
我們還可以借此機(jī)會(huì)運(yùn)用 tcpdump
,確認(rèn)程序是否正常進(jìn)行以及發(fā)送有效數(shù)據(jù)。我是這么做的:
- 在一個(gè)終端選項(xiàng)卡下執(zhí)行
sudo tcpdump -ni any port 53 and host 8.8.8.8
命令 - 在另一個(gè)不同的終端指標(biāo)卡下,運(yùn)行 這個(gè)程序(
ruby dns-1.rb
)
以下是輸出結(jié)果:
$ 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 請(qǐng)求(”這個(gè) example.com
的 IP 地址在哪里?“)以及響應(yīng)(“在93.184.216.34”)。所以一切運(yùn)行正常。現(xiàn)在只需要(你懂的)—— 搞清我們是如何生成并解析這組數(shù)據(jù)的。
步驟四:學(xué)一點(diǎn)點(diǎn) DNS 查詢的格式
現(xiàn)在我們有一個(gè)關(guān)于 example.com
的 DNS 查詢,讓我們了解它的含義。
下方是我們的查詢(16 位進(jìn)制格式):
b96201000001000000000000076578616d706c6503636f6d0000010001
如果你在 Wireshark 上搜索,你就能看見(jiàn)這個(gè)查詢它由兩部分組成:
- 請(qǐng)求頭:
b96201000001000000000000
- 語(yǔ)句本身:
076578616d706c6503636f6d0000010001
步驟五:制作請(qǐng)求頭
我們這一步的目標(biāo)就是制作字節(jié)串 b96201000001000000000000
(借助一個(gè) Ruby 函數(shù),而不是把它硬編碼出來(lái))。
(LCTT 譯注:硬編碼hardcode 指在軟件實(shí)現(xiàn)上,將輸出或輸入的相關(guān)參數(shù)(例如:路徑、輸出的形式或格式)直接以常量的方式撰寫在源代碼中,而非在運(yùn)行期間由外界指定的設(shè)置、資源、數(shù)據(jù)或格式做出適當(dāng)回應(yīng)。)
那么:請(qǐng)求頭是 12 個(gè)字節(jié)。那些個(gè) 12 字節(jié)到底意味著什么呢?如果你在 Wireshark 里看看(亦或者閱讀 RFC-1035),你就能理解:它是由 6 個(gè) 2 字節(jié)大小的數(shù)字串聯(lián)在一起組成的。
這六個(gè)數(shù)字分別對(duì)應(yīng)查詢 ID、標(biāo)志,以及數(shù)據(jù)包內(nèi)的問(wèn)題計(jì)數(shù)、回答資源記錄數(shù)、權(quán)威名稱服務(wù)器記錄數(shù)、附加資源記錄數(shù)。
我們還不需要在意這些都是些什么東西 —— 我們只需要把這六個(gè)數(shù)字輸進(jìn)去就行。
但所幸我們知道該輸哪六位數(shù),因?yàn)槲覀兙褪菫榱酥庇^地生成字符串 b96201000001000000000000
。
所以這里有一個(gè)制作請(qǐng)求頭的函數(shù)(注意:這里沒(méi)有 return
,因?yàn)樵?Ruby 語(yǔ)言里,如果處在函數(shù)最后一行是不需要寫 return
語(yǔ)句的):
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
上面內(nèi)容非常的短,主要因?yàn)槌瞬樵?ID ,其余所有內(nèi)容都由我們硬編碼寫了出來(lái)。
什么是 nnnnnn
?
可能能想知道 .pack('nnnnnn')
中的 nnnnnn
是個(gè)什么意思。那是一個(gè)向 .pack()
函數(shù)解釋如何將那個(gè) 6 個(gè)數(shù)字組成的數(shù)據(jù)轉(zhuǎn)換成一個(gè)字節(jié)串的一個(gè)格式字符串。
.pack
的文檔在 這里,其中描述了 n
的含義其實(shí)是“將其表示為” 16 位無(wú)符號(hào)、網(wǎng)絡(luò)(大端序)字節(jié)序’”。
(LCTT 譯注:大端序Big-endian:指將高位字節(jié)存儲(chǔ)在低地址,低位字節(jié)存儲(chǔ)在高地址的方式。)
16 個(gè)位等同于 2 字節(jié),同時(shí)我們需要用網(wǎng)絡(luò)字節(jié)序,因?yàn)檫@屬于計(jì)算機(jī)網(wǎng)絡(luò)范疇。我不會(huì)再去解釋什么是字節(jié)序了(盡管我確實(shí)有 一幅自制漫畫嘗試去描述它)。
測(cè)試請(qǐng)求頭代碼
讓我們快速檢測(cè)一下我們的 make_question_header
函數(shù)運(yùn)行情況。
puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*")
這里運(yùn)行后輸出 true
的話,我們就成功了。
好了我們接著繼續(xù)。
步驟六:為域名進(jìn)行編碼
下一步我們需要生成 問(wèn)題本身(“example.com
的 IP 是什么?”)。這里有三個(gè)部分:
- 域名(比如說(shuō)
example.com
) - 查詢類型(比如說(shuō)
A
代表 “IPv4 Address”) - 查詢類(總是一樣的,
1
代表 INternet)
最麻煩的就是域名,讓我們寫個(gè)函數(shù)對(duì)付這個(gè)。
example.com
以 16 進(jìn)制被編碼進(jìn)一個(gè) DNS 查詢中,如 076578616d706c6503636f6d00
。這有什么含義嗎?
如果我們把這些字節(jié)以 ASCII 值翻譯出來(lái),結(jié)果會(huì)是這樣:
076578616d706c6503636f6d00
7 e x a m p l e 3 c o m 0
因此,每個(gè)段(如 example
)的前面都會(huì)顯示它的長(zhǎng)度(7
)。
下面是有關(guān)將 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 + "\0"
end
除此之外,,要完成問(wèn)題部分的生成,我們只需要在域名結(jié)尾追加上(查詢)的類型和類。
步驟七:編寫 make_dns_query
下面是制作一個(gè) DNS 查詢的最終函數(shù):
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 行。
接下來(lái)是解析的階段
現(xiàn)在我嘗試去解析一個(gè) DNS 查詢,我們到了硬核的部分:解析。同樣的,我們會(huì)將其分成不同部分:
- 解析一個(gè) DNS 的請(qǐng)求頭
- 解析一個(gè) DNS 的名稱
- 解析一個(gè) DNS 的記錄
這幾個(gè)部分中最難的(可能跟你想的不一樣)就是:“解析一個(gè) DNS 的名稱”。
步驟八:解析 DNS 的請(qǐng)求頭
讓我們先從最簡(jiǎn)單的部分開始:DNS 的請(qǐng)求頭。我們之前已經(jīng)講過(guò)關(guān)于它那六個(gè)數(shù)字是如何串聯(lián)在一起的了。
那么我們現(xiàn)在要做的就是:
- 讀其首部 12 個(gè)字節(jié)
- 將其轉(zhuǎn)換成一個(gè)由 6 個(gè)數(shù)字組成的數(shù)組
- 為方便起見(jiàn),將這些數(shù)字放入一個(gè)類中
以下是具體進(jìn)行工作的 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 的一種說(shuō)法,意思是“使這些實(shí)例變量可以作為方法使用”。所以我們可以調(diào)用 header.flags
來(lái)查看@flags
變量。
我們也可以借助 DNSheader(buf)
調(diào)用這個(gè),也不差。
讓我們往最難的那一步挪挪:解析一個(gè)域名。
步驟九:解析一個(gè)域名
首先,讓我們寫其中的一部分:
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
這里會(huì)反復(fù)讀取一個(gè)字節(jié)的數(shù)據(jù),然后將該長(zhǎng)度讀入字符串,直到讀取的長(zhǎng)度為 0。
這里運(yùn)行正常的話,我們?cè)谖覀兊?DNS 響應(yīng)頭第一次看見(jiàn)了域名(example.com
)。
關(guān)于域名方面的麻煩:壓縮!
但當(dāng) example.com
第二次出現(xiàn)的時(shí)候,我們遇到了麻煩 —— 在 Wireshark 中,它報(bào)告上顯示輸出的域的值為含糊不清的 2 個(gè)字節(jié)的 c00c
。
這種情況就是所謂的 DNS 域名壓縮,如果我們想解析任何 DNS 響應(yīng)我們就要先把這個(gè)實(shí)現(xiàn)完。
幸運(yùn)的是,這沒(méi)那么難。這里 c00c
的含義就是:
- 前兩個(gè)比特(
0b11.....
)意思是“前面有 DNS 域名壓縮!” - 而余下的 14 比特是一個(gè)整數(shù)。這種情況下這個(gè)整數(shù)是
12
(0x0c
),意思是“返回至數(shù)據(jù)包中的第 12 個(gè)字節(jié)處,使用在那里找的域名”
如果你想閱讀更多有關(guān) DNS 域名壓縮之類的內(nèi)容。我找到了相關(guān)更容易讓你理解這方面內(nèi)容的文章: 關(guān)于 DNS RFC 的釋義。
步驟十:實(shí)現(xiàn) DNS 域名壓縮
因此,我們需要一個(gè)更復(fù)雜的 read_domain_name
函數(shù)。
如下所示:
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('.')
這里具體是:
- 如果前兩個(gè)位為
0b11
,那么我們就需要做 DNS 域名壓縮。那么:
- 讀取第二個(gè)字節(jié)并用一點(diǎn)兒運(yùn)算將其轉(zhuǎn)化為偏移量。
- 在緩沖區(qū)保存當(dāng)前位置。
- 在我們計(jì)算偏移量的位置上讀取域名
- 在緩沖區(qū)存儲(chǔ)我們的位置。
可能看起來(lái)很亂,但是這是解析 DNS 響應(yīng)的部分中最難的一處了,我們快搞定了!
一個(gè)關(guān)于 DNS 壓縮的漏洞
有些人可能會(huì)說(shuō),有惡意行為者可以借助這個(gè)代碼,通過(guò)一個(gè)帶 DNS 壓縮條目的 DNS 響應(yīng)指向這個(gè)響應(yīng)本身,這樣 read_domain_name
就會(huì)陷入無(wú)限循環(huán)。我才不會(huì)改進(jìn)它(這個(gè)代碼已經(jīng)夠復(fù)雜了好嗎!)但一個(gè)真正的 DNS 解析器確實(shí)會(huì)更巧妙地處理它。比如,這里有個(gè) 能夠避免在 miekg/dns 中陷入無(wú)限循環(huán)的代碼。
如果這是一個(gè)真正的 DNS 解析器,可能還有其他一些邊緣情況會(huì)造成問(wèn)題。
步驟十一:解析一個(gè) DNS 查詢
你可能在想:“為什么我們需要解析一個(gè) DNS 查詢?這是一個(gè)響應(yīng)?。 ?/p>
但每一個(gè) DNS 響應(yīng)包含它自己的原始查詢,所以我們有必要去解析它。
這是解析 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
內(nèi)容不是太多:類型和類各占 2 個(gè)字節(jié)。
步驟十二:解析一個(gè) DNS 記錄
最讓人興奮的部分 —— DNS 記錄是我們的查詢數(shù)據(jù)存放的地方!即這個(gè) “rdata 區(qū)域”(“記錄數(shù)據(jù)字段”)就是我們會(huì)在 DNS 查詢對(duì)應(yīng)的響應(yīng)中獲得的 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
我們還需要讓這個(gè) rdata
區(qū)域更加可讀。記錄數(shù)據(jù)字段的實(shí)際用途取決于記錄類型 —— 比如一個(gè)“A” 記錄就是一個(gè)四個(gè)字節(jié)的 IP 地址,而一個(gè) “CNAME” 記錄則是一個(gè)域名。
所以下面的代碼可以讓請(qǐng)求數(shù)據(jù)更可讀:
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
這個(gè)函數(shù)使用了 TYPES
這個(gè)哈希表將一個(gè)記錄類型映射為一個(gè)更可讀的名稱:
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('.')
—— 像是在說(shuō):“嘿!一個(gè) IP 地址有 4 個(gè)字節(jié),就將它轉(zhuǎn)換成一組四個(gè)數(shù)字組成的數(shù)組,然后數(shù)字互相之間用 ‘.’ 聯(lián)個(gè)誼吧?!?/p>
步驟十三:解析 DNS 響應(yīng)的收尾工作
現(xiàn)在我們正式準(zhǔn)備好解析 DNS 響應(yīng)了!
工作代碼如下所示:
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
這里大部分內(nèi)容就是在調(diào)用之前我們寫過(guò)的其他函數(shù)來(lái)協(xié)助解析 DNS 響應(yīng)。
如果 @header.num_answers
的值為 2,代碼會(huì)使用了 (1..@header.num_answers).map
這個(gè)巧妙的結(jié)構(gòu)創(chuàng)建一個(gè)包含兩個(gè) DNS 記錄的數(shù)組。(這可能有點(diǎn)像 Ruby 魔法,但我就是覺(jué)得有趣,但愿不會(huì)影響可讀性。)
我們可以把這段代碼整合進(jìn)我們的主函數(shù)中,就像這樣:
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]
盡管輸出結(jié)果看起來(lái)有點(diǎn)辣眼睛(類似于 #<DNSRecord:0x00000001368e3118>
),所以我們需要編寫一些好看的輸出代碼,提升它的可讀性。
步驟十四:對(duì)于我們輸出的 DNS 記錄進(jìn)行美化
我們需要向 DNS 記錄增加一個(gè) .to_s
字段,從而讓它有一個(gè)更良好的字符串展示方式。而者只是做為一行方法的代碼在 DNSRecord
中存在。
def to_s
"#{@name}\t\t#{@ttl}\t#{@type_name}\t#{@parsed_rdata}"
end
你可能也注意到了我忽略了 DNS 記錄中的 class
區(qū)域。那是因?yàn)樗偸窍嗤模↖N 表示 “internet”),所以我覺(jué)得它是個(gè)多余的。雖然很多 DNS 工具(像真正的 dig
)會(huì)輸出 class
。
大功告成!
這是我們最終的主函數(shù):
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
我不覺(jué)得我們還能再補(bǔ)充什么 —— 我們建立連接、發(fā)送一個(gè)查詢、輸出每一個(gè)回答,然后退出。完事兒!
$ ruby dig.rb example.com
example.com 18608 A 93.184.216.34
你可以在這里查看最終程序:dig.rb??梢愿鶕?jù)你的喜好給它增加更多特性,就比如說(shuō):
- 為其他查詢類型添加美化輸出。
- 輸出 DNS 響應(yīng)時(shí)增加“授權(quán)”和“可追加”的選項(xiàng)
- 重試查詢
- 確保我們看到的 DNS 響應(yīng)匹配我們的查詢(ID 信息必須是對(duì)的上的?。?/li>
另外如果我在這篇文章中出現(xiàn)了什么錯(cuò)誤,就 在推特和我聊聊吧。(我寫的比較趕所以可能還是會(huì)有些錯(cuò)誤)