python

子域名挖掘(JSFinder实现):从JS文件中揪出隐藏的攻击面

2026-07-06 #python

本文仅供合法授权的安全研究与学习使用,请勿用于非法用途。读者应确保行为符合当地法律法规。

前言

在红队渗透测试的信息收集阶段,子域名挖掘是绕不开的一环。一个大型企业往往拥有成百上千个子域名,而这些子域名中常常隐藏着测试环境、老旧系统、未授权接口等高价值攻击面——它们往往是边界突破的突破口。

传统的子域名收集手段主要有三种:字典爆破(如 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 源码里捞出来,成本极低、收益却往往出奇的高。

核心技术点

  1. HTML 解析:使用 BeautifulSoup 提取页面中的 <script> 标签,获取内联 JS 和外部 JS 引用
  2. 正则表达式提取链接:借鉴 LinkFinder 项目的高精度正则,从 JS 字符串中匹配 URL
  3. 相对路径处理:将 ///path./path../path 等相对路径转换为绝对路径
  4. 主域名校验:通过判断提取到的 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 # //cdn.example.com → https://cdn.example.com
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 # /api/user → https://host/api/user
else:
if re_URL[0:1] == ".":
if re_URL[0:2] == "..":
result = host_URL + "://" + ab_URL + re_URL[2:] # ../api → https://host/api
else:
result = host_URL + "://" + ab_URL + re_URL[1:] # ./api → https://host/api
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" # 内联 JS 直接取文本
else:
purl = process_url(url, script_src)
script_array[purl] = Extract_html(purl) # 外部 JS 请求其内容
script_array[url] = script_temp
allurls = []
for script in script_array:
temp_urls = extract_URL(script_array[script]) # 正则提取每个 JS 中的 URL
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:] # 取主域名根,如 www.baidu.com → baidu.com
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
# 扫描单个网站,提取 JS 中的 URL 和子域名
python JSFinder.py -u https://www.example.com

# 带上 Cookie 扫描登录后页面
python JSFinder.py -u https://www.example.com -c "sessionid=xxxx"

# 深度扫描(递归爬取页面中的 a 标签链接再分析)
python JSFinder.py -u https://www.example.com -d

# 从文件批量读取 URL 进行扫描
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

可以看到,从单个入口就能顺藤摸瓜找到 apitestadminoss 等高价值子域名——其中 testadmin 往往是渗透测试的重点突破口。

防御对策

作为蓝队,要从源头减少 JS 信息泄露:

  1. 构建时剔除敏感地址:在 CI/CD 流水线中配置规则,移除 JS 中的测试环境地址、内网 IP、调试接口。Webpack/Vite 有相关插件可自动替换环境变量。
  2. 接口域名集中管理:不要在前端硬编码多个子域名,统一走一个 API 网关域名,通过路由转发到内部服务。
  3. JS 代码混淆压缩:使用 terser、javascript-obfuscator 等工具对生产环境 JS 进行混淆,增加静态分析难度(注意这只能提高门槛,不能根本解决)。
  4. Subdomain Takeover 防护:定期盘点已解析但未实际使用的子域名 DNS 记录,及时清理,防止子域名被接管后用于钓鱼。
  5. 监控外部子域名收集:通过自建被动 DNS、证书透明度日志监控,及时发现暴露在外的新子域名。

总结

JSFinder 的实现并不复杂,但它的价值在于”用最简单的方式捡最大的漏”。在红队信息收集阶段,它往往能在字典爆破和搜索引擎都找不到新资产时,从 JS 文件中挖出意外的惊喜。

从代码层面看,这个工具的核心竞争力有三点:一是借鉴 LinkFinder 的高精度正则,匹配覆盖面广;二是完整的相对路径处理逻辑,保证提取结果可用;三是主域名根过滤机制,精准锁定同根资产。

在实际红队作业中,建议将 JSFinder 与字典爆破、crt.sh 证书查询、FOFA 等手段组合使用,形成多维度子域名收集矩阵,最大化信息收集的覆盖面。信息收集的广度,往往决定了后续渗透的深度。

评论
分享