ksubdomain源碼閱讀

前兩天看了amass關於dns枚舉的實現,當然關於加速dns枚舉的還有ksubdomain這個項目,今天花了幾分鐘看了下實現

閱讀基於 https://github.com/boy-hack/ksubdomain/commit/9a2f2967eb8fb5c155b22393b9241f4cd6a02dc4

分析

首先從入口點開始看 https://github.com/boy-hack/ksubdomain/blob/main/cmd/ksubdomain/enum.go#L55-L109

Action: func(c *cli.Context) error {
		if c.NumFlags() == 0 {
			cli.ShowCommandHelpAndExit(c, "enum", 0)
		}
		var domains []string
		// handle domain
		if c.String("domain") != "" {
			domains = append(domains, c.String("domain"))
		}
		if c.String("domainList") != "" {
			dl, err := core.LinesInFile(c.String("domainList"))
			if err != nil {
				gologger.Fatalf("讀取domain文件失敗:%s\n", err.Error())
			}
			domains = append(dl, domains...)
		}
		levelDict := c.String("level-dict")
		var levelDomains []string
		if levelDict != "" {
			dl, err := core.LinesInFile(levelDict)
			if err != nil {
				gologger.Fatalf("讀取domain文件失敗:%s,請檢查--level-dict參數\n", err.Error())
			}
			levelDomains = dl
		} else if c.Int("level") > 2 {
			levelDomains = core.GetDefaultSubNextData()
		}

		opt := &options.Options{
			Rate:         options.Band2Rate(c.String("band")),
			Domain:       domains,
			FileName:     c.String("filename"),
			Resolvers:    options.GetResolvers(c.String("resolvers")),
			Output:       c.String("output"),
			Silent:       c.Bool("silent"),
			Stdin:        c.Bool("stdin"),
			SkipWildCard: c.Bool("skip-wild"),
			TimeOut:      c.Int("timeout"),
			Retry:        c.Int("retry"),
			Method:       "enum",
			OnlyDomain:   c.Bool("only-domain"),
			NotPrint:     c.Bool("not-print"),
			Level:        c.Int("level"),
			LevelDomains: levelDomains,
		}
		opt.Check()

		r, err := runner.New(opt)
		if err != nil {
			gologger.Fatalf("%s\n", err.Error())
			return nil
		}
		r.RunEnumeration()
		r.Close()
		return nil
	},

具體的實現細節就不關注了,可以看到入口點只是讀取了一些配置,繼續進入 RunEnumeration 看看

func (r *runner) RunEnumeration() {
	ctx, cancel := context.WithCancel(r.ctx)
	defer cancel()
	go r.recvChanel(ctx) // 啓動接收線程
	for i := 0; i < 3; i++ {
		go r.sendCycle(ctx) // 發送線程
	}
	go r.handleResult(ctx) // 處理結果,打印輸出

	var isLoadOver bool = false // 是否加載文件完畢
	t := time.NewTicker(1 * time.Second)
	defer t.Stop()
	for {
		select {
		case <-t.C:
			r.PrintStatus()
			if isLoadOver {
				if r.hm.Length() == 0 {
					gologger.Printf("\n")
					gologger.Infof("掃描完畢")
					return
				}
			}
		case <-r.fisrtloadChanel:
			go r.retry(ctx) // 遍歷hm,依次重試
			isLoadOver = true
		}
	}
}

首先是啓動了一個接收dns resp數據包的協程,然後啓動了三個發送dns req數據包的協程,還有一個線程 handleResult 用來輸出結果

剩下的我們先不關注,可以思考一下,啓動一個發送協程,一個接收協程,一個用來打印結果,單純這三個協程我們肯定是沒法控制整個程序的停止的,因爲接收協程肯定是需要一個死循環去讀取。

所以我們看看下來的控制,isLoadOver 比較關鍵,可以看到它由 fisrtloadChanel 來控制,我們找找它是在哪裏被賦值的

type runner struct {
	ether           *device.EtherTable //本地網卡信息
	hm              *statusdb.StatusDb
	options         *options2.Options
	limit           ratelimit.Limiter
	handle          *pcap.Handle
	successIndex    uint64
	sendIndex       uint64
	recvIndex       uint64
	faildIndex      uint64
	sender          chan string
	recver          chan core.RecvResult
	freeport        int
	dnsid           uint16 // dnsid 用於接收的確定ID
	maxRetry        int    // 最大重試次數
	timeout         int64  // 超時xx秒後重試
	ctx             context.Context
	fisrtloadChanel chan string // 數據加載完畢的chanel
	startTime       time.Time
	domains         []string
}

func New(options *options2.Options) (*runner, error) {
	var err error
	version := pcap.Version()
	r := new(runner)
	gologger.Infof(version + "\n")

	r.options = options
	r.ether = GetDeviceConfig()
	r.hm = statusdb.CreateMemoryDB()

	gologger.Infof("DNS:%s\n", options.Resolvers)
	r.handle, err = device.PcapInit(r.ether.Device)
	if err != nil {
		return nil, err
	}

	// 根據發包總數和timeout時間來分配每秒速度
	allPacket := r.loadTargets()
	if options.Level > 2 {
		allPacket = allPacket * int(math.Pow(float64(len(options.LevelDomains)), float64(options.Level-2)))
	}
	calcLimit := float64(allPacket/options.TimeOut) * 0.85
	if calcLimit < 1000 {
		calcLimit = 1000
	}
	limit := int(math.Min(calcLimit, float64(options.Rate)))
	r.limit = ratelimit.New(limit) // per second

	gologger.Infof("Rate:%dpps\n", limit)

	r.sender = make(chan string, 99)          // 多個協程發送
	r.recver = make(chan core.RecvResult, 99) // 多個協程接收

	freePort, err := freeport.GetFreePort()
	if err != nil {
		return nil, err
	}
	r.freeport = freePort
	gologger.Infof("FreePort:%d\n", freePort)
	r.dnsid = 0x2021 // set dnsid 65500
	r.maxRetry = r.options.Retry
	r.timeout = int64(r.options.TimeOut)
	r.ctx = context.Background()
	r.fisrtloadChanel = make(chan string)
	r.startTime = time.Now()

	go func() {
		for _, msg := range r.domains {
			r.sender <- msg
			if options.Method == "enum" && options.Level > 2 {
				r.iterDomains(options.Level, msg)
			}
		}
		r.domains = nil
		r.fisrtloadChanel <- "ok"
	}()
	return r, nil
}

我們可以看到它是用來在數據全部發往 sender 後的一個標識位,可以看到 New 函數是用來初始化限速器,timeout等等。

然後我們繼續回到之前的代碼看看

case <-r.fisrtloadChanel:
			go r.retry(ctx) // 遍歷hm,依次重試
			isLoadOver = true

可以看到當數據全部發往 sender 後,將會調用retry

func (r *runner) retry(ctx context.Context) {
	for {
		// 循環檢測超時的隊列
		now := time.Now()
		r.hm.Scan(func(key string, v statusdb.Item) error {
			if r.maxRetry > 0 && v.Retry > r.maxRetry {
				r.hm.Del(key)
				atomic.AddUint64(&r.faildIndex, 1)
				return nil
			}
			if int64(now.Sub(v.Time)) >= r.timeout {
				// 重新發送
				r.sender <- key
			}
			return nil
		})
		length := 1000
		time.Sleep(time.Millisecond * time.Duration(length))
	}
}

可以看到具體邏輯是:判斷是否達到最大重試次數,如果沒有就重新入隊去進行dns請求,如果達到最大次數則從緩存中刪除它。

然後繼續往下看,可以看到 isLoadOver = true ,然後可以看

if isLoadOver {
				if r.hm.Length() == 0 {
					gologger.Printf("\n")
					gologger.Infof("掃描完畢")
					return
				}
			}

可以看到當 isLoadOver == true && r.hm.Length() == 0 時,會停止掃描退出。也就是所有的子域名枚舉完成或達到最大重試次數,則退出。

看完了控制邏輯,我們可以看看具體的發送包和接收包的函數了

首先看看發送

func (r *runner) sendCycle(ctx context.Context) {
	for domain := range r.sender {
		r.limit.Take()
		v, ok := r.hm.Get(domain)
		if !ok {
			v = statusdb.Item{
				Domain:      domain,
				Dns:         r.choseDns(),
				Time:        time.Now(),
				Retry:       0,
				DomainLevel: 0,
			}
			r.hm.Add(domain, v)
		} else {
			v.Retry += 1
			v.Time = time.Now()
			v.Dns = r.choseDns()
			r.hm.Set(domain, v)
		}
		send(domain, v.Dns, r.ether, r.dnsid, uint16(r.freeport), r.handle)
		atomic.AddUint64(&r.sendIndex, 1)
	}
}

可以看到首先是發送速率的控制,然後從緩存中獲取生成的子域名,如果沒有代表第一次跑,初始化一個Item丟給send去發包,如果已經存在則重試次數加一,然後重新選擇dns服務器,然後丟給send發包。

具體的發包函數我們就不看了,ksubdomain 是採用的 gopacket 直接構造dns包然後使用網卡發包,目的爲了提速

然後看看接收函數

func (r *runner) recvChanel(ctx context.Context) error {
	...

	parser := gopacket.NewDecodingLayerParser(
		layers.LayerTypeEthernet, &eth, &ipv4, &ipv6, &udp, &dns)

	var data []byte
	var decoded []gopacket.LayerType
	for {
		data, _, err = handle.ReadPacketData()
		if err != nil {
			continue
		}
		err = parser.DecodeLayers(data, &decoded)
		if err != nil {
			continue
		}
		if !dns.QR {
			continue
		}
		if dns.ID != r.dnsid {
			continue
		}
		atomic.AddUint64(&r.recvIndex, 1)
		if len(dns.Questions) == 0 {
			continue
		}
		subdomain := string(dns.Questions[0].Name)
		r.hm.Del(subdomain)
		if dns.ANCount > 0 {
			atomic.AddUint64(&r.successIndex, 1)
			result := core.RecvResult{
				Subdomain: subdomain,
				Answers:   dns.Answers,
			}
			r.recver <- result
		}
	}
}

我摘取了一部分,可以看到具體邏輯就是不斷從網卡中獲取然後解析dns返回包,然後從緩存中刪除該子域名並放入 recver chan,這個chan主要是用來讀取並輸出的。

整體邏輯大體上就是這樣。

總結

整體加速思路其實和amass有點像

amass是使用了一些額外的技巧來達到同步調用,使用單個udp連接來完成,一個協程用來寫,一個用來讀,而ksubdomain直接調用網卡驅動繞過了操作系統,可以突破操作系統的發包限制,會更快一些,amass對於udp到tcp的dns請求做了一些適配

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章