🦔

バイナリを読み解いて学ぶDNSパケットの構造

2023/09/21に公開

最近RustでDNSリゾルバを自作する記事を読んだので、勉強がてらGo言語で書き換えることにしました。
https://github.com/EmilHernvall/dnsguide

本記事ではまずDNSのプロトコルについてバイナリレベルで解説します。次回の記事でGo言語を使用して実際にDNSパケットのパーサーを作成します。

DNSとは

DNSはドメイン名をIPアドレスに名前解決するために使用される通信プロトコルです。

通常、DNSパケットはUDPトランスポートを使用して送信され、512バイトに制限されています。
ただし、TCPで送信することも可能で、eDNSを仕様することでパケットサイズを拡張することもできます。
本記事では従来のUDPによるDNSパケットの送信について解説します。

UDPにおけるDNSパケットの最大長が512バイトである理由

IPv4の仕様では最小パケットサイズとして576バイトと定義されています。 この値は、64バイトのヘッダーと512バイトのデータブロックを格納可能な大きさとして選択されたものです。
このため、UDPにおけるDNSメッセージサイズの最大値を512バイトまでとすることで、通常のDNS通信はIPv4ネットワークにおいて必ず1パケットで送受信可能になります。
DNS仕様策定当時は通信の信頼性が低かったので、DNSを実用的に使用可能にするためにはデータを1パケットに収めることは重要でした。

DNSパケットの構造

DNSは、リクエストとレスポンスが同じ形式を使用します。
DNSパケットは以下のようにHeaderセクション、Questionセクション、Answerセクション、Authorityセクション、Additionalセクションという5つのセクションで構成されます。

セクション サイズ 説明
Header 12バイト リクエストやレスポンスに関する設定
Question 可変サイズ 名前解決するドメイン名とクエリタイプの指定
Answer 可変サイズ クエリタイプに該当するレコード
Authority 可変サイズ 再帰的な名前解決で使用するネームサーバーの一覧(NSレコード)
Additional 可変サイズ NSレコードに対応するAレコード(ネームサーバーのIPアドレス)等の情報

Headerセクション

Headerセクションの構造は以下のようになっています。

参考 DNSパケットフォーマットと、DNSパケットの作り方

各フィールドの説明は以下のとおりです。

名前 サイズ 説明
ID (Packet Identifier) 16bit リクエストに割り当てられるランダムなID。レスポンスはリクエストと同じIDで応答しなければならない。
QR (Query Response) 1bit リクエストは0、レスポンスは1
OPCODE (Operation Code) 4bit 問い合わせの種類を表します。0が通常のクエリ、4がNotify、5がUpdateです。
AA (Authoritative Answer) 1bit 応答するサーバーが権威サーバー(自分自身の所持するDNSレコードを返している)かどうかを表すフラグ。
TC (Truncated Message) 1bit パケットサイズが512バイトを超えるなら1。従来は長さ制限がないTCPでの再問い合わせをするかどうかのヒントとして使用された。
RD (Recursion Desired) 1bit リクエストを受けたサーバーが該当するレコードを所持していない場合、再帰的にな名前解決をすべきかリクエストの送信側が指定するためのフラグ。
RA (Recursion Available) 1bit サーバーが再帰的な名前解決が可能かを提示するフラグ
Z (Reserved) 1bit 将来的な拡張のために利用される領域。
AD (Authentic Data) 1bit DNSSEC検証に成功したことを表すフラグ
CD (Checking Disabled) 1bit DNSSEC検証の禁止を指定するフラグ
RCode (Response Code) 4bit サーバーがレスポンスの状態(成功、失敗など)をクライアントに提示するために使用されるコード
QDCOUNT (Question Count) 16bit Questionセクションに含まれるエントリの数
ANCOUNT (Answer Count) 16bit Answerセクションに含まれるエントリの数
NSCOUNT (Authority Count) 16bit Authorityセクションに含まれるエントリの数
ARCOUNT (Additional Count) 16bit Additionalセクションに含まれるエントリの数

Questionセクション

Questionセクションは以下のような構造となっています。

フィールド名 サイズ 説明
Name 可変サイズ ドメイン名 (下記の方法でエンコードされる)
Type 2バイト レコードタイプ
Class 2バイト クラス、通常のネットワーク(インターネット)では1にセットされる

Answer, Authority, Additionalセクション

Answer, Authority, AdditionalセクションはDNSレコード(Aレコード、NSレコード、CNAMEレコードなど)のリストで構成されます。
すべてのDNSレコードはプリアンブル(Preamble)を先頭に持ちます。

この記事ではAレコードのみを解説します。
Aレコード以外は次回以降の記事で解説します。

Aレコードの構造は以下のとおりです。

フィールド名 サイズ 説明
Preamble 可変サイズ レコードのプリアンブル。詳細は以下で説明。
IP 4バイト 4バイトでエンコードされたIPアドレス

プリアンブル(Preamble)は以下のようになっています。

フィールド名 サイズ 説明
Name 可変サイズ ドメイン名 (下記の方法でエンコードされる)
Type 2バイト レコードタイプ
Class 2バイト クラス、通常は1にセットされる
TTL 4バイト レコードがどれくらいの期間キャッシュされるか
Len 2バイト レコード長

digコマンドの結果から読み解くDNSレスポンスの構造

digコマンドはDNSで名前解決を行うコマンドです。このコマンドでgoogle.comを名前解決します。
EDNSを使用せず、従来のDNSで名前解決を行うため、+noednsをオプションとして設定しています。

dig +noedns google.com

以下のような結果が表示されます。

; <<>> DiG 9.10.3-P4-Ubuntu <<>> +noedns google.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 36383
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;google.com.                    IN      A

;; ANSWER SECTION:
google.com.             204     IN      A       172.217.18.142

;; Query time: 0 msec
;; SERVER: 192.168.1.1#53(192.168.1.1)
;; WHEN: Wed Jul 06 13:24:19 CEST 2016
;; MSG SIZE  rcvd: 44

この結果は上で説明したDNSパケットの構造に準じて表示されています。
結果を詳しく見ていきましょう。
この部分がHeaderセクションを表しています。

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 36383
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

上で説明したHeaderセクションの構造にしたがった情報が返されています。
以下のような対応関係があります。

Headerセクションのフィールド名 digコマンドの表示 意味
OPCODE opcode: QUERY このクエリが通常のクエリであることを表します。
RCODE status: NOERROR エラーがなかったことを表します。
ID id: 36383 クエリのIDが36383であることを表します。IDはクエリごとにランダムに生成されます。
QR, AA, TC, RD, RA, AD, CD flags: qr rd ra ad QR,RD,RA,ADのフラグがセットされていることを表します。つまり、このパケットがレスポンスパケットであり(QR=1)、再帰的な名前解決が要求されていて(RD=1)、この応答が権威DNSサーバーから返されたものであり(RA=1)、DNSSECの検証が成功した(AD=1)ことを表しています。
QCOUNT QUERY: 1 クエリの数が一つであることを表します。
ANCOUNT ANSWER: 1 ANSWERセクションのレコード数が一つであることを表します。
NSCOUNT AUTHORITY: 0 AUTHORITYセクションのレコード数が0であることを表します。
ARCOUNT ADDITIONAL: 0 ADDITIONALセクションのレコード数が0であることを表します。

続いてQUESTIONセクションです。

;; QUESTION SECTION:
;google.com.                    IN      A

google.comに対してAレコードの問い合わせを行っていることを表します。

最後にANSWERセクションです。

;; ANSWER SECTION:
google.com.             204     IN      A       172.217.18.142

google.comのAレコードを返しています。AレコードはドメインをIPv4アドレスと対応付けるレコードです。
google.comのIPv4アドレスが172.217.18.142であることを表します。
204という数字はTTLを表しています。204秒間DNSレコードがキャッシュされます。

DNSパケットをバイナリ単位で読み解く

さきほどのdigコマンドによるDNSパケットをWiresharkでパケットキャプチャしたものが以下になります。

リクエスト

00000000  86 2a 01 20 00 01 00 00  00 00 00 00 06 67 6f 6f  |.*. .........goo|
00000010  67 6c 65 03 63 6f 6d 00  00 01 00 01              |gle.com.....|

レスポンス

00000000  86 2a 81 80 00 01 00 01  00 00 00 00 06 67 6f 6f  |.*...........goo|
00000010  67 6c 65 03 63 6f 6d 00  00 01 00 01 c0 0c 00 01  |gle.com.........|
00000020  00 01 00 00 01 25 00 04  d8 3a d3 8e              |.....%...:..|
0000002c

このバイナリを1バイトずつ読み解いていきましょう。

リクエストパケットの解析

Headerセクション

最初の12バイトはHeaderセクションです。86 2a 01 20 00 01 00 00 00 00 00 00となっています。

上で説明に用いた図を再度参照しながらHeaderセクションを読み解いてみましょう。

参考 DNSパケットフォーマットと、DNSパケットの作り方

Headerセクションの最初の2バイト(86 2a)はIDを表します。

Headerセクションの末尾の8バイトはQDCOUNT、ANCOUNT、NSCOUNT、ARCOUNTを表しており、QDCOUNTのみが00 01で、そのほかは00 00です。
つまりクエリが一つだけあり、ANSWER・AUTHORITY・ADDITIONALセクションにレコードを含まないことを表します。

その間の2バイトは解析がやや複雑です。以下に詳細をbit単位で図示します。

1bitずつ読み解いていきましょう。

  • 最初の1bit目はQRです。QRはリクエストでは0にセットされます。
  • 次の4bitはOPCODEです。通常のクエリであるためOPCODEも0です。
  • その次の3bitはAA, TC, RAフラグです。AA, TC, RAフラグはサーバーがセットするフラグであるためリクエストでは0にセットされています。
  • RDフラグはdigがデフォルトで再帰的な名前解決をするため1にセットされています。
  • Zフラグは現在は使用されていないフィールドであり、0にセットされます。
  • AD,CDはDNSSEC関連のフィールドです。この記事では詳しくは解説しません。
  • 末尾の4bitはRCODEです。RCODEもサーバーが設定する値のため0にセットされています。

Questionセクション

06 67 6f 6f 67 6c 65 03 63 6f 6d 00 00 01 00 01がQuestionセクションです。

末尾の4バイト00 01 00 01がTypeフィールド00 01とClassフィールド00 01を表しています。
Type 1はAレコードであることを意味します。Class 1はIN(インターネット)を意味します。

それ以外の部分06 67 6f 6f 67 6c 65 03 63 6f 6d 00がNameフィールドで名前解決をする対象のドメイン名を表しています。
この部分を以下に図示します。

google.comというドメイン名が「.」区切りで分割されていて、それぞれの分割の先頭にその長さを表すバイトが格納されていることを確認できます。
ドメイン名はNULLバイトで終端されています。

後述しますが、ドメイン名は圧縮されて格納されることもあります。

レスポンスパケットの解析

Headerセクション

Headerセクションに関してはID, QDCOUNT、ANCOUNT、NSCOUNT、ARCOUNTの部分はリクエストと変わりません。

それ以外の部分をリクエストとレスポンスで比較してみましょう。

リクエスト

レスポンス

  • レスポンスなのでQRは0から1になりました。
  • 再帰的な名前解決が可能なサーバーであったため、RAは1にセットされました。
  • DNSSECの検証に成功したためADは1にセットされました。

Questionセクション

Questionセクションはリクエストと変わりありません。

Answerセクション

c0 0c 00 01 00 01 00 00 01 25 00 04 d8 3a d3 8eがAnswerセクションです。
以下に図示します。

各フィールドの意味は以下の通りです。

  • Typeは1でAレコードを意味します。
  • Classは1でINを意味します。
  • TTLは293です。
  • IPの部分はドメイン(google.com)のIPアドレスが216.58.211.142であることを意味します。

Namec0 0cは名前解決の対象のドメイン名(google.com)を表していますが、リクエストのQuestionセクションでgoogle.comを06 67 6f 6f 67 6c 65 03 63 6f 6d 00によって表していたのと比べて明らかに異なっているのがわかります。

これにはドメイン名の圧縮が関係しています。

ドメイン名の圧縮

DNSはパケットサイズが512バイトに制限されていると前述しました。ただしドメイン名の長さ、レコードの数次第ではドメイン名を圧縮しないと容易にその制限を超えることがあり得ます。

以下にそのような例を示します。

dig @a.root-servers.net com
- 略 -

;; AUTHORITY SECTION:
com.                172800  IN  NS      e.gtld-servers.net.
com.                172800  IN  NS      b.gtld-servers.net.
com.                172800  IN  NS      j.gtld-servers.net.
com.                172800  IN  NS      m.gtld-servers.net.
com.                172800  IN  NS      i.gtld-servers.net.
com.                172800  IN  NS      f.gtld-servers.net.
com.                172800  IN  NS      a.gtld-servers.net.
com.                172800  IN  NS      g.gtld-servers.net.
com.                172800  IN  NS      h.gtld-servers.net.
com.                172800  IN  NS      l.gtld-servers.net.
com.                172800  IN  NS      k.gtld-servers.net.
com.                172800  IN  NS      c.gtld-servers.net.
com.                172800  IN  NS      d.gtld-servers.net.

;; ADDITIONAL SECTION:
e.gtld-servers.net. 172800  IN  A       192.12.94.30
b.gtld-servers.net. 172800  IN  A       192.33.14.30
b.gtld-servers.net. 172800  IN  AAAA    2001:503:231d::2:30
j.gtld-servers.net. 172800  IN  A       192.48.79.30
m.gtld-servers.net. 172800  IN  A       192.55.83.30
i.gtld-servers.net. 172800  IN  A       192.43.172.30
f.gtld-servers.net. 172800  IN  A       192.35.51.30
a.gtld-servers.net. 172800  IN  A       192.5.6.30
a.gtld-servers.net. 172800  IN  AAAA    2001:503:a83e::2:30
g.gtld-servers.net. 172800  IN  A       192.42.93.30
h.gtld-servers.net. 172800  IN  A       192.54.112.30
l.gtld-servers.net. 172800  IN  A       192.41.162.30
k.gtld-servers.net. 172800  IN  A       192.52.178.30
c.gtld-servers.net. 172800  IN  A       192.26.92.30
d.gtld-servers.net. 172800  IN  A       192.31.80.30

- 略 -

上記のコマンドは.comのトップレベルドメインを扱うネームサーバーについて、インターネットのルートサーバーのひとつに問い合わせをしています。

このコマンドの問い合わせ結果は非常に長大であり、圧縮しなければおそらく512バイトの制限を超えるでしょう。

しかし、データの大部分はドメイン名によって占められており、その中にはgtld-servers.net.が何度も現れています。

このようにDNSでは、レコードに共通のドメイン名が含まれることが頻繁にあります。この部分を圧縮することで、データサイズを大幅に削減することが可能です。

圧縮の具体的な方法について以下で解説します。

圧縮方法

すでに圧縮したいドメイン名と同じドメイン名がパケット内に含まれている場合にそのドメイン名に対するオフセットを示すことで圧縮を行います。
オフセットはパケットの先頭から何バイト目にそのドメイン名が格納されているかを表します。

圧縮されたドメイン名は以下のようにパケットに格納されます。

先頭の2bitのフラグが11であればドメイン名が圧縮されていることを表します。

フラグが00であれば、圧縮せずに従来通りのドメイン名を「.」で区切って、各分割の先頭にその長さを表すバイトを格納するという方法でドメイン名が表現されます。

さきほどのレスポンスパケットの例を振り返ります。
ドメイン名はc0 0cとして表現されていました。これを以下に可視化します。

フラグは11であり、ドメイン名が圧縮されています。オフセットは2進数で00000000001100つまり12です。
以下に再度レスポンスパケットのバイナリを示します。

00000000  86 2a 81 80 00 01 00 01  00 00 00 00 06 67 6f 6f  |.*...........goo|
00000010  67 6c 65 03 63 6f 6d 00  00 01 00 01 c0 0c 00 01  |gle.com.........|
00000020  00 01 00 00 01 25 00 04  d8 3a d3 8e              |.....%...:..|
0000002c

レスポンスパケットを12バイト読み飛ばすと06 67 6f 6f 67 6c 65 03 63 6f 6d 00となっており、google.comを表していることが確認できます。

まとめ

DNSパケットの構造についてバイナリレベルで解説しました。
次回の記事ではこの解説をもとにGo言語でDNSパケットパーサーの実装を行います。

参考資料

https://github.com/EmilHernvall/dnsguide
http://park12.wakwak.com/~eslab/pcmemo/dns/dns5.html#condense
https://atmarkit.itmedia.co.jp/ait/articles/1601/29/news014.html

Discussion