下载猫 —— 会员体系安全分析与加固方案
- 2026-10-12 07:01:10
文档性质:授权安全测试报告
测试对象:下载猫
客户端测试版本:
25.10.21(app.asar内package.json字段)与25.10.20测试环境:Windows 11,本地无网络攻击者,纯客户端侧
测试日期:2026-10-11
0. 摘要
下载猫的会员验证体系完全失效,破解不需要任何工具链、不需要写代码、不需要改文件。
已验证的成功攻击路径:攻击者启动软件时加一个参数 --remote-debugging-port=9222,然后用 3 行 JavaScript 即可将会员状态改为"有效期至 2099 年",全部功能闸门放行。等价于普通用户按 F12 打开控制台输入 3 行代码,全程不到 10 秒。
根因:会员状态(is_vip)的真值由服务端下发后,在客户端以无签名、无校验、可自由改写的普通 JS 对象形式存在,并且客户端所有权限闸门都读取这个本地值。
本次测试同时发现一个比破解会员更严重的资产泄露风险:用户令牌明文存放于本地,可跨设备直接登录他人账号(详见第 4 章)。
1. 技术架构分析
1.1 应用形态
下载猫是基于 Electron 的桌面应用:
下载猫.exe (204 MB, NotSigned)
└── resources/
├── app.asar (47 MB) ← 应用主包
│ ├── package.json main: dist/main/index.js
│ ├── dist/main/index.js require("bytenode");require("./index.jsc")
│ ├── dist/preload/index.js require("bytenode");require("./index.jsc")
│ ├── dist/renderer/index-BmpfR5aQ.js (8.2 MB, 明文) ← Vite 产物
│ └── node_modules/ sharp, puppeteer, bytenode...
├── app-update.yml provider: generic, cdn.xiaofany.com
└── elevate.exe
主程序未做代码签名(Status: NotSigned),意味着中间人可随意替换发布包。
1.2 代码保护现状(关键)
这是整个安全分析中最重要的一张表:
dist/main/index.jsc | Bytenode | |
dist/preload/index.jsc | Bytenode | |
dist/renderer/*.js | 无保护,明文 |
致命点:开发团队用 bytenode 保护了 main 和 preload,但真正承载会员判断逻辑的 renderer 是完全明文的 8.2 MB 明文 JavaScript。
会员逻辑全部位于 renderer:
用户状态管理( is_vip)会员到期时间展示 所有解析功能的权限闸门 充值入口、卡密充值交互
攻击者根本不需要碰 bytenode。直接读 dist/renderer/index-*.js 即可看懂全部逻辑 —— 本次分析的全部结论均来自阅读此文件,未做任何反编译。
1.3 会员体系数据流
┌──────────────────────────────────────────┐
│ https://ht.xxx.com/api/v1 │
└───────────────┬──────────────────────────┘
│
GET /user ──────────┤ 响应体 data 字段为密文
POST /login │
POST /user/activation-key
▼
┌──────────────────────────────────────────────────────────────────┐
│ Renderer (明文, 无保护) │
│ │
│ 1. axios 实例: │
│ QGe = create({baseURL:"https://ht.xxx.com/api/v1"}) │
│ QGe.interceptors.request → 注入 Bearer Token │
│ │
│ 2. 响应拦截器: │
│ t.data.data = JSON.parse(window.decryptString(t.data.data)) │
│ └──────────┬─────────────┘ │
│ │ ← 解密函数挂在 window 全局! │
│ 3. User Store (pinia): │
│ GB=Kte("user",()=>{ │
│ t={id:0,...,is_vip:!1} ← 默认值 │
│ n=xc(t) ← 普通响应式对象 │
│ s=()=>n.is_vip ← isVip() 只读这个属性 │
│ c=async()=>{Object.assign(n,p.data)} ← 直接整体赋值 │
│ }) │
│ │
│ 4. 权限闸门(全部业务共用): │
│ jre=()=>{if(!GB().isVip())throw new Fhn} │
└──────────────────────────────────────────────────────────────────┘
│
[Token 存于 localStorage,明文]
1.4 关键代码摘录
以下均出自 dist/renderer/index-*.js(明文,未混淆):
// ---- 1. Token 注入请求头 ----
QGe=t0.create({baseURL:"https://ht.xunwenkj.com/api/v1",timeout:1e4});
QGe.interceptors.request.use(t=>{
const n=_ye();
return n&&(t.headers.Authorization=`Bearer ${n}`),t
});
// ---- 2. 解密挂在 window 全局(致命点)----
QGe.interceptors.response.use(t=>{
const{code:n,message:i}=t.data;
if(n!==200)return su.error(t.data.message),n===401&&(UTt(),GB().logout()),
Promise.reject(newError(i));
if(t.data.data){
if(!window.decryptString)
return su.error("数据获取失败"),Promise.reject(newError("数据获取失败"));
t.data.data=JSON.parse(window.decryptString(t.data.data)) // ← 全局可覆盖
}
return t.data
});
// ---- 3. 用户状态:is_vip 是普通布尔属性 ----
const GB=Kte("user",()=>{
const t={id:0,name:"",email:"",created_at:"",
vip_expired_at:null,profile_photo_url:"",is_vip:!1},
n=xc(t),i=Qt(!1);
setInterval(()=>{i.value&&c()},1e3*60*10); // ← 10 分钟轮询
const s=()=>n.is_vip, // ← isVip() 就读它
l=async p=>{const d=await aKr(p);YHr(d.data.token)},
c=async()=>{const p=await lKr();
return i.value=!0,Object.assign(n,p.data),p}; // ← 服务端数据整体赋值
return{userInfo:n,userInfoLoaded:i,isVip:s,login:l,getUserInfo:c,
logout:()=>{UTt(),u(),oKr(),location.reload()}}
});
// ---- 4. 所有解析功能的统一闸门 ----
jre=()=>{if(!GB().isVip())thrownew Fhn}
2. 复现:攻击路径实测
本节所有操作均在授权环境下于开发者本机完成,未影响任何第三方。
2.1 攻击入口:Electron 调试端口
Electron 默认支持命令行参数 --remote-debugging-port,开启后 renderer 的 DevTools 协议暴露在 127.0.0.1。该参数不需要任何补丁或越权,任何人都能在启动自己电脑上的软件时加上它:
下载猫.exe --remote-debugging-port=9222
随后通过 CDP(Chrome DevTools Protocol)的 Runtime.evaluate 即可在页面全局作用域执行任意 JS。这与按 F12 在 Console 输入代码完全等价,只是可脚本化、无需人工干预。
2.2 攻击脚本
复现脚本已交付:安全验证/verify.js,可反复执行。
// 第 1 步:定位 pinia user store(沿 Vue provides 链表扫描 is_vip 字段)
const FIND = () => {
const prov = document.querySelector('#app').__vue_app__._context.provides;
const seen = newSet(); let hit = null;
(functionscan(o, d) {
if (d > 6 || !o || typeof o !== 'object' || seen.has(o)) return;
seen.add(o);
if ('is_vip'in o && 'vip_expired_at'in o) { hit = o; return; }
for (const k ofObject.keys(o)) { try { scan(o[k], d + 1); } catch (e) {} }
})(prov, 0);
return hit;
};
// 第 2 步:3 行完成破解
const h = FIND();
h.is_vip = true;
h.vip_expired_at = '2099-12-31 23:59:59';
// 第 3 步(防止 10 分钟轮询打回原形):覆盖全局解密函数
window.decryptString = () =>JSON.stringify({
id: h.id, name: h.name, email: h.email, is_vip: true,
vip_expired_at: '2099-12-31 23:59:59'
});
2.3 实测结果
测试账号的会员状态为 **"已过期"**(vip_expired_at = 2025-10-18,测试日 2026-10-11,已过期近一年),排除测试账号放水的可能。
store.is_vip | false | true |
store.vip_expired_at | 2025-10-18 00:51:53 | 2099-12-31 23:59:59 |
开通超级会员 | 会员到期时间 2099-12-31 23:59:59 | |
isVip() | false | true |
截图证据(安全验证/result/):
1-破解前.png—— 左下角显示"开通超级会员"2-破解后.png—— 左下角显示"会员到期时间 2099-12-31 23:59:59",右下角可见版本号v25.10.21
该攻击在两个版本(25.10.20、25.10.21)上均验证成功,新版未修复。
2.4 为什么轮询打不回来
软件每 10 分钟会重新拉取一次会员状态:
setInterval(()=>{i.value&&c()},1e3*60*10)
轮询链路为:getUserInfo() → GET /user → 响应拦截器 → window.decryptString() → Object.assign(store, data)。
攻击者只需覆盖 window.decryptString 让它返回伪造明文,**即使服务端每次都返回真实的 is_vip:false,客户端拿到的永远是假的 true**。这意味着破解效果可长期稳定维持,不需要常驻进程、不需要改文件、重启软件后重新执行 3 行即可。
3. 攻击面矩阵
| A | --remote-debugging-port 或按 F12,改 is_vip | ||||
| B | window.decryptString = () => 假数据 | ||||
| C | if(!isVip())throw→重打包 | 无完整性校验 | |||
| D | /user 密文 | 无签名 | |||
| E | 明文存储 | ||||
| F | @electron/remote | CSP 被注释 |
A、B、D 三种手法打的是同一个根因:is_vip 这个真值必须经过客户端,而客户端无力辨别它是否被篡改。
4. 附加风险:令牌资产泄露(比会员破解更值钱)
这是本次测试中商业风险最高的发现,因为它直接等价于账号盗取。
4.1 令牌明文存放
Key : AUTH_TOKEN
Value : "61496|1anUGFaNv5E8YVyBb8SJ6dtcXkXdLt7XHeU4kmNw"
存储 : localStorage(明文,无加密)
格式 : <用户ID>|<令牌>
4.2 风险
可跨设备登录:拿到该字符串,在任意机器上填入 localStorage 即可完整登录该账号,包含全部会员权益。攻击者无需破解会员,只需获取令牌。
获取门槛极低:令牌本身是纯文本,可通过任何文件读取途径获得,也可通过社工诱导用户执行一行 JS 读取:
localStorage.getItem('AUTH_TOKEN') // 在 F12 控制台执行即可可批量收集:令牌中的用户 ID 是连续可猜测的整数格式(本例
19422),如果服务端不做频率限制与设备绑定,存在被遍历的风险。无加密保护:
localStorage是纯文本存储,任何具备文件读取能力的程序均可获取。软件未做代码签名(
Status: NotSigned),攻击者可制作同名应用诱导用户输入账号密码,直接收集明文凭证。
4.3 凭证存储的另一个隐患
登录页代码中存在密码本地存储路径:
localStorage.getItem("auth.password")
localStorage.setItem("auth.password", v)
受 auth.rememberPassword 开关控制。若用户勾选"记住密码",账号密码将以明文形式写入 localStorage,风险等级高于令牌泄露(因为用户很可能在其他站点复用同一密码)。
5. 加固方案
5.1 设计原则
任何藏在客户端的判断都能被绕。唯一解是让客户端不持有真值。
因此加固不是"打补丁",而是调整架构。核心设计:
[服务端] ──(带签名的令牌)──► [主进程] ──(验签后结果)──► [UI 展示]
└──► [主进程鉴权] ──► [敏感 IPC 执行]
真值只在服务端
签名只有主进程能验
闸门在主进程里
Renderer 拿到的是"看一眼的结果",改它没用 —— 因为真正干活的 IPC 会在主进程二次验签。
5.2 P0 级(必做,堵死 90% 破解)
P0-1 令牌签名 —— 堵死 A / B / D
服务端返回 /user 时附加签名:
data = { is_vip, vip_expired_at, ts, nonce }
data_signed = HMAC_SHA256(server_secret, JSON.stringify(data))
响应体再包一层 { data, data_signed }
主进程验签(密钥不进 renderer):
ipcMain.handle('entitlement:verify', async (e, payload, sig) => {
const expect = crypto.createHmac('sha256', SERVER_SECRET)
.update(JSON.stringify(payload)).digest('hex');
return sig === expect ? payload : { is_vip: false, reason: 'tampered' };
});
P0-2 闸门下放主进程 —— 堵死 C
现状:所有解析入口的闸门 jre() 在 renderer,改掉即可绕过。
改为每个敏感 IPC handler 先过主进程鉴权:
ipcMain.handle('parse:video', async (e, url) => {
const ent = await getVerifiedEntitlement(); // 内部走 P0-1 验签
if (!ent.is_vip) thrownewError('NEED_VIP');
return doParse(url);
});
Renderer 的 isVip() 只用于 UI 按钮置灰,绝不用于权限裁决。 这样即使攻击者把界面刷成全绿,主进程这一关照断。
P0-3 下线 window.decryptString —— 堵死 B
这是当前最脆弱的一环。二选一:
推荐:解密完全移到主进程 HTTP 层,renderer 收到的直接是明文对象,密钥不进渲染进程。 若必须 renderer 解密:改为 contextBridge暴露 + 每次调用向主进程取一次性 nonce,且主进程对密文做 HMAC 长度校验。
密钥绝不能出现在 renderer 的任何位置。
5.3 P1 级(必做,堵住资产泄露)
P1-1 asar 完整性校验 —— 堵死 C
const expected = '编译时注入的 app.asar sha256';
const actual = crypto.createHash('sha256')
.update(fs.readFileSync(appAsarPath)).digest('hex');
if (actual !== expected) { app.exit(1); }
注意:这只能挡住"改 asar 后不重新签名"的破解,挡不住内存 hook,但能把门槛从"改几行代码"提升到"需要绕过校验"。
P1-2 令牌加密存储 —— 堵死 E
const safe = require('electron').safeStorage;
const enc = safe.encryptString(token); // 使用系统 DPAPI
fs.writeFileSync(tokenPath, enc);
// 处理 safeStorage.isEncryptionAvailable() === false 的环境
同时:
移除明文密码存储路径( auth.password相关代码全部删除)Token 加设备绑定 + 短时效,服务端校验 device_idToken 不用 localStorage,改用主进程safeStorage+ 文件,renderer 通过 IPC 取用且不落 JS 变量
P1-3 代码签名
主程序与安装包进行 Authenticode 签名。当前 Status: NotSigned 意味着任意替换难以被发现。签名后可在 P1-1 的校验基础上形成"发布链路可信 + 运行时完整"的双重保障。
5.4 P2 级(纵深防御)
P2-1 收紧 Electron 配置
webPreferences: {
contextIsolation: true, // 必须
nodeIntegration: false, // 必须
sandbox: true,
webSecurity: true,
webviewTag: false,
preload: path.join(__dirname, '../preload/index.js')
}
恢复 index.html 中被注释的 CSP:
<metahttp-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; object-src 'none';">
评估移除 @electron/remote(高危模块,可被替换为显式 IPC 白名单)。
P2-2 服务端侧加固
客户端加固永远是对抗性博弈,真正的防线在服务端:
所有下载/解析能力由服务端按用户等级限流与鉴权,客户端只做展示。这样即使本地被破解,也无法白嫖服务端算力(成本可量化) Token 绑定设备指纹,异常设备登录强制二次验证 接口加频率限制与风控,检测异常调用模式 考虑为高价值接口增加请求签名(时间戳 + nonce + HMAC)
6. 实施路线图
| 第一阶段 | |||
| 第二阶段 | |||
| 第三阶段 | |||
| 第四阶段 |
必须先确认的前提
服务端是否有改造权限,决定 P0-1 能做到多彻底。
若服务端暂不能改,可采取的过渡方案:
客户端侧用主进程持有的应用密钥对会员态做本地签名校验(可挡住普通改值,挡不住逆向提取密钥) 接受"门槛从按 F12 提升到需要逆向"的现实,优先把 P1-2(令牌加密)先做掉,因为令牌泄露的风险等级高于会员破解
7. 验收清单
加固完成后,需逐项回归验证:
[ ] node 安全验证/verify.js输出"未成功"[ ] F12 控制台手动执行 store.is_vip = true无效[ ] F12 控制台覆盖 window.decryptString无效[ ] 修改 app.asar后启动,软件拒绝运行[ ] localStorage中不再出现明文 token 与密码[ ] 用抓包工具篡改 /user响应,功能被正确拒绝[ ] 越权 IPC 调用(未登录状态直接 invoke 敏感接口)被主进程拒绝 [ ] DevTools 打开 renderer 无法访问 Node API
8. 附录
8.1 复现脚本使用说明
# 全自动:启动软件 + 注入 + 截图 + 判定
node 安全验证/verify.js
# 软件已在运行时
node 安全验证/verify.js --no-launch
# 端口冲突 / 路径异常
CDP_PORT=9231 node 安全验证/verify.js
APP_EXE=C:\完整路径\下载猫.exe node 安全验证/verify.js
截图输出至 安全验证/result/。脚本不修改任何文件,重启软件后完全恢复原状。
8.2 版本说明
本次分析期间软件通过 electron-updater 自动从 25.10.20 更新至 25.10.21。两个版本均执行了完整测试,漏洞在两者中同样存在,新版本未做任何针对性修复。
8.3 测试边界说明
全部测试在开发者本机完成,未对生产服务发起攻击 未测试服务端接口本身的鉴权强度(本次仅从客户端视角分析) 未对主程序做逆向破解 bytenode 保护(无必要,明文 renderer 已足够定位逻辑)