├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── README_en.md ├── cmd └── sf │ ├── banner.go │ └── sf.go ├── go.mod ├── go.sum └── internal ├── conf └── conf.go ├── engine ├── checker.go ├── engine.go ├── recorder.go └── resolver.go └── module ├── axfr.go ├── module.go └── wordlist.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: "stable" 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v4 25 | with: 26 | distribution: goreleaser 27 | version: latest 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pprof 2 | .DS_Store 3 | ._* 4 | *.txt 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | goarch: 13 | - amd64 14 | - 386 15 | - arm64 16 | ignore: 17 | - goos: darwin 18 | goarch: "386" 19 | binary: "{{ .ProjectName }}" 20 | ldflags: 21 | - -s -w -X main.Version={{.Version}} -X main.Branch={{.Branch}} -X main.Commit={{.ShortCommit}} 22 | main: ./cmd/sf 23 | 24 | archives: 25 | - format: zip 26 | 27 | checksum: 28 | algorithm: sha256 29 | name_template: "checksums.txt" 30 | 31 | snapshot: 32 | name_template: "{{ incpatch .Version }}-next" 33 | 34 | changelog: 35 | use: github 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rook1e 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SF 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/0x2E/sf)](https://goreportcard.com/report/github.com/0x2E/sf) 4 | [![go version](https://img.shields.io/github/go-mod/go-version/0x2E/sf)](https://github.com/0x2E/sf/blob/main/go.mod) 5 | 6 | 中文/[English](https://github.com/0x2E/sf/blob/main/README_en.md) 7 | 8 | SF 是一个高效的子域名爆破工具: 9 | 10 | - 基于 UDP 的无连接特性并行收发 DNS 请求 11 | - 支持**自定义爆破点**,使用占位符 `%` 设置 12 | - 支持基于 `*` 记录的**泛解析域名检测** 13 | - 支持秒级限流和失败重试 14 | - 支持检测域传送漏洞 15 | 16 | ## 安装 17 | 18 | 1. [Release](https://github.com/0x2E/sf/releases) 19 | 2. 编译源码 20 | 21 | ```shell 22 | git clone https://github.com/0x2E/sf.git 23 | cd sf 24 | go build -o sf ./cmd/sf 25 | ``` 26 | -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | # SF 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/0x2E/sf)](https://goreportcard.com/report/github.com/0x2E/sf) 4 | [![go version](https://img.shields.io/github/go-mod/go-version/0x2E/sf)](https://github.com/0x2E/sf/blob/main/go.mod) 5 | 6 | [中文](https://github.com/0x2E/sf/blob/main/README.md)/English 7 | 8 | SF is an efficient subdomain brute-forcing tool: 9 | 10 | - parallelizes sending and receiving DNS requests based on the connectionless feature of UDP 11 | - supports **custom brute-forcing points** using placeholder `%` 12 | - supports **detecting wildcard record** based on `*` records 13 | - supports second-level rate limiting and retrying on failures 14 | - supports detecting zone-transfer vulnerabilities 15 | 16 | ## Installation 17 | 18 | 1. [Release](https://github.com/0x2E/sf/releases) 19 | 2. compile 20 | 21 | ```shell 22 | git clone https://github.com/0x2E/sf.git 23 | cd sf 24 | go build -o sf ./cmd/sf 25 | ``` 26 | -------------------------------------------------------------------------------- /cmd/sf/banner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const banner = ` 4 | .-'''-. ________ 5 | / _ \ | | 6 | ( ' )/---' | .----' 7 | (_ o _). | _|____ 8 | (_,_). \ |_( )_ | 9 | .---. \ | (_ o._)__| 10 | \ \ - . |(_,_) 11 | \ / | | 12 | -...-' '---' 13 | ` 14 | -------------------------------------------------------------------------------- /cmd/sf/sf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/http" 7 | _ "net/http/pprof" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | flag "github.com/spf13/pflag" 13 | 14 | "github.com/0x2E/sf/internal/conf" 15 | "github.com/0x2E/sf/internal/engine" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | var Version, Branch, Commit string 20 | 21 | func main() { 22 | var ( 23 | c = conf.C 24 | output string 25 | disableCheck bool 26 | silent bool 27 | debug bool 28 | showHelp bool 29 | showVersion bool 30 | ) 31 | flag.StringVarP(&c.RawTarget, "domain", "d", "", `Target domain name. 32 | If the placeholder % exists, only replaces the placeholder instead of splicing wordlist as subdomain`) 33 | flag.StringVarP(&c.Wordlist, "wordlist", "w", "", "Wordlist file") 34 | flag.StringVarP(&c.Resolver, "resolver", "r", "8.8.8.8", "DNS resolver") 35 | flag.StringVarP(&output, "output", "o", "", "Output results to a file") 36 | flag.IntVarP(&c.Concurrent, "concurrent", "t", 800, "Number of concurrent") 37 | flag.IntVar(&c.Rate, "rate", 30000, `Maximum rate req/s. 38 | It is recommended to determine if the rate is appropriate by the send/recv statistics in log`) 39 | flag.IntVar(&c.Retry, "retry", 1, "Number of retries") 40 | flag.IntVarP(&c.StatisticsInterval, "stats", "s", 2, "Statistics interval(seconds) in log") 41 | flag.BoolVar(&disableCheck, "disable-check", false, "Disable check the validity of the subdomains") 42 | flag.BoolVar(&silent, "silent", false, "Only output valid subdomains, and logs that caused abnormal exit, e.g., fatal and panic") 43 | flag.BoolVar(&debug, "debug", false, "Set the log level to debug, and enable golang pprof with web service") 44 | flag.BoolVarP(&showVersion, "version", "v", false, "Show version") 45 | flag.BoolVarP(&showHelp, "help", "h", false, "Show help message") 46 | flag.CommandLine.SortFlags = false 47 | flag.Parse() 48 | 49 | if showHelp { 50 | flag.Usage() 51 | os.Exit(0) 52 | } 53 | 54 | if showVersion { 55 | version() 56 | os.Exit(0) 57 | } 58 | 59 | logrus.SetFormatter(&logrus.TextFormatter{ 60 | TimestampFormat: "20060102 15:04:05", 61 | FullTimestamp: true, 62 | }) 63 | 64 | if silent { 65 | if debug { 66 | logrus.Fatal("cannot enable 'debug' and 'silent' at the same time") 67 | } 68 | logrus.SetLevel(logrus.FatalLevel) 69 | } else { 70 | fmt.Print(banner) 71 | version() 72 | } 73 | 74 | if debug { 75 | logrus.SetLevel(logrus.DebugLevel) 76 | go pprof() 77 | } 78 | 79 | c.ValidCheck = !disableCheck 80 | 81 | if err := c.Verify(); err != nil { 82 | logrus.Fatal(err) 83 | } 84 | 85 | logrus.Infof("target: [%s]. wordlist: [%s]. resolver: [%s]. concurrent: [%d]. rate: [%d]. retry: [%d]. check valid: [%t]", 86 | c.RawTarget, c.Wordlist, c.Resolver, c.Concurrent, c.Rate, c.Retry, c.ValidCheck) 87 | 88 | startAt := time.Now() 89 | res := engine.New().Run() 90 | 91 | logrus.Infof("found %d subdomains. time: %.2f seconds.\n", len(res), time.Since(startAt).Seconds()) 92 | 93 | saveResult(output, res) 94 | } 95 | 96 | func saveResult(path string, data []string) { 97 | if strings.TrimSpace(path) == "" || len(data) == 0 { 98 | return 99 | } 100 | 101 | f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0o755) 102 | if err != nil { 103 | logrus.Error("cannot save results:", err) 104 | return 105 | } 106 | defer f.Close() 107 | bufWriter := bufio.NewWriter(f) 108 | for _, v := range data { 109 | bufWriter.WriteString(v + "\n") 110 | } 111 | bufWriter.Flush() 112 | } 113 | 114 | func pprof() { 115 | logrus.Debug("pprof is on 127.0.0.1:10000/debug/pprof") 116 | if err := http.ListenAndServe("127.0.0.1:10000", nil); err != nil { 117 | logrus.Error(err) 118 | } 119 | } 120 | 121 | func version() { 122 | fmt.Printf("version: %s. branch: %s. commit: %s\n", Version, Branch, Commit) 123 | } 124 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/0x2E/sf 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/miekg/dns v1.1.55 7 | github.com/pkg/errors v0.9.1 8 | github.com/sirupsen/logrus v1.9.3 9 | github.com/spf13/pflag v1.0.5 10 | go.uber.org/ratelimit v0.3.0 11 | ) 12 | 13 | require ( 14 | github.com/benbjohnson/clock v1.3.0 // indirect 15 | golang.org/x/mod v0.7.0 // indirect 16 | golang.org/x/net v0.2.0 // indirect 17 | golang.org/x/sys v0.2.0 // indirect 18 | golang.org/x/tools v0.3.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 2 | github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= 7 | github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= 8 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 9 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 13 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 14 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 15 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 18 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 19 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 20 | go.uber.org/ratelimit v0.3.0 h1:IdZd9wqvFXnvLvSEBo0KPcGfkoBGNkpTHlrE3Rcjkjw= 21 | go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyBaI= 22 | golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= 23 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 24 | golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= 25 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 26 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 27 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= 29 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM= 31 | golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 34 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /internal/conf/conf.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/miekg/dns" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | const ( 12 | TestDN = "github.com" 13 | Placeholder = "%" 14 | ) 15 | 16 | var C = &Config{} 17 | 18 | type Config struct { 19 | // Target is the RawTarget that trimmed subdomains which contains placeholder 20 | Target string 21 | // RawTarget is the user original input 22 | RawTarget string 23 | Wordlist string 24 | Resolver string 25 | Concurrent int 26 | Rate int 27 | Retry int 28 | StatisticsInterval int 29 | ValidCheck bool 30 | } 31 | 32 | // Verify checks if the args is valid 33 | func (c *Config) Verify() error { 34 | c.Target = c.RawTarget 35 | // trim subdomains that contains placeholder 36 | dn := c.Target 37 | lastPh := strings.LastIndex(dn, Placeholder) 38 | if lastPh > 0 { 39 | dn = dn[lastPh+1:] 40 | dot := strings.Index(dn, ".") 41 | dn = dn[dot+1:] 42 | } 43 | c.Target = dn 44 | if _, ok := dns.IsDomainName(c.Target); !ok { 45 | return errors.New("invalid domain name: " + c.Target) 46 | } 47 | c.Target = dns.Fqdn(c.Target) 48 | c.RawTarget = dns.Fqdn(c.RawTarget) 49 | 50 | if c.Wordlist != "" { 51 | f, err := os.Open(c.Wordlist) 52 | if err != nil { 53 | return errors.Wrap(err, "open wordlist file") 54 | } 55 | f.Close() 56 | } 57 | 58 | if strings.Index(c.Resolver, ":") == -1 { 59 | // TODO: TCP/DoT/DoH 60 | c.Resolver = c.Resolver + ":53" 61 | } 62 | m := &dns.Msg{} 63 | m.SetQuestion(dns.Fqdn(TestDN), dns.TypeA) 64 | if _, err := dns.Exchange(m, c.Resolver); err != nil { 65 | return errors.Wrap(err, "resolver may be invalid") 66 | } 67 | 68 | if c.Concurrent < 1 { 69 | return errors.New("'concurrent' should be greater than 1") 70 | } 71 | 72 | if c.Retry < 0 { 73 | return errors.New("'retry' should be greater than 0") 74 | } 75 | 76 | if c.Rate < 1 { 77 | return errors.New("'rate' should be greater than 1") 78 | } 79 | 80 | if c.StatisticsInterval < 1 { 81 | return errors.New("'stats' should be greater than 1") 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/engine/checker.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/0x2E/sf/internal/conf" 7 | "github.com/miekg/dns" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // existWildcard checks if there is a wildcard record 12 | func (e *Engine) existWildcard() bool { 13 | m := &dns.Msg{} 14 | m.SetQuestion("*."+conf.C.Target, dns.TypeA) 15 | resp, err := dns.Exchange(m, conf.C.Resolver) 16 | if err != nil || resp.Rcode != dns.RcodeSuccess || len(resp.Answer) == 0 { 17 | return false 18 | } 19 | 20 | e.wildcardRecord = resp.Answer[0] 21 | logrus.Debug("found wildcard record: " + e.wildcardRecord.String()) 22 | e.wildcardRecord.Header().Name = "" // for easier comparison 23 | return true 24 | } 25 | 26 | // checker checks if domain is valid: 27 | // 28 | // 1. not wildcard record 29 | func (e *Engine) checker(wg *sync.WaitGroup) { 30 | defer func() { 31 | close(e.toRecorder) 32 | wg.Done() 33 | }() 34 | 35 | for t := range e.toChecker { 36 | t.Record.Header().Name = "" // for easier comparison 37 | 38 | if dns.IsDuplicate(t.Record, e.wildcardRecord) { 39 | continue 40 | } 41 | 42 | e.toRecorder <- t 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/engine/engine.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/0x2E/sf/internal/conf" 9 | "github.com/0x2E/sf/internal/module" 10 | "github.com/miekg/dns" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const ( 15 | QueueMaxLen = 1000 16 | ) 17 | 18 | var ( 19 | NetTimeout = 3 * time.Second 20 | ) 21 | 22 | type Engine struct { 23 | needCheck bool 24 | // wildcardRecord is the wildcard record (`*.example.com`) 25 | wildcardRecord dns.RR 26 | toResolver chan *module.Task 27 | toChecker chan *module.Task 28 | toRecorder chan *module.Task 29 | results []string 30 | } 31 | 32 | func New() *Engine { 33 | return &Engine{ 34 | toResolver: make(chan *module.Task, QueueMaxLen), 35 | toChecker: make(chan *module.Task, QueueMaxLen), 36 | toRecorder: make(chan *module.Task, QueueMaxLen), 37 | } 38 | } 39 | 40 | func (e *Engine) Run() []string { 41 | wg := sync.WaitGroup{} 42 | e.needCheck = conf.C.ValidCheck && e.existWildcard() 43 | if e.needCheck { 44 | wg.Add(1) 45 | go e.checker(&wg) 46 | } else { 47 | logrus.Debug("turn off checker") 48 | close(e.toChecker) 49 | } 50 | wg.Add(2) 51 | go e.resolver(&wg) 52 | go e.recorder(&wg) 53 | 54 | wgModules := sync.WaitGroup{} 55 | ctx, cancel := context.WithCancel(context.Background()) 56 | defer cancel() 57 | 58 | wgModules.Add(1) 59 | go func() { 60 | startAt := time.Now() 61 | logger := logrus.WithField("module", "zone-transfer") 62 | defer wgModules.Done() 63 | 64 | if err := module.RunAxfr(ctx, e.toRecorder); err != nil { 65 | logger.Error(err) 66 | } 67 | 68 | logger.Debug("done, time: " + time.Since(startAt).String()) 69 | }() 70 | wgModules.Add(1) 71 | go func() { 72 | startAt := time.Now() 73 | logger := logrus.WithField("module", "wordlist") 74 | defer wgModules.Done() 75 | 76 | if err := module.RunWordlist(ctx, e.toResolver); err != nil { 77 | logger.Error(err) 78 | } 79 | 80 | logger.Debug("done, time: " + time.Since(startAt).String()) 81 | }() 82 | 83 | wgModules.Wait() 84 | logrus.Debug("all modules done") 85 | 86 | close(e.toResolver) 87 | wg.Wait() 88 | 89 | return e.results 90 | } 91 | -------------------------------------------------------------------------------- /internal/engine/recorder.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | func (e *Engine) recorder(wg *sync.WaitGroup) { 9 | defer wg.Done() 10 | 11 | res := make(map[string]struct{}) 12 | for t := range e.toRecorder { 13 | subdomain := t.DomainName[:len(t.DomainName)-1] 14 | fmt.Println(subdomain) 15 | res[subdomain] = struct{}{} 16 | } 17 | 18 | e.results = make([]string, 0, len(res)) 19 | for d := range res { 20 | e.results = append(e.results, d) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/engine/resolver.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/0x2E/sf/internal/conf" 11 | "github.com/0x2E/sf/internal/module" 12 | "github.com/miekg/dns" 13 | "github.com/sirupsen/logrus" 14 | "go.uber.org/ratelimit" 15 | ) 16 | 17 | func (e *Engine) resolver(wg *sync.WaitGroup) { 18 | defer wg.Done() 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | defer cancel() 21 | 22 | logger := logrus.WithField("step", "resolver") 23 | 24 | var toNext chan *module.Task 25 | if e.needCheck { 26 | toNext = e.toChecker 27 | } else { 28 | toNext = e.toRecorder 29 | } 30 | defer close(toNext) 31 | 32 | taskMap := &sync.Map{} 33 | toSender := make(chan *module.Task, conf.C.Concurrent) 34 | wgWorker := sync.WaitGroup{} 35 | sendCount := &atomic.Uint64{} 36 | recvCount := &atomic.Uint64{} 37 | 38 | for i := 0; i < conf.C.Concurrent; i++ { 39 | // TODO: dns.DialWithTLS 40 | udpConn, err := net.Dial("udp", conf.C.Resolver) 41 | if err != nil { 42 | logger.Error(err) 43 | continue 44 | } 45 | wgWorker.Add(1) 46 | worker := &resolverWorker{ 47 | conn: &dns.Conn{Conn: udpConn}, 48 | sendCount: sendCount, 49 | recvCount: recvCount, 50 | logger: logger.WithField("concurrent_id", i), 51 | senderDone: false, 52 | } 53 | go worker.sender(toSender) 54 | go worker.receiver(&wgWorker, toNext, taskMap) 55 | } 56 | 57 | go func() { 58 | ticker := time.NewTicker(time.Duration(conf.C.StatisticsInterval) * time.Second) 59 | defer ticker.Stop() 60 | 61 | for { 62 | select { 63 | case <-ticker.C: 64 | logger.Infof("[statistics] send %d, recv %d", sendCount.Load(), recvCount.Load()) 65 | case <-ctx.Done(): 66 | return 67 | } 68 | } 69 | }() 70 | 71 | go func() { 72 | rl := ratelimit.New(conf.C.Rate, ratelimit.WithoutSlack) 73 | for t := range e.toResolver { 74 | if _, ok := taskMap.Load(t.DomainName); ok { 75 | continue 76 | } 77 | rl.Take() 78 | toSender <- t 79 | taskMap.Store(t.DomainName, t) 80 | } 81 | 82 | // retry failed items 83 | for i := 0; i < conf.C.Retry; i++ { 84 | retryList := make([]*module.Task, 0) 85 | taskMap.Range(func(s, t interface{}) bool { 86 | if t, ok := t.(*module.Task); ok { 87 | if t.Received || time.Now().Unix()-t.LastQueryAt < int64(NetTimeout.Seconds()) { 88 | return true 89 | } 90 | retryList = append(retryList, t) 91 | } 92 | return true 93 | }) 94 | if len(retryList) == 0 { 95 | break 96 | } 97 | 98 | logger.Infof("#%d retry: %d items", i+1, len(retryList)) 99 | for _, t := range retryList { 100 | rl.Take() 101 | toSender <- t 102 | } 103 | } 104 | close(toSender) 105 | }() 106 | 107 | wgWorker.Wait() 108 | } 109 | 110 | type resolverWorker struct { 111 | conn *dns.Conn 112 | sendCount *atomic.Uint64 113 | recvCount *atomic.Uint64 114 | senderDone bool 115 | 116 | logger *logrus.Entry 117 | } 118 | 119 | func (w *resolverWorker) sender(toSender <-chan *module.Task) { 120 | defer func() { 121 | w.senderDone = true 122 | }() 123 | 124 | for t := range toSender { 125 | msg := (&dns.Msg{}).SetQuestion(t.DomainName, dns.TypeA) 126 | w.conn.SetWriteDeadline(time.Now().Add(NetTimeout)) 127 | if err := w.conn.WriteMsg(msg); err != nil { 128 | w.logger.Debug(err) 129 | continue 130 | } 131 | t.LastQueryAt = time.Now().Unix() 132 | w.sendCount.Add(1) 133 | } 134 | } 135 | 136 | func (w *resolverWorker) receiver(wg *sync.WaitGroup, toNext chan<- *module.Task, taskMap *sync.Map) { 137 | defer func() { 138 | wg.Done() 139 | w.conn.Close() 140 | }() 141 | 142 | for { 143 | w.conn.SetReadDeadline(time.Now().Add(NetTimeout)) 144 | msg, err := w.conn.ReadMsg() 145 | if err != nil { 146 | if err, ok := err.(net.Error); ok && err.Timeout() { 147 | if w.senderDone { 148 | // if receiver timeout after sender done, we assumed there are no more incoming packets 149 | return 150 | } 151 | continue 152 | } 153 | w.logger.Debug(err) 154 | continue 155 | } 156 | if t, ok := taskMap.Load(msg.Question[0].Name); ok { 157 | if task, ok := t.(*module.Task); ok { 158 | w.recvCount.Add(1) 159 | task.Received = true 160 | if msg.Rcode != dns.RcodeSuccess || len(msg.Answer) == 0 { 161 | continue 162 | } 163 | task.Record = msg.Answer[0] 164 | toNext <- task 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/module/axfr.go: -------------------------------------------------------------------------------- 1 | package module 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/0x2E/sf/internal/conf" 8 | "github.com/miekg/dns" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // RunAxfr is zone transfer module 13 | // 14 | // test: https://digi.ninja/projects/zonetransferme.php 15 | func RunAxfr(ctx context.Context, toNext chan<- *Task) error { 16 | m := new(dns.Msg) 17 | m.SetQuestion(conf.C.Target, dns.TypeNS) 18 | r, err := dns.Exchange(m, conf.C.Resolver) 19 | if err != nil { 20 | return errors.Wrap(err, " get NS record") 21 | } 22 | if r.Rcode != dns.RcodeSuccess || len(r.Answer) == 0 { 23 | return nil 24 | } 25 | 26 | wg := sync.WaitGroup{} 27 | var n *dns.NS 28 | var ok bool 29 | for _, v := range r.Answer { 30 | if n, ok = v.(*dns.NS); !ok { 31 | continue 32 | } 33 | wg.Add(1) 34 | go transferOneNS(&wg, n.Ns, toNext) 35 | } 36 | wg.Wait() 37 | return nil 38 | } 39 | 40 | func transferOneNS(wg *sync.WaitGroup, ns string, toNext chan<- *Task) { 41 | defer wg.Done() 42 | 43 | t := new(dns.Transfer) 44 | m := new(dns.Msg) 45 | m.SetAxfr(conf.C.Target) 46 | recvChan, err := t.In(m, ns+":53") // default timeout 2s 47 | if err != nil { 48 | return 49 | } 50 | for v := range recvChan { 51 | if v.Error != nil { // TODO: more error type 52 | break 53 | } 54 | for _, rr := range v.RR { 55 | t := rr.Header().Rrtype 56 | if t == dns.TypeA || t == dns.TypeAAAA || t == dns.TypeCNAME || t == dns.TypeMX { // TODO: more type 57 | putTask(toNext, rr.Header().Name) 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/module/module.go: -------------------------------------------------------------------------------- 1 | package module 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | ) 6 | 7 | type Task struct { 8 | DomainName string 9 | Record dns.RR 10 | LastQueryAt int64 11 | Received bool 12 | } 13 | 14 | func putTask(toNext chan<- *Task, dn string) { 15 | toNext <- &Task{ 16 | DomainName: dns.Fqdn(dn), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/module/wordlist.go: -------------------------------------------------------------------------------- 1 | package module 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "os" 7 | "strings" 8 | 9 | "github.com/0x2E/sf/internal/conf" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | func RunWordlist(ctx context.Context, toNext chan<- *Task) error { 14 | if conf.C.Wordlist == "" { 15 | return errors.New("no wordlist input") 16 | } 17 | 18 | f, err := os.Open(conf.C.Wordlist) 19 | if err != nil { 20 | return errors.Wrap(err, "open wordlist file") 21 | } 22 | defer f.Close() 23 | 24 | var fn func(string) string 25 | if conf.C.RawTarget != conf.C.Target { 26 | fn = func(word string) string { 27 | return strings.ReplaceAll(conf.C.RawTarget, "%", word) 28 | } 29 | } else { 30 | suffix := "." + conf.C.Target 31 | fn = func(word string) string { 32 | return word + suffix 33 | } 34 | } 35 | 36 | dSet := make(map[string]struct{}) 37 | scanner := bufio.NewScanner(f) 38 | scanner.Split(bufio.ScanLines) 39 | for scanner.Scan() { 40 | word := strings.TrimSpace(scanner.Text()) 41 | if _, ok := dSet[word]; ok { 42 | continue 43 | } 44 | dSet[word] = struct{}{} 45 | 46 | dn := fn(word) 47 | // logrus.Debug(dn) 48 | putTask(toNext, dn) 49 | } 50 | 51 | // try main domain too 52 | putTask(toNext, conf.C.Target) 53 | return nil 54 | } 55 | --------------------------------------------------------------------------------