Go言語で自作するDNSパケットパーサー
前回の記事ではDNSパケットの構造についてバイナリレベルで解説しました。
今回はその内容をGo言語でパケットパーサーとして実装していきます。
入出力バッファの実装
まずは入出力に使用するバッファを実装しましょう。
以下のように定義できます。
type BytePacketBuffer struct {
buf [512]byte // DNSのパケットサイズは最長512バイトのため、512バイト長のbufferを定義
pos uint16 // 入出力を行っている位置
}
// 初期化関数
func NewBytePacketBuffer() *BytePacketBuffer {
return &BytePacketBuffer{}
}
// posを返す
func (b *BytePacketBuffer) Pos() uint16 {
return b.pos
}
// posをsteps分進める
func (b *BytePacketBuffer) Step(steps uint16) error {
b.pos += steps
return nil
}
// posを指定した位置に変更
func (b *BytePacketBuffer) Seek(pos uint16) error {
b.pos = pos
return nil
}
また、バッファからデータを読み込む関数を実装します。
// バッファからデータを1バイト分読み込み、posを1バイト分進める
func (b *BytePacketBuffer) Read() (byte, error) {
if b.pos >= 512 {
return 0, errors.New("End of buffer")
}
res := b.buf[b.pos]
b.pos++
return res, nil
}
// 指定した位置のデータを取得
func (b *BytePacketBuffer) Get(pos uint16) (byte, error) {
if pos >= 512 {
return 0, errors.New("End of buffer")
}
return b.buf[pos], nil
}
// 指定した範囲のデータを取得
func (b *BytePacketBuffer) GetRange(start, length uint16) ([]byte, error) {
if start+length >= 512 {
return nil, errors.New("End of buffer")
}
return b.buf[start : start+length], nil
}
// 2バイト分のデータをバッファから取得する
func (b *BytePacketBuffer) ReadU16() (uint16, error) {
high, err := b.Read()
if err != nil {
return 0, err
}
low, err := b.Read()
if err != nil {
return 0, err
}
return uint16(high)<<8 | uint16(low), nil
}
// 4バイト分のデータをバッファから取得する
func (b *BytePacketBuffer) ReadU32() (uint32, error) {
b1, err := b.Read()
if err != nil {
return 0, err
}
b2, err := b.Read()
if err != nil {
return 0, err
}
b3, err := b.Read()
if err != nil {
return 0, err
}
b4, err := b.Read()
if err != nil {
return 0, err
}
return (uint32(b1) << 24) | (uint32(b2) << 16) | (uint32(b3) << 8) | uint32(b4), nil
}
バッファからドメイン名を読み込む関数も実装します。
ドメイン名は圧縮されている場合とされていない場合の2パターンの読み込み方が存在します。
圧縮されているかどうかはドメイン名の部分の先頭2bitを見て判断します。これが2進数で11
であれば、圧縮されています。11
以外であれば圧縮されていません。
google.comのドメインを例に説明します。
圧縮されていなければ以下のように「.」区切りで格納され、各領域のサイズが領域の先頭に格納されます。
圧縮されていれば以下のように格納されます。
2バイトで格納されます。オフセットを読み取ったあとはオフセットの位置にジャンプし、ジャンプ後は上記の圧縮されていない場合と同様に読み込みます。
フラグの部分が先ほど説明した圧縮されているかどうかを表す2bitの領域になります。
詳しくはこの記事を参照してください
ドメイン名読み込みの関数(ReadQName)の全体は以下のとおりです。
func (b *BytePacketBuffer) ReadQName(outstr *string) error {
pos := b.Pos()
jumped := false
maxJumps := 5
jumpsPerformed := 0
delim := ""
for {
if jumpsPerformed > maxJumps {
return errors.New("Limit of 5 jumps exceeded")
}
// 先頭に格納されている長さを取得する
lenByte, err := b.Get(pos)
if err != nil {
return err
}
// 先頭2bitが`11`であるかどうかを確認する
if lenByte & 0xC0 == 0xC0 {
if !jumped {
b.Seek(pos + 2)
}
b2, err := b.Get(pos + 1)
if err != nil {
return err
}
// 0xC0がフラグになっていてそれ以外のビットがoffsetになっている
offset := ((uint16(lenByte) ^ 0xC0) << 8) | uint16(b2)
pos = uint16(offset)
jumped = true
jumpsPerformed++
continue
} else {
pos++
if lenByte == 0 {
break
}
*outstr += delim
strBuffer, err := b.GetRange(pos, uint16(lenByte))
if err != nil {
return err
}
*outstr += string(strBuffer)
delim = "."
pos += uint16(lenByte)
}
}
if !jumped {
b.Seek(pos)
}
return nil
}
複雑なので一つずつ解説していきます。
まずは先頭のバイトを取り出します。
ドメイン名が圧縮されていなければ、これがドットで区切られた領域の長さに対応します。
圧縮されていればフラグとオフセットを表します。
// 先頭に格納されている「ドットで区切られた領域の長さ」もしくは「圧縮形式のフラグとオフセット」を取得する
lenByte, err := b.Get(pos)
if err != nil {
return err
}
続いてこの先頭のバイトの上位2ビットが11
であるかどうか(圧縮されているかどうか)を確認します。
0xC0 = 0b11000000
であり、0xC0との&をとることで先頭2bitが11
であることを確認できます。
圧縮されていれば残りのオフセットを取り出して、パケットのオフセットの位置にあるドメイン名を取り出します。
// 先頭2bitが`11`である(ドメイン名が圧縮されている)かどうかを確認する
if lenByte & 0xC0 == 0xC0 {
if !jumped {
b.Seek(pos + 2)
}
// 2バイト目を取得する
b2, err := b.Get(pos + 1)
if err != nil {
return err
}
// 1バイト目の末尾6bitと2バイト目からオフセットを計算する
offset := ((uint16(lenByte) ^ 0xC0) << 8) | uint16(b2)
// 計算したオフセットを次の読み込み位置に設定
pos = uint16(offset)
jumped = true
jumpsPerformed++
continue
} else {
pos++
if lenByte == 0 {
break
}
// デリミタを出力に追加(初回以外)
*outstr += delim
// サイズで指定された分の領域を読み込む
strBuffer, err := b.GetRange(pos, uint16(lenByte))
if err != nil {
return err
}
// 読み込んだ領域を結果に追加する
*outstr += string(strBuffer)
// 2回目以降はデリミタとして「.」を追加する
delim = "."
// 次の領域に移動する
pos += uint16(lenByte)
}
DNSヘッダーパーサーの実装
特に難しい部分はありません。
以下のDNSヘッダーセクションの構造に従って1バイトずつ読み込みましょう。
package main
// DNSのヘッダー構造を定義
type DnsHeader struct {
ID uint16
RecursionDesired bool
TruncatedMessage bool
AuthoritativeAnswer bool
Opcode uint8
Response bool
ResCode ResultCode
CheckingDisabled bool
AuthedData bool
Z bool
RecursionAvailable bool
Questions uint16
Answers uint16
AuthoritativeEntries uint16
ResourceEntries uint16
}
func NewDnsHeader() *DnsHeader {
return &DnsHeader{}
}
// ヘッダーの読み込み
func (h *DnsHeader) Read(buffer *BytePacketBuffer) error {
var err error
h.ID, err = buffer.ReadU16()
if err != nil {
return err
}
flags, err := buffer.ReadU16()
if err != nil {
return err
}
a := byte(flags >> 8)
b := byte(flags & 0xFF)
h.RecursionDesired = (a & (1 << 0)) > 0
h.TruncatedMessage = (a & (1 << 1)) > 0
h.AuthoritativeAnswer = (a & (1 << 2)) > 0
h.Opcode = (a >> 3) & 0x0F
h.Response = (a & (1 << 7)) > 0
h.ResCode = ResultCode(b & 0x0F)
h.CheckingDisabled = (b & (1 << 4)) > 0
h.AuthedData = (b & (1 << 5)) > 0
h.Z = (b & (1 << 6)) > 0
h.RecursionAvailable = (b & (1 << 7)) > 0
h.Questions, err = buffer.ReadU16()
if err != nil {
return err
}
h.Answers, err = buffer.ReadU16()
if err != nil {
return err
}
h.AuthoritativeEntries, err = buffer.ReadU16()
if err != nil {
return err
}
h.ResourceEntries, err = buffer.ReadU16()
if err != nil {
return err
}
return nil
}
Questionセクションパーサーの実装
特に難しい部分はありません。
以下の構造に従ってパーサーを実装します。
フィールド名 | サイズ | 説明 |
---|---|---|
Name | 可変サイズ | ドメイン名 (下記の方法でエンコードされる) |
Type | 2バイト | レコードタイプ |
Class | 2バイト | クラス、通常のネットワーク(インターネット)では1にセットされる |
// Question構造体の定義
type DnsQuestion struct {
Name string
QType QueryType
}
func NewDnsQuestion(name string, qType QueryType) *DnsQuestion {
return &DnsQuestion{
Name: name,
QType: qType,
}
}
// Questionセクションを読み込む
func (q *DnsQuestion) Read(buffer *BytePacketBuffer) error {
// ドメイン名の読み込み
if err := buffer.ReadQName(&q.Name); err != nil {
return err
}
// クエリタイプ
qTypeNum, err := buffer.ReadU16()
if err != nil {
return err
}
q.QType = QueryTypeFromNum(qTypeNum)
// クラス (使用しないので読み飛ばす)
_, err = buffer.ReadU16()
if err != nil {
return err
}
return nil
}
DNSレコードパーサーの実装
今回はAレコードのみに対応します。
Aレコードの構造は以下のとおりです。
フィールド名 | サイズ | 説明 |
---|---|---|
Preamble | 可変サイズ | レコードのプリアンブル。詳細は以下で説明。 |
IP | 4バイト | 4バイトでエンコードされたIPアドレス |
プリアンブル(Preamble)は全てのDNSレコードで共通の構造です。以下のようになっています。
フィールド名 | サイズ | 説明 |
---|---|---|
Name | 可変サイズ | ドメイン名 (下記の方法でエンコードされる) |
Type | 2バイト | レコードタイプ |
Class | 2バイト | クラス、通常は1にセットされる |
TTL | 4バイト | レコードがどれくらいの期間キャッシュされるか |
Len | 2バイト | レコード長 |
この構造に従ってパース処理を実装します。
まずはプリアンブルを読み込み、レコードタイプに応じて個別のパース処理を行います。
// Aレコード以外のレコード
type UnknownRecord struct {
Domain string
QType uint16
DataLen uint16
TTL uint32
}
// Aレコード
type ARecord struct {
Domain string
Addr net.IP
TTL uint32
}
// DNSレコードをパース
func ReadDnsRecord(buffer *BytePacketBuffer) (DnsRecord, error) {
var domain string
// ドメイン名
if err := buffer.ReadQName(&domain); err != nil {
return nil, err
}
// レコードタイプ
qTypeNum, err := buffer.ReadU16()
if err != nil {
return nil, err
}
// 数値からレコードタイプを取得
qType := QueryTypeFromNum(qTypeNum)
// クラス (使用しないので読み捨てる)
if _, err := buffer.ReadU16(); err != nil {
return nil, err
}
// ttl
ttl, err := buffer.ReadU32()
if err != nil {
return nil, err
}
// レコード長
dataLen, err := buffer.ReadU16()
if err != nil {
return nil, err
}
// クエリタイプに従って、レコードをパースする
switch qType.query_type {
case A:
rawAddr, err := buffer.ReadU32()
if err != nil {
return nil, err
}
addr := net.IPv4(
byte(rawAddr>>24),
byte(rawAddr>>16),
byte(rawAddr>>8),
byte(rawAddr),
)
return &ARecord{
Domain: domain,
Addr: addr,
TTL: ttl,
}, nil
default:
if err := buffer.Step(uint16(dataLen)); err != nil {
return nil, err
}
return &UnknownRecord{
Domain: domain,
QType: qTypeNum,
DataLen: dataLen,
TTL: ttl,
}, nil
}
}
DNSパケットパーサーの実装
それではヘッダーパーサーとQuestionセクションパーサー、レコードパーサーを組み合わせてパケットパーサーを実装していきましょう。
まずはヘッダーを読み込んで、Questionセクション、Answerセクション、Authorityセクション、Additionalセクションのレコード数を取得し、その数だけレコードパーサーを実行するという流れになります。
コードは以下のとおりです。
// DNSパケットの構造体を定義
type DnsPacket struct {
Header *DnsHeader
Questions []*DnsQuestion
Answers []DnsRecord
Authorities []DnsRecord
Resources []DnsRecord
}
func NewDnsPacket() *DnsPacket {
return &DnsPacket{
Header: NewDnsHeader(),
Questions: []*DnsQuestion{},
Answers: []DnsRecord{},
Authorities: []DnsRecord{},
Resources: []DnsRecord{},
}
}
// DNSパケットを読み込む
func ReadDnsPacket(buffer *BytePacketBuffer) (*DnsPacket, error) {
header := NewDnsHeader()
// ヘッダーの読み込み
if err := header.Read(buffer); err != nil {
return nil, err
}
dnsPacket := NewDnsPacket()
dnsPacket.Header = header
// Questionセクションの読み込み
for i := 0; i < int(header.Questions); i++ {
question := &DnsQuestion{}
if err := question.Read(buffer); err != nil {
return nil, err
}
dnsPacket.Questions = append(dnsPacket.Questions, question)
}
// Answerセクションの読み込み
for i := 0; i < int(header.Answers); i++ {
// レコードを読み込む
record, err := ReadDnsRecord(buffer)
if err != nil {
return nil, err
}
dnsPacket.Answers = append(dnsPacket.Answers, record)
}
// Authorityセクションの読み込み
for i := 0; i < int(header.AuthoritativeEntries); i++ {
// レコードを読み込む
record, err := ReadDnsRecord(buffer)
if err != nil {
return nil, err
}
dnsPacket.Authorities = append(dnsPacket.Authorities, record)
}
// Additionalセクションの読み込み
for i := 0; i < int(header.ResourceEntries); i++ {
// レコードを読み込む
record, err := ReadDnsRecord(buffer)
if err != nil {
return nil, err
}
dnsPacket.Resources = append(dnsPacket.Resources, record)
}
return dnsPacket, nil
}
エントリーポイント
上記の処理をまとめて、エントリーポイントを作成します。
パケットパーサーでバイト列をパースし、それぞれのセクションの内容を出力しています。
response.txtはgoogle.comへdigコマンドを実行した際のレスポンスをリダイレクトしたファイルです。
func main() {
file, err := os.Open("response_packet.txt")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer file.Close()
buffer := NewBytePacketBuffer()
_, err = file.Read(buffer.buf[:])
if err != nil {
fmt.Println("Error reading file:", err)
return
}
packet, err := ReadDnsPacket(buffer)
if err != nil {
fmt.Println("Error reading DNS packet:", err)
return
}
pp.Print(packet.Header)
for _, q := range packet.Questions {
pp.Print(q)
}
for _, rec := range packet.Answers {
pp.Print(rec)
}
for _, rec := range packet.Authorities {
pp.Print(rec)
}
for _, rec := range packet.Resources {
pp.Print(rec)
}
}
実行結果は以下のようになります。
DnsHeader{
ID: 16965,
RecursionDesired: true,
TruncatedMessage: false,
AuthoritativeAnswer: false,
Opcode: 0,
Response: true,
ResCode: 0,
CheckingDisabled: false,
AuthedData: false,
Z: false,
RecursionAvailable: true,
Questions: 1,
Answers: 1,
AuthoritativeEntries: 0,
ResourceEntries: 0,
}
DnsQuestion{
Name: "google.com",
QType: A,
}
ARecord{
Domain: "google.com",
Addr: "216.58.211.142",
TTL: 85,
}%
コード
今回作成したコードは下記のリンクから閲覧できます。
参考
Discussion