Python实现的信息收集工具箱:域名解析、CDN检测与端口扫描
2026/9/13 19:59:05 网站建设 项目流程

简介:这是一份基于Python开发的域名与网络资产信息收集工具源码,涵盖域名解析、IP反查域名、WHOIS查询、CDN检测、端口扫描、目录扫描、子域名挖掘等常用功能,适合安全测试人员、网络运维工程师及Python网络编程学习者参考实践。压缩包整体约157KB,共10个文件,以Python主程序为核心,辅以YAML配置、字典文本、README说明及License许可文件,代码结构简洁,便于按模块阅读与二次开发。其中包含fuzz、subdomain等字典文件,可直接用于目录探测与子域名枚举场景,端口扫描模块基于nmap接口,需配合本机nmap环境运行。目前已有344人学习使用,对于希望快速搭建自己的域名信息收集工具箱或理解网络协议封装思路的读者来说,是一份轻量且实用的入门参考。

1. 基于Python实现的域名解析、IP反查域名、WHOIS查询、CDN检测、端口扫描、目录扫描、子域名挖掘,到底在解决什么问题

做过资产测绘或者安全评估的人都有同感:拿到一个目标域名后,第一轮信息收集通常要重复打开四五个工具,DNS解析用一个、端口扫描用另一个、目录扫描再换一个,中间结果靠手记或临时拼脚本。这套基于Python实现的域名解析、IP反查域名、WHOIS查询、CDN检测、端口扫描、目录扫描、子域名挖掘工具,本质是把信息收集最常碰到的七个动作收敛到同一个命令行入口里,输出统一格式的结果,方便后续做关联分析和二次处理。

它的价值不在于某个单点功能比专业工具强,而在于你能在不受网络环境限制的前提下,用一套代码把七类数据拉通:A记录、PTR、WHOIS、CDN归属、开放端口、敏感目录、子域名集合。对Python开发者来说,这也是一个很好的“网络编程 + 并发 + 协议交互”练习范本——socket、asyncio、dnspython、python-whois 这几个库会在同一份代码里协作。接下来从工程落地角度,把每个模块怎么拆、参数怎么设、坑在哪里,一条条讲清楚。

2. 环境搭建与整体架构:如何用最小依赖组织七合一工具

2.1 依赖选型:标准库为主,第三方库只留三个

先解决环境问题。Python版本建议3.10及以上,主要用到asyncioipaddressconcurrent.futures等标准库,不需要额外安装。第三方依赖建议控制在三个以内,避免工具在目标机器上部署时被依赖问题卡住:

# 创建虚拟环境,避免污染系统 Python python3 -m venv .venv source .venv/bin/activate # 安装三个核心依赖 pip install dnspython python-whois httpx

参数说明:dnspython负责A记录、CNAME、PTR等DNS记录查询,用法和解析能力比socket原生函数强不少;python-whois用来做WHOIS信息解析,底层基于RFC 3912,对.com/.net等主流后缀支持较好;httpx用于目录扫描和CDN检测中的HTTP请求,支持异步和超时控制。如果安装python-whois时遇到编译报错,多半是setuptools版本问题,先执行pip install --upgrade setuptools再装。

不推荐用requests代替httpx的原因在于:目录扫描通常要同时探测几百个路径,httpx的异步客户端配合asyncio.Semaphore控制并发,比requests搭配线程池的写法更省内存,代码也更短。

2.2 统一入口:用 argparse 把七个模块拆成子命令

一个工具如果七个功能混在一个脚本里,参数维护是灾难。常见做法是用argparse的子命令模式,让每个模块拥有独立参数,同时保留“全量扫描”的联动入口。我把这套结构的骨架简化如下:

import argparse def cmd_dns(args): print(f"[DNS] resolve {args.domain} -> {args.type}") def cmd_ptr(args): print(f"[PTR] reverse {args.ip}") def cmd_whois(args): print(f"[WHOIS] query {args.domain}") def cmd_cdn(args): print(f"[CDN] check {args.domain}") def cmd_port(args): print(f"[PORT] scan {args.host} ports {args.ports}") def cmd_dir(args): print(f"[DIR] scan {args.url} wordlist {args.wordlist}") def cmd_subdomain(args): print(f"[SUBDOMAIN] brute {args.domain} dict {args.dict}") def main(): parser = argparse.ArgumentParser(prog="recon-cli", description="Python实现的信息收集工具箱") sub = parser.add_subparsers(dest="module", required=True) p1 = sub.add_parser("dns", help="域名解析") p1.add_argument("domain") p1.add_argument("--type", default="A", choices=["A", "CNAME", "MX", "NS"]) p1.set_defaults(func=cmd_dns) p2 = sub.add_parser("ptr", help="IP反查域名") p2.add_argument("ip") p2.set_defaults(func=cmd_ptr) p3 = sub.add_parser("whois", help="WHOIS查询") p3.add_argument("domain") p3.set_defaults(func=cmd_whois) p4 = sub.add_parser("cdn", help="CDN检测") p4.add_argument("domain") p4.set_defaults(func=cmd_cdn) p5 = sub.add_parser("port", help="端口扫描") p5.add_argument("host") p5.add_argument("--ports", default="21,22,23,25,53,80,110,135,139,143,443,445,993,995,1433,1521,3306,3389,5432,6379,8080,8443") p5.set_defaults(func=cmd_port) p6 = sub.add_parser("dir", help="目录扫描") p6.add_argument("url") p6.add_argument("--wordlist", default="dict_common.txt") p6.set_defaults(func=cmd_dir) p7 = sub.add_parser("subdomain", help="子域名挖掘") p7.add_argument("domain") p7.add_argument("--dict", default="subnames.txt") p7.set_defaults(func=cmd_subdomain) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()

逻辑说明:add_subparsers(dest="module", required=True)让不同子命令分发到不同处理函数,set_defaults(func=...)是一个常用技巧,把处理函数绑定到命名空间上,parse之后直接调用。每个子命令的--ports--wordlist--dict都设计了默认值,方便新手直接跑通;生产使用时再按目标环境调整。

这个结构的收益在于:后续无论是加“结果落盘”还是“批量扫描”,都只需在对应子命令里追加代码,不会影响其他模块。实际开发时建议为“工具源码”项目建立modules/目录,把 dns.py、port.py、subdomain.py 等按功能拆文件,入口脚本只做调度。

2.3 结果统一出口:JSON 格式避免二次解析

七个模块产出格式不统一的话,后面做数据关联会非常痛苦。我一般会为每个模块定义一个scan()函数,返回值统一为字典,最后用json.dumps(ensure_ascii=False, indent=2)输出。这个设计配合后续的流水线串联,能让端口扫描发现的高危端口、子域名挖掘出来的新域名,自动作为下一轮扫描的输入目标。

3. 域名解析、IP反查与WHOIS:三个查询类模块的实现与坑

3.1 域名解析:socket 足够日常,dnspython 才能看到完整记录

先用简单方式实现A记录查询:

import socket def resolve_with_socket(domain): try: ip_list = socket.getaddrinfo(domain, None) return sorted(set(item[4][0] for item in ip_list)) except socket.gaierror as e: return {"error": str(e)}

逻辑说明:socket.getaddrinfo返回值是一个四元组列表,第四个元素的第一个位置是IP字符串,用集合去重后排序,就能拿到域名对应的全部IP。这个方法的问题在于拿不到CNAME和MX,对CDN检测来说信息不够。

dnspython 的写法能看到更完整的链路:

import dns.resolver def resolve_full(domain, record_type="A"): answers = dns.resolver.resolve(domain, record_type, lifetime=5) return [r.to_text() for r in answers]

参数说明:lifetime=5是单次查询的超时时间,单位秒。在内网DNS环境建议调大到10,公网环境5秒足够。dns.resolver.resolve返回的每个r对象,.to_text()能把A记录、CNAME、MX等不同类型的值统一转成字符串,避免分别处理不同类型对象。

这里有一个容易被忽略的坑:dns.resolver默认读取系统/etc/resolv.conf的DNS配置,如果你用公共DNS做基准测试,需要显式指定nameserver:

resolver = dns.resolver.Resolver() resolver.nameservers = ["8.8.8.8", "1.1.1.1"] answers = resolver.resolve(domain, "A")

实际使用时不建议硬编码DNS地址,提供--dns参数让用户传入,否则工具在本地位移的场景下(比如公司内部DNS环境)会拿到完全不同的解析结果。

3.2 IP反查域名:PTR记录是唯一标准答案

反查域名的基础是PTR记录,没有第三方接口能绕过它。实现方式:

import dns.reversename import dns.resolver def reverse_lookup(ip): # 构造反向查询名:192.168.1.1 -> 1.1.168.192.in-addr.arpa rev_name = dns.reversename.from_address(ip) try: answers = dns.resolver.resolve(rev_name, "PTR", lifetime=5) return [r.to_text().rstrip(".") for r in answers] except dns.resolver.NXDOMAIN: return {"ip": ip, "ptr": None, "note": "无PTR记录"} except dns.resolver.NoAnswer: return {"ip": ip, "ptr": None, "note": "存在但无PTR记录"}

参数说明:dns.reversename.from_address负责把IP转换成反向查询名,省去手动拼接in-addr.arpa的麻烦。rstrip(".")去掉FQDN末尾的点,保持输出美观。

这个模块的坑在于:IPv6的反查格式和IPv4不同,IPv6用的是ip6.arpa域,dns.reversename.from_address能自动识别协议版本,但如果你手动拼字符串,务必分协议处理。此外,很多机房默认不配置PTR记录,反查为空是常态,工具输出里要保留“无记录”和“查询失败”两种状态,别混为一谈。

3.3 WHOIS查询:python-whois 的解析限制和 RDAP 补充方案

python-whois 的用法很简单:

import whois def query_whois(domain): try: w = whois.query(domain) return { "domain": w.name, "registrar": w.registrar, "creation_date": str(w.creation_date), "expiration_date": str(w.expiration_date), "name_servers": w.name_servers, "status": w.status, "org": getattr(w, "org", None) } except Exception as e: return {"error": str(e), "domain": domain}

逻辑说明:whois.query返回的对象包含结构化属性;getattr(w, "org", None)是因为部分TLD的whois响应里没有org字段,直接用w.org会抛AttributeError,这里用getattr兜底。

python-whois 的局限性在于:对.com/.net之外的TLD(如.org、.io、.cn)的字段解析不完全可靠,有时候creation_date会是列表而非字符串,需要用str()强制转换。更规范的替代方案是RDAP(Registration Data Access Protocol),直接HTTP GET请求即可,不需要额外库:

curl -s "https://rdap.org/domain/example.com" | python3 -m json.tool

说明:RDAP是WHOIS的下一代协议,输出是标准JSON,字段结构比WHOIS文本解析稳定得多。如果工具面向全球域名,建议同时启用两种查询方式,默认走RDAP,失败时回落到python-whois。

4. CDN检测:CNAME链分析、IP段比对、证书特征三维判断

4.1 为什么不能只靠IP段判断:CNAME链是更早的信号

很多CDN检测方案只做了“解析IP是否在CDN厂商网段内”这一步,但漏掉了DNS层更明显的特征——CNAME。接入CDN的域名,解析结果通常是xxx.cloudfront.netxxx.a.b.cdn.cloudflare.net这类CDN域名。从CNAME链入手,能在IP比对之前就给出高置信度的判断。

import dns.resolver def get_cname_chain(domain): chain = [] current = domain for _ in range(10): # 防止循环跳转,限制最大链路长度 try: answers = dns.resolver.resolve(current, "CNAME", lifetime=5) cname = answers[0].to_text().rstrip(".") chain.append(cname) current = cname except dns.resolver.NoAnswer: break except dns.resolver.NXDOMAIN: break return chain

逻辑说明:循环里每次查询当前域名的CNAME,拿到后继续查下一个,直到域名没有CNAME记录为止。加10次循环上限是因为少数配置错误的域名可能A和CNAME来回跳,形成死循环。返回的链列表最后一项通常是真正的源站域名或直接挂在CDN节点下的记录。

判断逻辑很直接:如果CNAME链中任意一条记录的主域名落在已知CDN厂商域名集合内,比如cloudfront.netcdn.cloudflare.netkunlun.com(阿里云CDN)、chinanetcenter.com(网宿)等,直接判定为CDN,返回命中厂商。这个集合建议单独放在cdn_domains.txt文件里,方便后续补充。

4.2 IP段比对:用 ipaddress 标准库匹配厂商网段

CNAME检测无法覆盖所有情况——某些CDN服务支持A记录直连回源,或者CNAME被运营商劫持。这时候回到IP段比对:

import ipaddress # 模拟数据,生产环境从 cdn_ip_ranges.txt 加载 CDN_RANGES = { "Cloudflare": ["104.16.0.0/13", "172.64.0.0/13"], "Akamai": ["104.64.0.0/10"], "腾讯云CDN": ["1.32.128.0/18"] } def check_cdn_by_ip(ip_str): ip = ipaddress.ip_address(ip_str) for vendor, cidrs in CDN_RANGES.items(): for cidr in cidrs: if ip in ipaddress.ip_network(cidr): return vendor return None

参数说明:ipaddress.ip_address负责校验IP格式并生成IP对象;ipaddress.ip_network(cidr)把CIDR字符串转换成网络对象;ip in network是标准库内置的包含判断,性能和正确性都优于自己写位运算。

CDN厂商的IP段文件怎么维护?常见做法是定期从厂商官网下载公开的IP列表(AWS、Cloudflare、阿里云等都有官方发布页),或者从BGP数据源(如RIPE Stat API)拉取ASN对应的前缀。不建议手工罗列,数据会过期。

4.3 辅助特征:TLS证书颁发者与HTTP响应头

有时候IP段比对和CNAME链都查不出来,比如域名用了国内小厂CDN或者源站本身就在云上。这时加两个辅助判断维度:

  • TLS证书的颁发者:CDN厂商签发的证书通常是CN=*.cloudfront.netO=Let's Encrypt,和源站自签证书差异明显
  • HTTP响应头:Server: cloudflareVia: 1.1 a.b.c.d (Cloudflare)X-Cache: HIT都是明确的CDN指纹
import ssl import socket import json def check_cdn_by_cert(domain, port=443, timeout=5): try: ctx = ssl.create_default_context() with socket.create_connection((domain, port), timeout=timeout) as sock: with ctx.wrap_socket(sock, server_hostname=domain) as tls: cert = tls.getpeercert() issuer = dict(x[0] for x in cert.get("issuer", [])) return issuer.get("organizationName") or issuer.get("commonName") except Exception as e: return str(e)

逻辑说明:ssl.create_default_context创建带默认CA证书链的验证上下文,wrap_socket完成TLS握手,getpeercert()返回证书的OpenSSL格式字典。issuer字段是嵌套元组结构,用dict(x[0] for x in ...)扁平化后取组织名。

CDN检测综合判断的策略:先跑CNAME链,命中即返回;否则解析全部IP,逐个比对IP段;仍无结果再看证书和响应头。三个维度全部跑完再说“疑似CDN”而非“确定CDN”,这个分寸在报告里要体现出来。

5. 端口扫描与目录扫描:并发参数、超时设置、结果过滤

5.1 端口扫描:TCP Connect + asyncio 并发控制

用Python写端口扫描,核心是并发和超时控制。asyncio.open_connection是底层socket的异步封装,配合Semaphore控制同时打开的连接数,避免触发目标防火墙的封禁策略:

import asyncio async def scan_port(host, port, timeout=2.0): try: reader, writer = await asyncio.wait_for( asyncio.open_connection(host, port), timeout=timeout ) # 尝试读取banner,非阻塞 try: banner = await asyncio.wait_for(reader.read(1024), timeout=1.0) return port, "open", banner.decode(errors="replace").strip()[:200] except Exception: return port, "open", "" finally: writer.close() await writer.wait_closed() except asyncio.TimeoutError: return port, "filtered", "" except ConnectionRefusedError: return port, "closed", "" async def port_scan(host, ports, max_concurrency=200): sem = asyncio.Semaphore(max_concurrency) results = [] async def bounded(p): async with sem: return await scan_port(host, p) tasks = [bounded(p) for p in ports] for coro in asyncio.as_completed(tasks): result = await coro if result[1] != "closed": results.append(result) return results

参数说明:timeout=2.0是连接超时,局域网扫描可以降到0.3秒,公网扫描建议2到3秒;max_concurrency=200是最大并发连接数,目标机器性能差或网络延迟高时调小到50。asyncio.as_completed按完成顺序返回结果,比gather更早产出有数据的端口。

Python原生能实现的是TCP Connect扫描,无法发SYN半开扫描包(需要raw socket权限,通常要用Scapy或系统nmap工具配合)。对大多数服务发现场景,TCP Connect足够。端口列表建议开放给用户自定义,同时内置常见端口表:21/22/23/25/53/80/110/135/139/143/443/445/993/995/1433/1521/3306/3389/5432/6379/8080/8443。

5.2 目录扫描:状态码过滤与重定向处理

目录扫描的代码核心是并发HTTP请求和状态码判断。用httpx异步客户端:

import asyncio import httpx async def dir_scan(base_url, wordlist, max_concurrency=20, timeout=5): async with httpx.AsyncClient( timeout=timeout, follow_redirects=False, headers={"User-Agent": "Mozilla/5.0 (compatible; ReconBot/1.0)"} ) as client: sem = asyncio.Semaphore(max_concurrency) results = [] async def check(path): async with sem: try: resp = await client.get(base_url + path) if resp.status_code in (200, 204, 301, 302, 403): return { "path": path, "status": resp.status_code, "size": len(resp.content), "location": resp.headers.get("location", "") } except Exception: pass return None paths = [line.strip() for line in open(wordlist, encoding="utf-8", errors="ignore")] tasks = [check(p) for p in paths] for coro in asyncio.as_completed(tasks): r = await coro if r: results.append(r) return results

参数说明:follow_redirects=False很关键——很多目录扫描器会跟着302跳转,结果把首页内容当成目录内容,造成大量重复误报。这里保留301/302并记录location字段,由用户判断是否跟进。max_concurrency=20是保守值,无CDN防护的站点可以调高到50;遇到WAF必须降到5以下,否则触发封IP。

字典从哪里来?常见的路径字典几万个词条,不建议直接硬编码在代码里。开源字典(如FuzzDB的top500backup.txt)可以按需取用,放到独立文件里,命令行传入路径。这个设计和--wordlist参数对应。

5.3 两个扫描的“误判源”排查

端口扫描和目录扫描合在一起跑,最常见的干扰是CDN和负载均衡。域名解析到CDN节点后,端口扫描看到的是CDN的端口而非源站端口,目录扫描打到CDN缓存节点,返回的页面可能全是200。遇到这种情况,先对真实IP做端口扫描,再对域名做目录扫描,结果分开存放,别混在同一个表里。

超时参数同样需要区分场景:目录扫描的超时不应低于5秒,因为某些后端接口要渲染模板;端口扫描的超时和并发数直接相关,并发越高,单个连接的超时要越低,否则最长扫描时间会被拖到并发数 * 超时时间 / 端口数这个量级。

6. 子域名挖掘与全量流水线:最后一公里的实用技巧

6.1 子域名挖掘:字典爆破 + crt.sh 被动收集双通道

子域名挖掘两类方法建议都实现:字典爆破用dnspython做A/CNAME查询,命中即记录;被动收集查询证书透明日志,一天能捞到海量子域名,且不产生主动流量。被动收集只需一条命令:

curl -s "https://crt.sh/?q=%25.example.com&output=json" | python3 -c "import sys,json; data=json.load(sys.stdin); print('\n'.join(sorted(set(i['name_value'] for i in data if 'name_value' in i))))"

参数说明:%25%的URL编码,表示匹配任意子域名前缀;output=json让crt.sh返回结构化数据;name_value字段里可能有*.example.com这类泛解析条目,需要在处理时过滤掉。

字典爆破部分的代码逻辑:

import asyncio import dns.asyncresolver async def brute_subdomain(domain, wordlist, concurrency=50): resolver = dns.asyncresolver.Resolver() resolver.nameservers = ["8.8.8.8", "1.1.1.1"] sem = asyncio.Semaphore(concurrency) found = [] async def query(sub): async with sem: name = f"{sub}.{domain}" try: answers = await resolver.resolve(name, "A", lifetime=3) ips = [r.to_text() for r in answers] found.append({"subdomain": name, "ips": ips}) except Exception: pass words = [w.strip() for w in open(wordlist, encoding="utf-8", errors="ignore") if w.strip()] await asyncio.gather(*(query(w) for w in words)) return found

逻辑说明:dns.asyncresolver.Resolver是dnspython 2.x对asyncio的原生支持,不需要额外线程池。resolver.resolvelifetime=3单独控制单次查询超时,避免某个查询卡住整个任务。这里需要注意:泛解析(wildcard DNS)会让所有不存在的子域名都返回IP,导致结果全是噪声。应对方法是在爆破前先查一个随机字符串子域名,如果它也有解析记录,就判定目标存在泛解析,此时需要对结果做“二次验证”——把拿到的IP和随机子域名的IP比对,相同的直接丢弃。

6.2 把七个模块串成一条命令:中间结果落盘与断点续跑

工具做到这一步,每个模块独立跑没问题了,但真正的效率提升在于串联。我会在入口脚本里增加--all参数,用一条python recon-cli.py collect --domain example.com跑完整个流程:

def cmd_collect(args): domain = args.domain results = {} # 1. 基础解析 results["dns"] = resolve_full(domain) # 2. CDN检测 results["cdn"] = check_cdn(domain) # 3. 子域名挖掘 subs = run_subdomain_brute(domain) results["subdomains"] = subs # 4. 对子域名逐个做端口扫描和目录扫描 for sub in subs: results[sub["subdomain"]] = { "ports": run_port_scan(sub["ips"][0]), "dirs": run_dir_scan(f"http://{sub['subdomain']}/") } # 5. 结果落盘 with open(f"{domain}_recon.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)

流水线设计上的关键点:全量扫描可能耗时很长,一定要把中间结果实时写入JSON文件,而不是等全部跑完再写。断点续跑的技巧是扫描前先检查目标文件是否存在,存在则跳过该阶段。具体实现可以在输出文件名里加上模块名,比如example.com_subdomains.json,下次运行时检测到文件存在就加载缓存数据,只扫描缺失的模块。

最后一个建议:输出目录里加上report.md的生成逻辑,把各阶段结果整理成可读的清单,方便直接贴到工作记录里。整个工具的生命周期不在“跑一次”,而在“跑完能复现、能增量扩展”——把子域名挖掘的产出自动喂给端口扫描和目录扫描当输入,这才是信息收集工具链的完整闭环。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询