Linux中國

從零開始,運用 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,我不想說太多,但是我要說的是,計算機網路的基礎單位是「 數據包 packet 」(即一串位元組),而在這個程序中,我們要做的是計算機網路中最簡單的事情:發送 1 個數據包,並接收 1 個數據包作為響應。

所以 UDP 是一個傳遞數據包的最簡單的方法。

它是發送 DNS 查詢最常用的方法,不過你還可以用 TCP 或者 DNS-over-HTTPS。

步驟二:從 Wireshark 複製一個 DNS 查詢

下一步:假設我們都不知道 DNS 是如何運作的,但我們還是想儘快發送一個能運行的 DNS 查詢。獲取 DNS 查詢並確保 UDP 連接正常工作的最簡單方法就是複製一個已經正常工作的 DNS 查詢!

所以這就是我們接下來要做的,使用 Wireshark (一個絕贊的數據包分析工具)。

我的操作大致如下:

  1. 打開 Wireshark,點擊 「 捕獲 capture 」 按鈕。
  2. 在搜索欄輸入 udp.port == 53 作為篩選條件,然後按下回車。
  3. 在我的終端運行 ping example.com(用來生成一個 DNS 查詢)。
  4. 點擊 DNS 查詢(顯示 「Standard query A example.com」)。 (「A」:查詢類型;「example.com」:域名;「Standard query」:查詢類型描述)
  5. 右鍵點擊位於左下角面板上的 「 域名系統(查詢) Domain Name System (query) 」。
  6. 點擊 「 複製 Copy 」 ——> 「 作為十六進位流 as a hex stream 」。
  7. 現在 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 ,確認程序是否正常進行以及發送有效數據。我是這麼做的:

  1. 在一個終端選項卡下執行 sudo tcpdump -ni any port 53 and host 8.8.8.8 命令
  2. 在另一個不同的終端指標卡下,運行 這個程序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 譯註: 硬編碼 hardcode 指在軟體實現上,將輸出或輸入的相關參數(例如:路徑、輸出的形式或格式)直接以常量的方式撰寫在源代碼中,而非在運行期間由外界指定的設置、資源、數據或格式做出適當回應。)

那麼:請求頭是 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 譯註: 大端序 Big-endian :指將高位位元組存儲在低地址,低位位元組存儲在高地址的方式。)

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(&apos;.&apos;)
end

這裡會反覆讀取一個位元組的數據,然後將該長度讀入字元串,直到讀取的長度為 0。

這裡運行正常的話,我們在我們的 DNS 響應頭第一次看見了域名(example.com)。

關於域名方面的麻煩:壓縮!

但當 example.com 第二次出現的時候,我們遇到了麻煩 —— 在 Wireshark 中,它報告上顯示輸出的域的值為含糊不清的 2 個位元組的 c00c

這種情況就是所謂的 DNS 域名壓縮,如果我們想解析任何 DNS 響應我們就要先把這個實現完。

幸運的是,這沒那麼難。這裡 c00c 的含義就是:

  • 前兩個比特(0b11.....)意思是「前面有 DNS 域名壓縮!」
  • 而餘下的 14 比特是一個整數。這種情況下這個整數是 120x0c),意思是「返回至數據包中的第 12 個位元組處,使用在那裡找的域名」

如果你想閱讀更多有關 DNS 域名壓縮之類的內容。我找到了相關更容易讓你理解這方面內容的文章: 關於 DNS RFC 的釋義

步驟十:實現 DNS 域名壓縮

因此,我們需要一個更複雜的 read_domain_name 函數。

如下所示:

domain = []
loop do
  len = buf.read(1).unpack(&apos;C&apos;)[0]
  break if len == 0
  if len & 0b11000000 == 0b11000000
    # weird case: DNS compression!
    second_byte = buf.read(1).unpack(&apos;C&apos;)[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(&apos;.&apos;)

這裡具體是:

  • 如果前兩個位為 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(&apos;nn&apos;)
  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(&apos;nnNn&apos;)
    @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(&apos;C*&apos;).join(&apos;.&apos;)
  else
    buf.read(length)
  end
end

這個函數使用了 TYPES 這個哈希表將一個記錄類型映射為一個更可讀的名稱:

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

read.rdata 中最有趣的一部分可能就是這一行 buf.read(length).unpack(&apos;C*&apos;).join(&apos;.&apos;) —— 像是在說:「嘿!一個 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(&apos;0.0.0.0&apos;, 12345)
  sock.connect(&apos;8.8.8.8&apos;, 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

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國