一、问题背景:单页应用的异步接口坑哭DAST爬虫
做前端的朋友都知道,现在的单页应用(比如用Vue、React写的项目)和早年的多页应用完全不一样——早年的页面是换一个路由就刷新一次,爬虫能顺着链接一个一个爬;现在的单页应用,大部分路由切换都是前端自己搞的,不会真的刷新页面,甚至很多页面内容是等前端发异步请求拉到数据才渲染的。
这就给DAST爬虫(就是那种专门扫网站漏洞的自动化工具)出了大难题。比如你做的一个电商后台,有个「订单详情」的路由是/order/[id],id是从数据库拉的订单号,每次点不同订单才会发请求拉详情。DAST爬虫爬的时候,只会死盯着它能拿到的固定链接,根本不知道id能换,要么爬不到所有订单详情页,要么爬着爬着就卡——比如有的单页应用为了防止重复请求,会给每个异步请求加个临时的token,爬虫不知道怎么拿这个token,就会反复发无效请求,最后卡在死循环里,扫完了漏了一堆页面没扫,漏洞自然查不出来。
更麻烦的是,很多单页应用的路由是嵌套的,比如/user/[userId]/order/[orderId],一层套一层,爬虫根本摸不清规律。要是全靠自动化工具死磕,不仅慢,还容易漏扫,最后只能靠测试人员手动去点,效率低到爆炸。
二、核心痛点拆解:为什么DAST爬不了单页应用
要解决问题,得先搞懂DAST爬虫和单页应用的矛盾到底在哪,我把它拆成三个核心痛点:
2.1 动态路由的「不可枚举」问题
普通多页应用的路由是固定的,比如/about、/contact,爬虫能直接枚举出来;但单页应用的动态路由(比如/user/123、/product/456)的参数(id)是从后端接口返回的,不是写死在代码里的。DAST爬虫没法像人一样,先拉用户列表拿到所有userId,再去拼路由,自然爬不到所有动态路由对应的页面。
2.2 异步接口的「依赖前置」问题
单页应用的页面内容,很多是依赖前置接口返回的数据的。比如你要进「订单详情」页,得先登录(拿登录态),再拉订单列表(拿orderId),最后才能拉订单详情(发/api/order/[orderId]的请求)。DAST爬虫不会理这些依赖顺序,只会乱发请求,要么拿不到数据,要么触发接口的防刷机制,最后陷入死循环。
2.3 单页应用的「状态隔离」问题
DAST爬虫是无状态的,每次爬一个页面,都会开一个新的浏览器上下文(或者模拟一个新的请求),不会保留之前的操作状态。但单页应用的路由切换、页面渲染,都是依赖前端的全局状态(比如Vuex、Redux存的登录信息、用户权限)。爬虫换了上下文,之前的状态全没了,自然爬不了需要登录态的页面。
三、手工探索的解决方案:精准映射动态路由
既然自动化DAST爬不了,那我们可以用「手工探索+工具辅助」的方式,先把单页应用的所有动态路由都找出来,再喂给DAST去扫。这个方法的核心是「先摸清楚路由规律,再批量生成合法路由」,我拿一个真实的电商后台项目举例子,这个项目用的是Vue 3 + Vue Router + Pinia的技术栈,先把技术栈明确标出来: 技术栈:Vue 3 + Vue Router + Pinia + Axios
3.1 第一步:手工梳理动态路由的依赖逻辑
首先,我们得自己先跑一遍项目,摸清楚每个动态路由需要什么前置条件。比如这个电商后台的动态路由有三个:
- 用户详情页:
/user/[userId],前置条件是先拉用户列表(接口/api/user/list)拿到所有userId - 订单详情页:
/order/[orderId],前置条件是先拉订单列表(接口/api/order/list)拿到所有orderId - 商品详情页:
/product/[productId],前置条件是先拉商品分类(接口/api/category/list),再拉对应分类下的商品列表(接口/api/product/list?categoryId=[categoryId])拿到所有productId
这个步骤很关键,要是摸错了依赖,后面生成的路由全是无效的。比如商品详情页的productId不是直接拉的,是先拉分类再拉商品,要是直接去猜productId,肯定会漏很多。
3.2 第二步:用浏览器工具导出合法的路由参数
摸清楚依赖逻辑后,我们就可以用浏览器的开发者工具,把所有合法的路由参数导出来。这里我们可以用Chrome的「Network」面板,过滤出对应的接口,然后导出返回的数据。
比如要拉所有userId,我们可以这么做:
- 打开Chrome开发者工具,切换到「Network」面板
- 过滤出
/api/user/list接口,点击「Fetch/XHR」,只看异步请求 - 刷新用户列表页,等接口返回成功,点击接口的「Response」标签,看到返回的JSON数据
- 把JSON数据复制出来,保存成
user_list.json
这里我给一个/api/user/list接口的返回示例:
{
"code": 200,
"message": "success",
"data": [
{
"userId": 1001,
"username": "张三",
"role": "admin"
},
{
"userId": 1002,
"username": "李四",
"role": "user"
},
{
"userId": 1003,
"username": "王五",
"role": "user"
}
]
}
3.3 第三步:批量生成合法的动态路由
拿到参数后,我们可以写一个简单的Node.js脚本,把参数拼成合法的动态路由。这个脚本的作用就是读JSON文件,提取参数,拼路由,最后输出成一个路由列表文件。
下面是完整的脚本代码,加了详细的注释:
// 引入Node.js的fs模块,用来读写文件
const fs = require('fs');
// 引入path模块,用来处理文件路径
const path = require('path');
// -------------------------- 配置区:根据项目修改这里 --------------------------
// 项目的基础域名(比如你的项目是本地跑的,就是http://localhost:3000)
const baseUrl = 'http://localhost:3000';
// 要生成的路由类型:user(用户详情)、order(订单详情)、product(商品详情)
const routeType = 'user';
// JSON文件的路径:对应之前导出的user_list.json、order_list.json等
const jsonFilePath = path.join(__dirname, 'user_list.json');
// 输出的路由列表文件路径
const outputFilePath = path.join(__dirname, 'dynamic_routes.txt');
// -------------------------- 配置区结束 --------------------------
// 定义路由模板:根据路由类型返回对应的模板
function getRouteTemplate(type) {
switch(type) {
case 'user':
// 用户详情页的模板:/user/[userId]
return `${baseUrl}/user/`;
case 'order':
// 订单详情页的模板:/order/[orderId]
return `${baseUrl}/order/`;
case 'product':
// 商品详情页的模板:/product/[productId]
return `${baseUrl}/product/`;
default:
throw new Error('不支持的路由类型');
}
}
// 定义提取参数的方法:根据路由类型从JSON数据里提取对应的参数
function extractParams(type, jsonData) {
switch(type) {
case 'user':
// 从user_list的data数组里提取所有userId
return jsonData.data.map(item => item.userId);
case 'order':
// 从order_list的data数组里提取所有orderId
return jsonData.data.map(item => item.orderId);
case 'product':
// 从product_list的data数组里提取所有productId
return jsonData.data.map(item => item.productId);
default:
throw new Error('不支持的路由类型');
}
}
// 主逻辑:生成路由
try {
// 读取JSON文件的内容
const jsonContent = fs.readFileSync(jsonFilePath, 'utf8');
// 把JSON内容解析成对象
const jsonData = JSON.parse(jsonContent);
// 获取路由模板
const routeTemplate = getRouteTemplate(routeType);
// 提取所有合法的参数
const params = extractParams(routeType, jsonData);
// 把参数拼成完整的路由,每行一个
const routes = params.map(param => `${routeTemplate}${param}`).join('\n');
// 把路由列表写入输出文件
fs.writeFileSync(outputFilePath, routes, 'utf8');
// 打印成功提示
console.log(`动态路由生成成功,共${params.length}条,已保存到${outputFilePath}`);
} catch (error) {
// 捕获错误并打印
console.error('生成动态路由失败:', error.message);
}
这个脚本怎么用呢?很简单:
- 先装Node.js(要是没装的话,去官网下一个,一路默认安装就行)
- 把脚本保存成
generate_routes.js,放在和user_list.json同一个文件夹里 - 打开命令行(Windows是CMD,Mac是终端),切换到这个文件夹,运行命令:
node generate_routes.js
- 运行成功后,文件夹里会生成一个
dynamic_routes.txt文件,里面就是所有合法的用户详情页路由,比如:
http://localhost:3000/user/1001
http://localhost:3000/user/1002
http://localhost:3000/user/1003
要是要生成订单详情页的路由,只需要把脚本里的routeType改成order,jsonFilePath改成order_list.json,再运行一遍就行。
3.4 第四步:把动态路由喂给DAST爬虫扫漏洞
生成的dynamic_routes.txt里的路由,都是合法的、需要登录态才能访问的路由。接下来我们只需要把这个路由列表喂给DAST爬虫,让它按列表扫就行。
这里我拿常用的DAST工具OWASP ZAP(一个开源的漏洞扫描工具)举例子,怎么导入路由列表:
- 打开OWASP ZAP,新建一个会话,目标是你的项目域名(比如
http://localhost:3000) - 先配置登录态:因为你的路由需要登录才能访问,所以要在ZAP里配置登录的规则——比如先登录拿到Cookie,或者配置登录接口的参数,让ZAP能自动登录
- 配置好登录态后,找到「主动扫描」功能,在扫描的目标里,选择「导入URL列表」,然后选中你生成的
dynamic_routes.txt - 启动扫描,ZAP就会按列表里的路由一个一个扫,不会再陷入死循环,也不会漏扫动态路由对应的页面
四、手工探索的优缺点分析
4.1 优点
- 准确率高:手工梳理的依赖逻辑,不会像自动化爬虫那样摸错规律,生成的路由全是合法的,扫出来的漏洞也是真实存在的,不会有假阳性
- 适配性强:不管你的单页应用的动态路由有多复杂,只要能手工摸清楚依赖逻辑,就能生成对应的路由列表,适配所有类型的单页应用
- 效率比纯手工高:要是你的项目有几百个动态路由,纯手工点着扫漏洞肯定要花大半天,用这个方法只要10分钟就能生成路由列表,让工具自动扫
4.2 缺点
- 依赖手工操作:要是项目迭代快,动态路由的依赖逻辑变了,就得重新梳理、重新生成路由列表,不能完全自动化
- 覆盖范围依赖手工梳理的完整性:要是手工梳理的时候漏了某个依赖逻辑,生成的路由就会漏扫对应的页面,比如商品详情页的分类没摸全,就会漏扫一部分商品详情页
- 不适用于小型项目:要是你的项目只有几个动态路由,用这个方法反而比纯手工扫还麻烦,多了写脚本、导数据的步骤
五、应用场景与注意事项
5.1 适用场景
这个方法特别适合这几类项目:
- 复杂的单页应用后台:比如电商后台、管理系统、OA系统,这类项目的动态路由多,依赖逻辑复杂,自动化DAST很难覆盖
- 有严格安全要求的项目:比如金融类、医疗类的项目,对漏洞扫描的准确率要求高,不能有假阳性,也不能漏扫
- 迭代周期稳定的项目:要是项目每个月迭代一次,动态路由的逻辑不会天天变,这个方法的维护成本就很低
5.2 注意事项
- 登录态的有效期:生成路由的时候,你是用自己的账号登录项目导出的接口数据,要是账号的登录态过期了,DAST爬虫扫的时候就会拿不到数据,所以要在扫之前确认登录态是有效的,或者配置DAST自动登录
- 测试环境的隔离:不要在生产环境做这个操作,要在测试环境导出接口数据、生成路由、扫漏洞,避免影响生产环境的正常运行
- 接口数据的完整性:导出接口数据的时候,要确认接口返回的是全量数据,不是分页的第一页,比如用户列表接口要是分页的,得把所有页的数据都导出来,不然生成的路由会漏
- 脚本的适配性:要是你的动态路由的参数是字符串(比如用户名、商品名),只需要修改脚本里的
extractParams方法,提取对应的字符串参数就行,不需要改脚本的核心逻辑
六、方法优化:半自动化的探索方式
要是觉得手工梳理依赖逻辑太麻烦,我们可以用一些工具来辅助,比如用Chrome的扩展程序「Vue DevTools」或者「React DevTools」,能直接看到前端的全局状态,比如Pinia存的用户列表、订单列表,不用再去Network面板导数据,直接从DevTools里复制就行。
另外,要是你的项目用了Vue Router,我们还可以写一个简单的前端脚本,放在浏览器的控制台里运行,直接导出所有动态路由。比如下面这个脚本,能导出Vue 3项目里所有的动态路由:
// 从Vue实例里拿到Vue Router的实例
const router = window.__VUE_APP__.config.globalProperties.$router;
// 拿到所有路由配置
const routes = router.getRoutes();
// 过滤出动态路由(动态路由的path里有:xxx的格式)
const dynamicRoutes = routes.filter(route => route.path.includes(':'));
// 打印动态路由的模板
console.log('动态路由模板:', dynamicRoutes.map(route => route.path));
运行这个脚本,就能直接看到项目里所有动态路由的模板,不用再手工梳理,能节省很多时间。
七、总结
单页应用的动态路由和异步接口,确实给DAST爬虫带来了很大的挑战,但只要我们换个思路,不用硬磕自动化爬虫,而是用「手工探索+工具辅助」的方式,先摸清楚路由的依赖逻辑,再批量生成合法的动态路由,最后喂给DAST爬虫扫漏洞,就能解决这个问题。
这个方法的核心是「以人为主,工具为辅」,利用人的逻辑判断能力,弥补自动化爬虫的不足,既能保证漏洞扫描的准确率,又能提高效率。要是你的项目是复杂的单页应用,又被DAST爬虫的死循环问题困扰,不妨试试这个方法。
评论
围绕“单页应用大量异步接口让DAST爬虫陷入死循环,手工探索模式如何精准映射动态路由并完成漏洞验证”参与讨论