バイナリを読み解いて学ぶDNSパケットの構造
最近RustでDNSリゾルバを自作する記事を読んだので、勉強がてらGo言語で書き換えることにしました。
本記事ではまず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セクションの構造は以下のようになっています。
各フィールドの説明は以下のとおりです。
名前 | サイズ | 説明 |
---|---|---|
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セクションを読み解いてみましょう。
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パケットパーサーの実装を行います。
参考資料
Discussion