🐙

Go言語で自作するDNSパケットパーサー

2023/09/29に公開

前回の記事では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バイトずつ読み込みましょう。

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

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,
}%             

コード

今回作成したコードは下記のリンクから閲覧できます。

https://github.com/gorogoroumaru/godns/tree/6256a564998a92211db156953ba95daf0666205c

参考

https://github.com/EmilHernvall/dnsguide/

Discussion