从 Electron 授权链路看一次本地 VIP 绕过
这次看的是一个 Electron 桌面客户端的 VIP 注册机制。现象很直接:应用提供 VIP 功能,正常情况下需要在线名单或离线许可证才能解锁;但在本地分析后,可以通过修改客户端授权判定点,让程序稳定进入 VIP 状态。
这类问题的重点不在"能不能构造一个漂亮的注册码",而在客户端授权链路本身:当最终授权结论由用户本地可修改的 JavaScript 代码产出时,本地保护做得再复杂,也很容易在最后一个布尔返回值上被截断。
攻击面入口:Electron 的 ASAR 包
目录结构里能看到典型 Electron 应用形态:
text
thoughts/
├── 思绪思维导图.exe
└── resources/
└── app.asar
exe 更像运行壳,真正的业务逻辑被打在 resources/app.asar 里。解包后可以看到主进程代码、前端 bundle 和静态资源。
package.json 指向开源项目:
text
https://github.com/wanglin2/mind-map
version: 0.19.0
但对比对应 tag 后发现,开源版本里没有这套 VIP 主逻辑。也就是说,授权代码是发行包里额外加入的,分析时应以当前 ASAR 运行时代码为准,而不是以 GitHub 源码为准。
这个判断很关键。挖客户端洞时,源码仓库只能帮助理解框架和业务背景,真正的攻击面永远是当前用户机器上实际执行的产物。
授权链路:VIP 状态不是一个单点开关
前端 bundle 中的 VIP 判断逻辑大致是三段式:
text
在线 VIP 名单
↓ 未命中
本地 VIP 缓存
↓ 未命中
本地离线许可证
在线逻辑会请求:
text
https://simple-mind-map.oss-cn-beijing.aliyuncs.com/vip.json
返回内容是客户端 UUID 白名单。当前机器的 getClientUUID() 如果在名单里,前端会保存一个短期本地 VIP 状态:
js
saveVIPStatus({
isVip: true,
expire: Date.now() + 2592e6
})
这是一种常见授权设计:在线名单负责快速放行,本地缓存负责离线续期,离线许可证负责长期授权。
从攻击视角看,重点不是这三条路径哪条最容易伪造,而是它们最后都会汇聚成同一个事实:
text
前端是否认为当前用户是 VIP
所以真正值得追的是状态汇聚点,而不是一开始就陷进某个数据源里。
缓存保护:为什么不直接改本地 JSON
主进程暴露了和 VIP 缓存相关的 IPC:
text
getVIPStatus
saveVIPStatus
本地数据目录在:
text
C:\Users\15225\AppData\Roaming\thoughts\storage
如果这里保存的是明文 JSON,那直接写入类似下面的数据就可能完成绕过:
json
{
"isVip": true,
"expire": 9999999999999
}
但实际不是这样。主进程对本地状态做了两类保护。
第一类是加密存储。敏感状态通过 AES-256-CBC 保存,如果系统支持 Electron safeStorage,还会用 safeStorage 保护本地加密密钥;否则才回退到固定 key。
第二类是机器绑定。读取 VIP 缓存时,会校验缓存中的 clientUUID 是否等于当前机器的 machineId()。
所以本地缓存实际更接近:
text
AES 加密数据 + safeStorage 保护的密钥 + machineId 绑定 + expire 过期时间
这套设计的目的很明确:防止复制别人的 VIP 缓存,或者手动篡改本地 JSON。
从漏洞利用角度看,硬写缓存不是不能做,但成本被抬高了。需要复现加密流程、处理 safeStorage、填对机器码,还要保持字段结构和过期时间都符合预期。这个方向工作量大,而且和漏洞本质关系不大。
离线许可证:RSA 验签挡住了"真注册机"
主进程里还有离线许可证逻辑:
text
activateLicense
getIsLicenseVIP
许可证数据保存到一个加密存储键中:
text
1cJwF4oVPI8XCMkphqjG
核心逻辑可以抽象成:
js
license = readLocalLicense()
result = verifyByPublicKey(license.licenseCode)
return result.success
这里使用的是 RSA 公钥验签。正常授权模型如下:
text
服务端或管理员持有私钥
↓
签发 licenseCode
↓
客户端内置公钥
↓
验证 licenseCode 签名
公钥能验证签名,但不能生成合法签名。只要私钥不在客户端里,且签名实现没有明显漏洞,就不能从客户端直接推导出任意合法 licenseCode。
这也是这类题最容易误判的点。看到 activateLicense,第一反应可能是"逆注册码算法";但 RSA 验签已经说明它不是简单的 hash、xor 或固定格式拼接。客户端只有验签能力,没有签发能力。
所以严格来说,这里做不出真正意义上由私钥签发的"合法注册码生成器"。更实际的路线是做本地授权补丁器。
漏洞点:前端信任主进程的布尔结论
虽然缓存和许可证都有保护,但前端并不理解这些细节。前端最后只需要一个结论:
text
当前是不是 VIP?
这个结论来自主进程 IPC。
关键代码如下:
js
r.ipcMain.handle("getIsLicenseVIP", async () => {
const e = await C("1cJwF4oVPI8XCMkphqjG")
return !(!e || !e.licenseCode) && (await R(e.licenseCode)).success
})
这段代码是离线许可证链路的最后一跳:
text
读 license
↓
检查 licenseCode
↓
RSA 公钥验签
↓
返回 true / false
对前端来说,IPC 返回的 boolean 就是最终事实。前端不会再验签,也不会二次读取许可证。只要这个 handler 返回 true,前端就会认为本机具有 VIP 权限。
因此最小补丁是:
js
r.ipcMain.handle("getIsLicenseVIP", async () => !0)
这不是破解 RSA,也不是伪造 licenseCode,而是直接修改了本地可信结论的产出点。
漏洞根因可以概括为一句话:
text
授权结果由本地可修改代码最终裁决,缺少不可绕过的服务端强制校验或完整性约束。
只要攻击者能改本地客户端代码,任何纯本地授权判断最终都可以被改写。缓存加密和 RSA 验签能保护"授权数据",但不能保护"使用授权数据的本地判断逻辑"。
利用思路:等长补丁比重打包更轻
常规做法可以是:
text
解包 app.asar
修改 background.js
重新打包 app.asar
但这里补丁点非常短,适合做等长二进制替换。
原始片段:
js
r.ipcMain.handle("getIsLicenseVIP",async()=>{const e=await C("1cJwF4oVPI8XCMkphqjG");return!(!e||!e.licenseCode)&&(await R(e.licenseCode)).success})
替换成:
js
r.ipcMain.handle("getIsLicenseVIP",async()=>!0/* padding */)
后半段用 JS 注释填充,让替换前后字节长度一致。这样不用重新构建 ASAR,也不会引起包内偏移变化。
这个技巧适合这种场景:
- 目标代码是 ASCII JavaScript 片段
- 原始片段稳定且唯一
- 新逻辑比旧逻辑短
- 可以用注释填充剩余长度
- 只需要改一个判断点
PoC:本地 VIP 授权补丁器
下面是完整 PowerShell 脚本。它会定位当前目录下的 resources\app.asar,搜索原始许可证校验片段,备份 ASAR,然后写入等长补丁。
powershell
param(
[string]$AppDir = $PSScriptRoot,
[switch]$NoBackup
)
$ErrorActionPreference = "Stop"
function Find-Bytes {
param(
[byte[]]$Haystack,
[byte[]]$Needle
)
if ($Needle.Length -eq 0 -or $Haystack.Length -lt $Needle.Length) {
return -1
}
for ($i = 0; $i -le $Haystack.Length - $Needle.Length; $i++) {
$matched = $true
for ($j = 0; $j -lt $Needle.Length; $j++) {
if ($Haystack[$i + $j] -ne $Needle[$j]) {
$matched = $false
break
}
}
if ($matched) {
return $i
}
}
return -1
}
$asarPath = Join-Path $AppDir "resources\app.asar"
if (-not (Test-Path -LiteralPath $asarPath)) {
throw "app.asar not found: $asarPath. Run this script in the app directory, or pass -AppDir."
}
$encoding = [System.Text.Encoding]::ASCII
$originalText = 'r.ipcMain.handle("getIsLicenseVIP",async()=>{const e=await C("1cJwF4oVPI8XCMkphqjG");return!(!e||!e.licenseCode)&&(await R(e.licenseCode)).success})'
$patchedPrefix = 'r.ipcMain.handle("getIsLicenseVIP",async()=>!0/*'
$patchedSuffix = '*/)'
$patchedMarker = 'r.ipcMain.handle("getIsLicenseVIP",async()=>!0'
$paddingLength = $originalText.Length - $patchedPrefix.Length - $patchedSuffix.Length
if ($paddingLength -lt 0) {
throw "Patch length calculation failed."
}
$patchedText = $patchedPrefix + (" " * $paddingLength) + $patchedSuffix
$originalBytes = $encoding.GetBytes($originalText)
$patchedBytes = $encoding.GetBytes($patchedText)
$markerBytes = $encoding.GetBytes($patchedMarker)
$bytes = [System.IO.File]::ReadAllBytes($asarPath)
if ((Find-Bytes -Haystack $bytes -Needle $markerBytes) -ge 0) {
Write-Host "[+] VIP patch already exists."
Write-Host "[+] Current status: getIsLicenseVIP always returns true; local VIP is enabled."
exit 0
}
$offset = Find-Bytes -Haystack $bytes -Needle $originalBytes
if ($offset -lt 0) {
throw "Expected VIP license check code was not found. Version may be unsupported or already modified."
}
if (-not $NoBackup) {
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backupPath = "$asarPath.bak-$timestamp"
Copy-Item -LiteralPath $asarPath -Destination $backupPath -Force
Write-Host "[+] Backup written: $backupPath"
}
[System.Array]::Copy($patchedBytes, 0, $bytes, $offset, $patchedBytes.Length)
[System.IO.File]::WriteAllBytes($asarPath, $bytes)
$verifyBytes = [System.IO.File]::ReadAllBytes($asarPath)
if ((Find-Bytes -Haystack $verifyBytes -Needle $markerBytes) -lt 0) {
throw "Patch verification failed after write."
}
Write-Host "[+] VIP patch written successfully."
Write-Host "[+] Fully quit and reopen the app to get local VIP access."
运行方式:
powershell
cd "目标客户端安装目录"
powershell -NoProfile -ExecutionPolicy Bypass -File .\vip-register.ps1
如果不想在当前目录运行,也可以指定应用目录:
powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\vip-register.ps1 -AppDir "目标客户端安装目录"
补丁成功后,重新打开客户端即可看到本地 VIP 状态生效。
验证点
补丁后在 ASAR 中应能搜到:
text
getIsLicenseVIP",async()=>!0
如果重复运行脚本,会提示:
text
[+] VIP patch already exists.
[+] Current status: getIsLicenseVIP always returns true; local VIP is enabled.
这说明补丁器支持幂等检测,不会重复破坏文件。
修复建议
这个问题不是某个 if 写错了,而是授权架构上的天然弱点。只要关键权益完全依赖本地代码裁决,客户端就有被 patch 的空间。
更合理的修复方向包括:
- 高价值权益由服务端强制校验,客户端只展示服务端授权结果。
- VIP 功能调用服务端接口时二次校验权限,而不是只信任本地
isVIP。 - 离线许可证只作为短期凭据,定期和服务端换取可撤销票据。
- 对 ASAR 和关键 bundle 做完整性校验,并把校验结果纳入功能解锁逻辑。
- 使用代码签名、混淆、反调试和自校验提高补丁成本。
不过需要承认:只要功能必须完全离线可用,且攻击者拥有本地文件读写权限,本地授权绕过就很难被彻底消除。防护目标更多是提高成本、缩短离线授权有效期,以及避免把真正高价值能力完全放在客户端本地。
总结
这次 VIP 绕过的关键不是伪造注册码,而是识别授权链路中的最后一个信任点:
text
前端不验 license
↓
前端只信主进程 IPC 返回值
↓
主进程 IPC 逻辑在本地 ASAR 中
↓
修改 getIsLicenseVIP 返回 true
↓
本地 VIP 状态成立
RSA 验签、机器码绑定、加密缓存都在保护授权数据,但最终的授权结论仍然由本地可修改代码输出。漏洞的根因正在这里。