SubDomainsBrute源码分析

1.SubDomainsBrute简介

SubDomainsBrute是一款目标域名收集工具,工具可从github上直接搜索SubDomainsBrute找到,
dirsearch的使用是通过命令行获取参数,例如,
python SubDomainsBrute.py --full

可以看到SubDomainsBrute文件结构如下:
dict(字典)、Lib(源码)、tmp(结果生成)文件夹、subDomainsBrute.py
dict包含dns_servers、next_sub、next_sub_full、sample_qq.com、subnames、subnames_all_5_letters、subnames_full。lib包含cmdline、common、consle_width.py
在运行结束后生成,domain_full.txt,有最终的子域名信息

具体的参数可看Github上如下的usage。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Usage: subDomainsBrute.py [options] target.com
Options:
  --version             show program's version number and exit
  -h, --help            show this help message and exit
  -f FILE               File contains new line delimited subs, default is
                        subnames.txt.
  --full                Full scan, NAMES FILE subnames_full.txt will be used
                        to brute
  -i, --ignore-intranet
                        Ignore domains pointed to private IPs
  -t THREADS, --threads=THREADS
                        Num of scan threads, 200 by default
  -p PROCESS, --process=PROCESS
                        Num of scan Process, 6 by default
  -o OUTPUT, --output=OUTPUT
                        Output file name. default is {target}.txt

2. SubDomainsBrute源码分析

想要对源码分析,首先找到程序入口,即SubDomainsBrute.py。

1
2
3
4
5
6
7
8
#main
tmp_dir = 'tmp/%s_%s' % (args[0], int(time.time()))
multiprocessing.freeze_support()
dns_servers = load_dns_servers()
next_subs = load_next_sub(options)
scan_count = multiprocessing.Value('i', 0)
found_count = multiprocessing.Value('i', 0)
queue_size_array = multiprocessing.Array('i', options.process)

首先根据传入的域名创建了tmp子文件,然后dns_servers调用load_dns_servers(),该函数在common.py中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#load_dns_servers()
def load_dns_servers():
    print_msg('[+] Validate DNS servers', line_feed=True)
    dns_servers = []
    pool = Pool(5)
    for server in open('dict/dns_servers.txt').readlines():
        server = server.strip()
        if server and not server.startswith('#'):
            pool.apply_async(test_server, (server, dns_servers))
    pool.join()

    server_count = len(dns_servers)
    print_msg('
[+] %s DNS Servers found' % server_count, line_feed=True)
    if server_count == 0:
        print_msg('[ERROR] No valid DNS Server !', line_feed=True)
        sys.exit(-1)
    return dns_servers

dns_servers.txt中包含了

1
2
3
4
5
6
7
# dns_servers.txt
119.29.29.29
182.254.116.116
# 223.5.5.5
# 223.6.6.6
114.114.115.115
114.114.114.114

119.29.29.29是腾讯DNSPod公共DNS,还有其他的公共DNS如,114dns(114.114.114.114),阿里(223.5.5.5.5),百度(180.76.76.76),360(101.226.4.6),google(8.8.8.8)等。load_dns_servers中循环读取dns_servers.txt中的dns服务,strip()函数去除换行符,并使用 test_server()判断是否是可用的dns服务,并采用协程执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# test_server
def test_server(server, dns_servers):
    resolver = dns.resolver.Resolver(configure=False)
    resolver.lifetime = resolver.timeout = 5.0
    try:
        resolver.nameservers = [server]
        answers = resolver.query('public-dns-a.baidu.com')    # an existed domain
        if answers[0].address != '180.76.76.76':
            raise Exception('Incorrect DNS response')
        try:
            resolver.query('test.bad.dns.lijiejie.com')    # non-existed domain
            with open('bad_dns_servers.txt', 'a') as f:
                f.write(server + '
')
            print('[+] Bad DNS Server found %s' % server)
        except Exception as e:
            dns_servers.append(server)
        print('[+] Server %s < OK >   Found %s' % (server.ljust(16), len(dns_servers)))
    except Exception as e:
        print('[+] Server %s <Fail>   Found %s' % (server.ljust(16), len(dns_servers)))

首先指定了一个存在的网址,如果解析出来的ip正确,那么就将这个server添加到dns_server中,然后指定一个不存在的网址,无法解析,直接except得到dns_servers。得到最终的dns_servers列表后,load_dns_servers()中会继续判断如果列表长度等于0,那么就是No valid DNS Server,程序退出,否则返回dns_server。主函数调用load_dns_servers()后,又调用了common.py中的load_next_sub函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#common.py
def load_next_sub(options):
    next_subs = []
    _file = 'dict/next_sub_full.txt' if options.full_scan else 'dict/next_sub.txt'
    with open(_file) as f:
        for line in f:
            sub = line.strip()
            if sub and sub not in next_subs:
                tmp_set = {sub}
                while tmp_set:
                    item = tmp_set.pop()
                    if item.find('{alphnum}') >= 0:
                        for _letter in 'abcdefghijklmnopqrstuvwxyz0123456789':
                            tmp_set.add(item.replace('{alphnum}', _letter, 1))
                    elif item.find('{alpha}') >= 0:
                        for _letter in 'abcdefghijklmnopqrstuvwxyz':
                            tmp_set.add(item.replace('{alpha}', _letter, 1))
                    elif item.find('{num}') >= 0:
                        for _letter in '0123456789':
                            tmp_set.add(item.replace('{num}', _letter, 1))
                    elif item not in next_subs:
                        next_subs.append(item)
    return next_subs

根据options是否为full选择不同的字典,逐行读取字典,strip函数去除换行,如果没有在next_subs列表中,暂时村放入tmp_set,弹出该元素后进行相应的if判断,如果都不符合,直接加在next_subs列表中,最后next_subs列表有该字典中的内容。如果读取的字符串有{alphnum}、{alpha}、{num}这样的,函数将会对这些进行替换,例如含有test{alphanum},则会循环被替换为,['testa'、'testb'...、'testz'、'test0'...、'test9']。
接着主程序中执行了如下步骤

1
2
3
scan_count = multiprocessing.Value('i', 0)
found_count = multiprocessing.Value('i', 0)
queue_size_array = multiprocessing.Array('i', options.process)

multiprocessing为多进程。i代表数字,设置扫描数量,找到数量初始为0。
进程和线程存在区别,比如之前分析dirsearch,该工具就是用的多线程threading。对于进程multiprocessing,通常有两种方式

1
2
from multiprocessing import Processing
from multiprocessing import Pool

Pool类主要用于多目标,如果目标少且不用控制进程数量则可以用Process类。其中Pool可以控制进程数。

接着主程序调用了wildcard_test函数和get_sub_file_path函数。

1
2
domain = wildcard_test(args[0])
options.file = get_sub_file_path()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#SubDomainsBrute.py
def wildcard_test(domain, level=1):
    try:
        r = dns.resolver.Resolver(configure=False)
        r.nameservers = dns_servers
        answers = r.query('lijiejie-not-existed-test.%s' % domain)
        ips = ', '.join(sorted([answer.address for answer in answers]))
        if level == 1:
            print('any-sub.%s   %s' % (domain.ljust(30), ips))
            wildcard_test('any-sub.%s' % domain, 2)
        elif level == 2:
            exit(0)
    except Exception as e:
        return domain

wildcard_test部分和test_server部分较为相似,最终返回我们输入的域名

1
2
3
4
5
6
7
8
9
10
11
12
def get_sub_file_path():
    if options.full_scan and options.file == 'subnames.txt':
        path = 'dict/subnames_full.txt'
    else:
        if os.path.exists(options.file):
            path = options.file
        elif os.path.exists('dict/%s' % options.file):
            path = 'dict/%s' % options.file
        else:
            print_msg('[ERROR] Names file not found: %s' % options.file)
            exit(-1)
    return path

在full扫描的情况下,返回路径dict/subnames_full.txt,否则在dict目录下寻找所输入的目录。找不到则显示ERROR。
接着,主程序内容如下

1
2
3
4
5
for process_num in range(options.process):
    p = multiprocessing.Process(target=run_process,
    args=(domain, options, process_num, dns_servers, next_subs, scan_count, found_count, queue_size_array, tmp_dir))
    all_process.append(p)
    p.start()

options.process为用户从命令行输入的进程数,默认为6,接着设置多进程,并添加到进程列表中,开始进程。在进程设置过程中设置了run_process()函数

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
100
101
102
103
104
105
106
107
108
109
110
111
def run_process(*params):
    signal.signal(signal.SIGINT, user_abort)
    s = SubNameBrute(*params)
    s.run()
def user_abort(sig, frame):
    exit(-1)
def run(self):
    threads = [gevent.spawn(self.scan, i) for i in range(self.options.threads)]
    gevent.joinall(threads)
def scan(self, j):
    self.resolvers[j].nameservers = [self.dns_servers[j % self.dns_count]] + self.dns_servers

    while True:
        try:
            self.lock.acquire()
            if time.time() - self.count_time > 1.0:
                self.scan_count.value += self.scan_count_local
                self.scan_count_local = 0
                self.queue_size_array[self.process_num] = self.queue.qsize()
                if self.found_count_local:
                    self.found_count.value += self.found_count_local
                    self.found_count_local = 0
                self.count_time = time.time()
            self.lock.release()
            brace_count, sub = self.queue.get(timeout=3.0)
            if brace_count > 0:
                brace_count -= 1
                if sub.find('{next_sub}') >= 0:
                    for _ in self.next_subs:
                        self.queue.put((0, sub.replace('{next_sub}', _)))
                if sub.find('{alphnum}') >= 0:
                    for _ in 'abcdefghijklmnopqrstuvwxyz0123456789':
                        self.queue.put((brace_count, sub.replace('{alphnum}', _, 1)))
                elif sub.find('{alpha}') >= 0:
                    for _ in 'abcdefghijklmnopqrstuvwxyz':
                        self.queue.put((brace_count, sub.replace('{alpha}', _, 1)))
                elif sub.find('{num}') >= 0:
                    for _ in '0123456789':
                        self.queue.put((brace_count, sub.replace('{num}', _, 1)))
                continue
        except gevent.queue.Empty as e:
            break

        try:

            if sub in self.found_subs:
                continue

            self.scan_count_local += 1
            cur_domain = sub + '.' + self.domain
            answers = self.resolvers[j].query(cur_domain)

            if answers:
                self.found_subs.add(sub)
                ips = ', '.join(sorted([answer.address for answer in answers]))
                if ips in ['1.1.1.1', '127.0.0.1', '0.0.0.0', '0.0.0.1']:
                    continue
                if self.options.i and is_intranet(answers[0].address):
                    continue

                try:
                    self.scan_count_local += 1
                    answers = self.resolvers[j].query(cur_domain, 'cname')
                    cname = answers[0].target.to_unicode().rstrip('.')
                    if cname.endswith(self.domain) and cname not in self.found_subs:
                        cname_sub = cname[:len(cname) - len(self.domain) - 1]  # new sub
                        if cname_sub not in self.normal_names_set:
                            self.found_subs.add(cname)
                            self.queue.put((0, cname_sub))
                except Exception as e:
                    pass

                first_level_sub = sub.split('.')[-1]
                if (first_level_sub, ips) not in self.ip_dict:
                    self.ip_dict[(first_level_sub, ips)] = 1
                else:
                    self.ip_dict[(first_level_sub, ips)] += 1
                    if self.ip_dict[(first_level_sub, ips)] > 30:
                        continue

                self.found_count_local += 1

                self.outfile.write(cur_domain.ljust(30) + ' ' + ips + '
')
                self.outfile.flush()
                try:
                    self.scan_count_local += 1
                    self.resolvers[j].query('lijiejie-test-not-existed.' + cur_domain)
                except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e:
                    if self.queue.qsize() < 10000:
                        for _ in self.next_subs:
                            self.queue.put((0, _ + '.' + sub))
                    else:
                        self.queue.put((1, '{next_sub}.' + sub))
                except Exception as e:
                    pass

        except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e:
            pass
        except dns.resolver.NoNameservers as e:
            self.queue.put((0, sub))  # Retry
        except dns.exception.Timeout as e:
            self.timeout_subs[sub] = self.timeout_subs.get(sub, 0) + 1
            if self.timeout_subs[sub] <= 2:
                self.queue.put((0, sub))  # Retry
        except Exception as e:
            import traceback
            traceback.print_exc()
            with open('errors.log', 'a') as errFile:
                errFile.write('[%s] %s
' % (type(e), str(e)))

siganal.signal,发起信号,当按下键盘的ctrl+c的时候 exit退出程序。run函数调用了协程。
在linux系统中,线程就是轻量级的进程,而轻量级的线程即微线程会被称为协程。
线程是在进程中做切换,协程则相当于在线程当中做切换。
如果队列不是空的,就从队列中取出,设定timeout为3,且本地扫描数+1。接着,和load_next_sub类似,如果队列中有符合条件的则进行替换,优先级增加。遍历子目录,如果sub在found_subs中就跳过,如果不在,扫描数量+1,当前域名cur_domain=sub.domain是把主域名前面加上子域名,用dns进行查询,如果存在则在子域名列表中增加,并把其对应的ip写在ips中,如果ip对应为1.1.1.1、127.0.0.1、0.0.0.0、0.0.0.1或是内网(通过common.py的is_intranet函数判断,函数如下)则跳过,并且查询cname,如果子域名,ip这个键值对不存在则设为1,存在则加1,如果字典值大于30就结束,找到的子域名总数加1,将子域名写到文件中。

1
2
3
4
5
6
7
8
9
10
11
def is_intranet(ip):
    ret = ip.split('.')
    if len(ret) != 4:
        return True
    if ret[0] == '10':
        return True
    if ret[0] == '172' and 16 <= int(ret[1]) <= 31:
        return True
    if ret[0] == '192' and ret[1] == '168':
        return True
    return False

以10开头或者172.16~31开头,或者192.168开头的判断为内网,返回True。

3.总体思路总结

总体的函数调用关系图如下所示,红线代表调用,白线代表程序执行顺序。


函数调用关系图.png

程序首先load_dns_servsers,根据字典获取DNS服务,然后测试服务是否可用。然后根据用户选择,通过load_next_sub进行子域名字典的读取,将子域名加在到队列中,拼接成完整域名,DNS服务连接从而判断其域名是否存在,多次循环迭代访问,得到最终的域名列表。然后run_process,用协程的方式进行扫描,scan函数为程序的主要功能函数,通过对每个子域名的DNS响应,根据其answer进行结果列表的添加。得到最终的子域名列表。