本文仅供合法授权的安全研究与学习使用,请勿用于非法用途。读者应确保行为符合当地法律法规。
前言
在红队渗透测试的信息收集阶段,子域名挖掘是绕不开的一环。一个大型企业往往拥有成百上千个子域名,而这些子域名中常常隐藏着测试环境、老旧系统、未授权接口等高价值攻击面——它们往往是边界突破的突破口。
传统的子域名收集手段主要有三种:字典爆破(如 Layer、Sublist3r)、搜索引擎语法(如 site:example.com)、以及第三方 API(如 crt.sh、FOFA)。但有一种方式经常被忽略——从前端 JavaScript 文件中提取链接和子域名。现代 Web 应用大量采用前后端分离架构,API 地址、内部接口、调试地址常常硬编码在 JS 文件里,这为红队提供了一条”捡漏”的捷径。
本文基于开源工具 JSFinder 的真实 Python 实现源码,剖析其从 JS 文件中提取子域名的技术原理与实现细节。
技术背景
为什么从 JS 里挖子域名?
现代 Web 应用普遍存在以下现象:
- 前后端分离:前端 Vue/React 打包后的 JS 中硬编码了后端 API 地址
- 微服务架构:不同业务线有独立的子域名,前端 JS 会引用这些接口
- 开发遗留:测试环境地址、调试接口未被清理就上了生产
- CDN/SaaS 集成:第三方服务地址(如 OSS、统计、IM)散落在 JS 中
这些信息通过正则匹配就能从 JS 源码里捞出来,成本极低、收益却往往出奇的高。
核心技术点
- HTML 解析:使用 BeautifulSoup 提取页面中的
<script> 标签,获取内联 JS 和外部 JS 引用
- 正则表达式提取链接:借鉴 LinkFinder 项目的高精度正则,从 JS 字符串中匹配 URL
- 相对路径处理:将
//、/path、./path、../path 等相对路径转换为绝对路径
- 主域名校验:通过判断提取到的 host 是否包含主域名根,过滤出属于目标的子域名
实现思路
JSFinder 的整体流程可以概括为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 输入 URL ↓ 请求页面 HTML 源码 ↓ BeautifulSoup 解析 <script> 标签 ↓ 收集内联 JS + 请求外部 JS 文件内容 ↓ 正则提取所有 JS 中的 URL ↓ 相对路径转绝对路径 ↓ 按主域名根过滤 → 得到子域名与 URL 列表 ↓ 输出/保存结果
|
工具还支持 -d(深度模式,会递归爬取页面中 <a> 标签的链接再逐个分析)和 -f(从文件批量读取 URL)两种增强模式。
核心代码解析
1. 命令行参数解析
工具通过 argparse 提供灵活的命令行接口,支持单 URL、文件批量、深度爬取、JS 模式、结果输出等参数:
1 2 3 4 5 6 7 8 9 10
| def parse_args(): parser = argparse.ArgumentParser(epilog='\tExample: \r\npython ' + sys.argv[0] + " -u http://www.baidu.com") parser.add_argument("-u", "--url", help="The website") parser.add_argument("-c", "--cookie", help="The website cookie") parser.add_argument("-f", "--file", help="The file contains url or js") parser.add_argument("-ou", "--outputurl", help="Output file name. ") parser.add_argument("-os", "--outputsubdomain", help="Output file name. ") parser.add_argument("-j", "--js", help="Find in js file", action="store_true") parser.add_argument("-d", "--deep",help="Deep find", action="store_true") return parser.parse_args()
|
其中 -c 参数很关键——很多业务页面需要登录态才能访问到完整的 JS 资源,带上 Cookie 可以显著提升信息收集的覆盖面。
2. 核心:从 JS 中提取 URL 的正则
这是整个工具最精华的部分,正则来源于 LinkFinder 项目,能够匹配四类常见 URL 形态:
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
| def extract_URL(JS): pattern_raw = r""" (?:"|') # 开始引号分隔符 ( ((?:[a-zA-Z]{1,10}://|//) # 匹配协议 http:// 或 // [^"'/]{1,}\. # 匹配域名 (任意字符 + 点) [a-zA-Z]{2,}[^"']{0,}) # 域名扩展和/或路径 | ((?:/|\.\./|\./) # 以 /,../,./ 开始的相对路径 [^"'><,;| *()(%%$^/\\\[\]] # 下一个字符不能是... [^"'><,;|()]{1,}) # 其余字符不能是 | ([a-zA-Z0-9_\-/]{1,}/ # 带/的相对端点 [a-zA-Z0-9_\-/]{1,} # 资源名称 \.(?:[a-zA-Z]{1,4}|action) # 扩展名 (长度1-4或action) (?:[\?|/][^"|']{0,}|)) # ? 标记带参数 | ([a-zA-Z0-9_\-]{1,} # 文件名 \.(?:php|asp|aspx|jsp|json| action|html|js|txt|xml) # . + 指定扩展名 (?:\?[^"|']{0,}|)) # ? 标记带参数 ) (?:"|') # 结束引号分隔符 """ pattern = re.compile(pattern_raw, re.VERBOSE) result = re.finditer(pattern, str(JS)) if result == None: return None js_url = [] return [match.group().strip('"').strip("'") for match in result if match.group() not in js_url]
|
这段正则的精妙之处在于:它只匹配被引号(" 或 ')包裹的内容,因为在 JS 中 URL 一定是作为字符串出现的。同时它覆盖了四种典型场景:完整 URL、相对路径(/api、./api、../api)、带扩展名的资源路径、以及常见脚本文件名。re.VERBOSE 模式让正则支持注释和换行,可读性大大增强。
3. 请求页面源码
1 2 3 4 5 6 7 8 9
| def Extract_html(URL): header = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.108 Safari/537.36", "Cookie": args.cookie} try: raw = requests.get(URL, headers = header, timeout=3, verify=False) raw = raw.content.decode("utf-8", "ignore") return raw except: return None
|
注意 verify=False 关闭了 SSL 证书校验,这在目标使用自签名证书或过期证书时很有用;timeout=3 控制超时,避免某个不可达的外链拖慢整体节奏。
4. 相对路径转绝对路径
JS 中大量使用相对路径,必须转换为绝对路径才能进一步访问和判断子域名归属:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| def process_url(URL, re_URL): black_url = ["javascript:"] URL_raw = urlparse(URL) ab_URL = URL_raw.netloc host_URL = URL_raw.scheme if re_URL[0:2] == "//": result = host_URL + ":" + re_URL elif re_URL[0:4] == "http": result = re_URL elif re_URL[0:2] != "//" and re_URL not in black_url: if re_URL[0:1] == "/": result = host_URL + "://" + ab_URL + re_URL else: if re_URL[0:1] == ".": if re_URL[0:2] == "..": result = host_URL + "://" + ab_URL + re_URL[2:] else: result = host_URL + "://" + ab_URL + re_URL[1:] else: result = host_URL + "://" + ab_URL + "/" + re_URL else: result = URL return result
|
该函数完整处理了 //、http、/path、./path、../path 五种情况,是后续 URL 去重和子域名判断的基础。
5. 主流程:从 URL 提取链接与子域名
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
| def find_by_url(url, js = False): if js == False: html_raw = Extract_html(url) if html_raw == None: return None html = BeautifulSoup(html_raw, "html.parser") html_scripts = html.findAll("script") script_array = {} script_temp = "" for html_script in html_scripts: script_src = html_script.get("src") if script_src == None: script_temp += html_script.get_text() + "\n" else: purl = process_url(url, script_src) script_array[purl] = Extract_html(purl) script_array[url] = script_temp allurls = [] for script in script_array: temp_urls = extract_URL(script_array[script]) if len(temp_urls) == 0: continue for temp_url in temp_urls: allurls.append(process_url(script, temp_url)) result = [] for singerurl in allurls: url_raw = urlparse(url) domain = url_raw.netloc positions = find_last(domain, ".") miandomain = domain if len(positions) > 1:miandomain = domain[positions[-2] + 1:] suburl = urlparse(singerurl) subdomain = suburl.netloc if miandomain in subdomain or subdomain.strip() == "": if singerurl.strip() not in result: result.append(singerurl) return result return sorted(set(extract_URL(Extract_html(url)))) or None
|
其中 find_last 函数通过查找域名中所有 . 的位置来提取主域名根——例如 a.b.example.com 经过 positions[-2] + 1 切片后得到 example.com,这样就能把所有 *.example.com 的子域名都识别为同根。
6. 子域名提取与结果输出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| def find_subdomain(urls, mainurl): url_raw = urlparse(mainurl) domain = url_raw.netloc miandomain = domain positions = find_last(domain, ".") if len(positions) > 1:miandomain = domain[positions[-2] + 1:] subdomains = [] for url in urls: suburl = urlparse(url) subdomain = suburl.netloc if subdomain.strip() == "": continue if miandomain in subdomain: if subdomain not in subdomains: subdomains.append(subdomain) return subdomains
|
giveresult 函数负责汇总输出,并支持将 URL 和子域名分别写入文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| def giveresult(urls, domian): if urls == None: return None print("Find " + str(len(urls)) + " URL:") content_url = "" content_subdomain = "" for url in urls: content_url += url + "\n" print(url) subdomains = find_subdomain(urls, domian) print("\nFind " + str(len(subdomains)) + " Subdomain:") for subdomain in subdomains: content_subdomain += subdomain + "\n" print(subdomain) if args.outputurl != None: with open(args.outputurl, "a", encoding='utf-8') as fobject: fobject.write(content_url) if args.outputsubdomain != None: with open(args.outputsubdomain, "a", encoding='utf-8') as fobject: fobject.write(content_subdomain)
|
使用方法与运行效果
基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| python JSFinder.py -u https://www.example.com
python JSFinder.py -u https://www.example.com -c "sessionid=xxxx"
python JSFinder.py -u https://www.example.com -d
python JSFinder.py -f urls.txt
python JSFinder.py -u https://www.example.com -ou urls.txt -os subdomains.txt
|
典型运行效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| url:https://www.example.com Find 47 URL: https://www.example.com/static/js/main.js https://api.example.com/v1/user/info https://test.example.com/debug https://cdn.example.com/static/img/logo.png ...
Find 6 Subdomain: www.example.com api.example.com test.example.com cdn.example.com admin.example.com oss.example.com
|
可以看到,从单个入口就能顺藤摸瓜找到 api、test、admin、oss 等高价值子域名——其中 test 和 admin 往往是渗透测试的重点突破口。
防御对策
作为蓝队,要从源头减少 JS 信息泄露:
- 构建时剔除敏感地址:在 CI/CD 流水线中配置规则,移除 JS 中的测试环境地址、内网 IP、调试接口。Webpack/Vite 有相关插件可自动替换环境变量。
- 接口域名集中管理:不要在前端硬编码多个子域名,统一走一个 API 网关域名,通过路由转发到内部服务。
- JS 代码混淆压缩:使用 terser、javascript-obfuscator 等工具对生产环境 JS 进行混淆,增加静态分析难度(注意这只能提高门槛,不能根本解决)。
- Subdomain Takeover 防护:定期盘点已解析但未实际使用的子域名 DNS 记录,及时清理,防止子域名被接管后用于钓鱼。
- 监控外部子域名收集:通过自建被动 DNS、证书透明度日志监控,及时发现暴露在外的新子域名。
总结
JSFinder 的实现并不复杂,但它的价值在于”用最简单的方式捡最大的漏”。在红队信息收集阶段,它往往能在字典爆破和搜索引擎都找不到新资产时,从 JS 文件中挖出意外的惊喜。
从代码层面看,这个工具的核心竞争力有三点:一是借鉴 LinkFinder 的高精度正则,匹配覆盖面广;二是完整的相对路径处理逻辑,保证提取结果可用;三是主域名根过滤机制,精准锁定同根资产。
在实际红队作业中,建议将 JSFinder 与字典爆破、crt.sh 证书查询、FOFA 等手段组合使用,形成多维度子域名收集矩阵,最大化信息收集的覆盖面。信息收集的广度,往往决定了后续渗透的深度。