ペンテストに便利な DNS Echo サーバーを 99 行で作る

サイバーディフェンス研究所 アドベントカレンダー 2022 の 12 日目(8 記事目)です。

クエリされた内容に応じてレスポンスを動的に作って返す、ほろ苦い DNS サーバープログラムを作っていきます。

前菜🥗

なぜそんなものを作りたくなるのかよくわからない。という方向けのちょっと長い解説をします。 想像がつく方はソースコードに進んでください。

DNS は Out-of-band application security testing (OAST) テスト手法の重要なファクターとして利用されています。 OAST はセキュリティテスト対象のホストから外部に向かう通信を誘発させるさせることで脆弱性を検出するテスト手法です。 昨年の今頃大きな騒ぎとなった Apache Log4j 脆弱性のスキャンにも一役買いました。

OAST はとりわけ OWASP Top 10 に挙がる程メジャーな脆弱性となった Server-Side Request Forgery (SSRF) と関連が深く、脆弱性の検出や悪用、対策のバイパスに DNS の活用が欠かせません。

当社では OAST 手法のテスト実施のため、プライベートな Burp Collaborator と Interactsh を運用しています(パブリックサーバーのように悪用されないよう気をつけつつ)。

このあたりの話題を掘り下げていくのも一興ですが、話題を自作 DNS サーバーに戻します。 当社では Burp Collaborator と Interactsh のような機能(使用ポート番号)の重複したツールを同居させるなどの目的で、次の機能を持った DNS サーバープログラムを開発・運用しています。

  1. 権威 DNS サーバー機能
    • ゾーンファイルに従ったレスポンスを返す通常の権威 DNS サーバー
    • この際の詳細なログが OAST 手法のテストに有用
  2. 転送機能
    • 特定ドメインのクエリを Burp Collaborator と Interactsh へ転送
  3. 動的なレコード制御
    • Let's Encrypt の DNS-01 チャレンジでドメインを検証し、ワイルドカード証明書を発行
  4. エコー機能
    • 今回の内容

だんだん本題に近づいて来ました。 近年メジャー化した SSRF 脆弱性の対策として通信先が内部ネットワークの IP ではないかと検証(フィルター)されることがあります。 このフィルターのバイパスに DNS が一役買うことがあります。 例えば http://127.0.0.1 は内部ネットワークに対する攻撃としてフィルターされてしまうけど、http://localhost.example.com というペイロードを投げて DNS による名前解決時に localhost.example.com に対して 127.0.0.1 を返すとフィルターされず内部ネットワークに対して通信できる、といった具合です。 この手の SSRF に関するテクニックは検索するとたくさん出てくるので、興味がわいた方はそちらも研究頂くと楽しいのではないかと思います。

SSRF で通信させたくなる 169.254.169.254 に代表される特別な IP アドレスは調べ始めるといくつもあり、全て調べあげてゾーンファイルに書いておくのはとても面倒だ。。。ということで生まれたのが、今回ご紹介する DNS エコーです。

ソースコード🍝

DNS エコー機能を実装した Go 言語のプログラムです。 コピーして echo.go という名前で保存してください。

コメントに解説を書きながら 99 行に収まってしまうのはひとえに偉大なライブラリーのおかげです 🙏

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package main

import (
	"log"
	"net"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"github.com/miekg/dns"
)

var domain = "echo.example.com"
var addr = ":5353"

// panic時にプロセスを終了させずにログ出力するハンドラー
func handlePanic(w dns.ResponseWriter, r *dns.Msg) {
	if rcv := recover(); rcv != nil {
		log.Println("[ERR] panic", rcv, w, r)
	}
}

// クエリで指定されたサブドメインをレコードとして応答(エコー機能)するクエリハンドラー
func echoHandler(w dns.ResponseWriter, r *dns.Msg) {
	defer handlePanic(w, r)

	// 関数終了時にDNS応答
	m := new(dns.Msg)
	m.SetReply(r)
	m.Compress = false
	defer w.WriteMsg(m)

	for _, q := range r.Question {
		// サブドメイン部分のみ取り出して、レスポンスのリソースデータとして扱う
		dataLen := len(q.Name) - len(domain) - 2
		if dataLen < 1 {
			continue
		}
		// その際、簡易なフィルターバイパス用に「-」を「.」に、「_」を「:」に置換しておく
		data := strings.NewReplacer("-", ".", "_", ":").Replace(q.Name[:dataLen])

		// この時点で応答できるかは未定だが、応答しようとしている内容をログ出力
		log.Printf("[INFO] query: remote=%s/%s name=%s class=%s type=%s data=%s\n",
			w.RemoteAddr().String(), w.RemoteAddr().Network(),
			q.Name, dns.ClassToString[q.Qclass], dns.TypeToString[q.Qtype], data)

		// レスポンスの共通ヘッダー
		// クエリの内容をそのまま使用し、TTLは0固定でキャッシュさせない
		rr_header := dns.RR_Header{Name: q.Name, Rrtype: q.Qtype, Class: q.Qclass, Ttl: 0}

		// NewRRやNewZoneParserを使うとほとんどあらゆるレコードに対応可能になるが、
		// 同時にCNAMEによるSubdomain Takeoverなどリスクを負うことになるので必要なものだけ追加する
		switch q.Qclass {
		case dns.ClassINET:
			switch q.Qtype {
			case dns.TypeA: // Aレコード + IPv4アドレス
				if ip := net.ParseIP(data); ip != nil && ip.To4() != nil {
					m.Answer = append(m.Answer, &dns.A{Hdr: rr_header, A: ip})
				} else {
					log.Printf("[WARN] invalid A record data: %s\n", data)
				}
			case dns.TypeAAAA: // AAAAレコード + IPv6アドレス
				if ip := net.ParseIP(data); ip != nil && ip.To16() != nil {
					m.Answer = append(m.Answer, &dns.AAAA{Hdr: rr_header, AAAA: ip})
				} else {
					log.Printf("[WARN] invalid AAAA record data: %s\n", data)
				}
			}
		}
	}
}

// 指定ネットワークでDNSサーバー処理を実行
func serveDNS(server *dns.Server) {
	if err := server.ListenAndServe(); err != nil {
		log.Fatal("Failed to start server: ", err)
	}
}

func main() {
	// DNSクエリハンドラーを登録
	dns.HandleFunc(domain, echoHandler)

	// UDP でリッスン開始(go ルーチン)
	udpSrv := &dns.Server{Addr: addr, Net: "udp"}
	defer udpSrv.Shutdown()
	go serveDNS(udpSrv)

	// TCP でリッスン開始(go ルーチン)
	tcpSrv := &dns.Server{Addr: addr, Net: "tcp"}
	defer tcpSrv.Shutdown()
	go serveDNS(tcpSrv)

	// シグナルを受信したら終了
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
	<-quit
}

いざ、実食🍴

簡単化のため Go module は無効化して実行します(わかる人はよしなにお願いします)。

必要なパッケージを取得します。

$ GO111MODULE=off go get github.com/miekg/dns

実行(ビルド)します。

$ GO111MODULE=off go run echo.go

こういう小さなツールでは、引数をパースする処理をサボりたいし、いちいちプログラムを書き換えるのも面倒ですよね。 実行(ビルド)時に変数を書き換えるという Tips とともに実行します。

$ GO111MODULE=off go run -ldflags '-X "main.domain=echo.example.jp" -X "main.addr=127.0.0.1:8053"' echo.go

プログラムにハードコードされていた情報を書き換えて、IP=127.0.0.1、port=8053、domain=echo.example.jp で待ち受ける DNS エコーサーバーが起動しました。 dig コマンドでいろいろ問い合わせてみます。

# 127.128.129.130 の A レコード
$ dig @127.0.0.1 -p 8053 +short 127.128.129.130.echo.example.jp A
127.128.129.130
$ dig @127.0.0.1 -p 8053 +short 127-128-129-130.echo.example.jp A
127.128.129.130

# ::1 の AAAA レコード
$ dig @127.0.0.1 -p 8053 +short ::1.echo.example.jp AAAA
::1
$ dig @127.0.0.1 -p 8053 +short __1.echo.example.jp AAAA
::1

指定した文字列がそのままレスポンスに返るような動作をしていることがわかります。 サーバー側にはこのようなログが出力されます。

2022/12/xx 16:25:07 [INFO] query: remote=127.0.0.1:32783/udp name=127.128.129.130.echo.example.jp. class=IN type=A data=127.128.129.130
2022/12/xx 16:25:10 [INFO] query: remote=127.0.0.1:57443/udp name=127-128-129-130.echo.example.jp. class=IN type=A data=127.128.129.130
2022/12/xx 16:25:13 [INFO] query: remote=127.0.0.1:49927/udp name=::1.echo.example.jp. class=IN type=AAAA data=::1
2022/12/xx 16:25:16 [INFO] query: remote=127.0.0.1:52142/udp name=__1.echo.example.jp. class=IN type=AAAA data=::1

類似のサービス🍻

今回の記事を書きながら調べていると、DNS Echo と勝手に名付けたこの子と同様の機能を提供するサービスが wildcard DNS service という名前で知られていたらしいことに気づきました。

https://moss.sh/free-wildcard-dns-services/

そうと知らずにアドベントカレンダーに予約したリサーチ不足をなげきました。 しかしよく見てみると既存のサービスは PowerDNS のバックエンドを必要としていたり、ソースコードが公開されているものと見比べると実装言語のパフォーマンスや安全性が期待できたり、なにより 99 行という手軽さです。 むしろ優勝候補?と思ったのでそのまま公開することにしました。

Burp Collaborator や Interactsh もそうですが、我々ペンテスターはセキュリティテストにパブリックなサービスを利用するのは難しいという事情もあります。

ごちそうさまでした🙏

DNS Echo サーバーを簡単に作る方法を紹介しました。 不正利用される可能性もあるので、間違っても重要なドメインで使わないでください。 実用を検討する際はもう少しコネコネしてみてください。

P.S. 写真は昨年クックパッドを見ながら作ったザッハトルテです🍰。今年のクリスマスは何を作ろうか思案中です。

© 2016 - 2024 DARK MATTER / Built with Hugo / テーマ StackJimmy によって設計されています。