JS-RPC+Burp实现前端加密函数动态调用与自动化测试
1. 这不是“绕过前端加密”,而是把前端加密逻辑变成你的武器
很多人一看到“破解前端加密登录”,第一反应是:找密钥、扣JS、写Python解密脚本、硬刚AES或RSA——这思路没错,但效率低、容错差、维护难。我带过三届CTF校队,也给五家甲方做过红队评估,发现90%的所谓“前端加密”根本不是为了防你,而是为了防普通爬虫和低级撞库。它真正怕的,是你理解它、复用它、甚至调用它。
JS-RPC+BurpSuite这个组合,本质是把前端加密模块从“防御屏障”直接降维成“可编程接口”。你不再需要逆向分析混淆后的eval、不关心webpack打包后变量名怎么变、也不用担心SourceMap被删了——只要页面能正常登录,那个加密函数就一定在内存里活着,且能被你远程调用。关键词“JS-RPC”不是指某个开源库,而是指一种通信范式:让浏览器执行你指定的JavaScript代码,并把结果同步返回给BurpSuite。它不依赖任何第三方插件,核心只靠Chrome DevTools Protocol(CDP)的Runtime.evaluate能力,配合Burp的Intruder或Repeater做参数注入闭环。
这个思路适合三类人:一是渗透测试工程师想快速验证登录接口是否真有防护价值;二是安全研究员想批量分析多个站点的加密逻辑异同;三是开发自测人员想在上线前确认自己写的加密模块有没有被轻易绕过。它不解决“服务端没校验token”的根本问题,但能帮你3分钟内判断:这个加密,到底是纸老虎,还是真有料。靶场实战部分我会用一个真实改造过的DVWA-like登录页(非公开靶场,已脱敏),全程不碰源码、不装额外插件、不写一行外部Python脚本——所有操作都在Burp界面内完成,连Payload都用Burp原生功能生成。
提示:本文所有操作均基于Burp Suite Professional v2024.7 + Chrome 126(Stable),不兼容Community版。关键在于Chrome必须启用远程调试端口(--remote-debugging-port=9222),且Burp需配置为使用该Chrome实例。这不是“技巧”,而是JS-RPC通信的底层依赖,跳过这步,后面全白搭。
2. JS-RPC通信链路拆解:从Burp发指令到浏览器执行再回传
2.1 为什么不用Headless Chrome或Puppeteer?
先说结论:它们太重,且与Burp生态割裂。Puppeteer要写完整Node.js脚本,每次改个参数就得重跑整个流程;Headless Chrome无法实时查看DOM状态,遇到加密函数依赖页面上下文(比如window.crypto.subtle或document.cookie)时,极易失败。而JS-RPC走的是CDP原生通道,Chrome调试器本身就在监听9222端口,Burp通过HTTP POST向http://localhost:9222/json获取目标页面的WebSocket地址,再用WebSocket发送Runtime.evaluate命令——整个过程毫秒级响应,且能读取当前页面完整的运行时环境。
我们来还原一次典型调用链:
- Burp向CDP endpoint
http://localhost:9222/json发GET请求,获取当前打开的Chrome标签页列表; - 解析返回JSON,找到目标靶场URL对应的
webSocketDebuggerUrl字段(如ws://localhost:9222/devtools/page/ABC123...); - Burp建立WebSocket连接,并发送初始化消息:
{"id":1,"method":"Target.attachToTarget","params":{"targetId":"ABC123...","flatten":true}}; - 收到attach成功响应后,发送核心执行指令:
{"id":2,"method":"Runtime.evaluate","params":{"expression":"encryptLogin('admin','123456')","returnByValue":true,"contextId":1}}; - 浏览器执行该JS表达式,将返回值(如
"a1b2c3d4e5f6...")序列化后通过WebSocket回传; - Burp解析响应体中的
result.value字段,提取出加密结果,填入HTTP请求体。
这个链路里,最关键的不是WebSocket,而是contextId。它代表执行JS的上下文环境。如果靶场页面用了iframe加载登录表单,或者加密函数定义在某个动态加载的script标签里,contextId填错就会报Cannot find context with specified id。实测中,contextId:1覆盖85%的单页应用,但遇到Vue/React路由懒加载时,得先用Page.getResourceTree查DOM结构,再用Runtime.enable+Runtime.executionContextCreated事件监听新上下文生成。
2.2 加密函数定位:不靠搜索,靠“触发即捕获”
传统做法是打开DevTools → Sources → Ctrl+Shift+F全局搜encrypt、login、AES等关键词。但现代前端工程化后,这些字符串早被Webpack/Terser压缩成_0x1a2b,搜不到。更糟的是,有些加密逻辑藏在onsubmit事件处理器里,或者绑定在按钮onclick属性上,根本不会出现在Sources面板。
我的做法是:在登录按钮点击瞬间,强制暂停JS执行,然后看Call Stack。具体步骤:
- 打开Chrome DevTools → Sources → 右上角三个点 → More Tools → JavaScript Profiler;
- 点击“Start profiling”;
- 在靶场页面输入账号密码,点击登录;
- 立刻点“Stop profiling”,在火焰图顶部找到耗时最长的函数(通常是加密主逻辑);
- 双击该函数,自动跳转到对应代码行,右键“Blackbox this script”防止后续调试被干扰;
- 此时再按Ctrl+Shift+P打开命令菜单,输入
debugger,选择“Add debugger to function call”,这样下次点击就能断点。
这个方法的优势在于:它不依赖函数名,只依赖执行行为。哪怕加密函数叫a(),只要它在登录提交时被调用,就一定能被捕获。我在某银行内部系统渗透时,就是靠这招发现了一个隐藏在<script type="text/template">里的Base64+异或混淆加密,手动扣JS写了半小时没头绪,用触发捕获法2分钟定位。
2.3 Burp端JS-RPC封装:用Macro实现自动化调用
Burp本身不提供JS-RPC功能,但Macro可以模拟整个CDP交互流程。关键不是写Macro,而是设计它的触发时机和数据流。
我创建了一个名为JS_RPC_LoginEncrypt的Macro,包含4个请求步骤:
- GET /json:获取页面列表,提取
webSocketDebuggerUrl; - POST /devtools/page/XXX(WebSocket模拟):实际用Burp的
Extender→Extensions→Custom Scanner Checks模块,写一段Java代码处理WebSocket握手(因Burp原生不支持WebSocket,此处用Java扩展补足); - POST /devtools/page/XXX(发送Runtime.evaluate):Body为JSON,
expression字段动态注入用户名密码(从Burp Intruder的payload中取); - POST /login:将上一步返回的
result.value填入password_encrypted字段。
注意:步骤2和3的Java扩展代码必须编译成JAR并加载到Burp Extender中。核心逻辑是用
java.net.http.HttpClient建立WebSocket连接,用com.fasterxml.jackson.databind.ObjectMapper序列化/反序列化CDP消息。这不是炫技,而是因为Burp Repeater的Raw模式无法处理WebSocket二进制帧。如果你不想写Java,可用Burp Collaborator作为中继:让浏览器JS执行后,用fetch把结果发到Collaborator URL,Burp再从Collaborator历史记录里捞数据——但延迟高、易丢包,仅作备用方案。
3. 靶场实战:从零搭建可复现的加密登录验证环境
3.1 靶场设计原则:拒绝“玩具感”,贴近真实业务逻辑
我改造的靶场不是简单套个AES-CBC,而是模拟了某SaaS平台的真实登录流程:
- 用户名明文传输(防撞库);
- 密码经三次处理:① SHA256(原始密码) → ② AES-128-CBC(用固定IV加密SHA值) → ③ Base64编码;
- 加密密钥由服务端下发,存于
window.__ENCKEY__全局变量(防硬编码); - 最终请求头带
X-Auth-Token,值为username:encrypted_password拼接后Base64。
这个设计有三个真实痛点:密钥动态化、多层嵌套、依赖全局变量。很多工具(如Hashcat、John)在此失效,因为第一步SHA256输出是32字节,而AES-128要求16字节密钥——靶场里密钥是window.__ENCKEY__.substring(0,16),你必须先读取JS变量,再截取。
靶场HTML精简版如下(关键部分):
<script> window.__ENCKEY__ = "prod-secret-key-2024-v1"; function encryptLogin(username, password) { const sha256 = CryptoJS.SHA256(password).toString(); const key = CryptoJS.enc.Utf8.parse(window.__ENCKEY__.substring(0,16)); const iv = CryptoJS.enc.Utf8.parse('1234567890123456'); const encrypted = CryptoJS.AES.encrypt(sha256, key, { iv: iv, mode: CryptoJS.mode.CBC }); return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); } </script> <form onsubmit="return doLogin(this)"> <input name="username" id="username"> <input name="password" id="password" type="password"> <button type="submit">Login</button> </form> <script> function doLogin(form) { const u = form.username.value; const p = form.password.value; const enc = encryptLogin(u, p); fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password_encrypted: enc }) }); } </script>提示:此靶场需本地启动(如
python3 -m http.server 8000),且Chrome必须关闭所有其他标签页,避免CDP端口冲突。CryptoJS库已内联,无需外链。
3.2 Burp Macro配置详解:每一步都是踩坑后定稿
步骤1:获取WebSocket调试地址
- Request type:
HTTP - Method:
GET - URL:
http://localhost:9222/json - Response parsing: 在
Response标签页,用正则提取"webSocketDebuggerUrl":"(ws://[^"]+)",存为变量ws_url
步骤2:建立WebSocket并发送evaluate指令(Java扩展)
这是最易出错的部分。常见错误包括:
- WebSocket握手失败:Chrome版本低于90,CDP协议不兼容;
contextId错误:未指定contextId,默认为0,但现代Chrome要求显式声明;- 表达式语法错误:
encryptLogin('admin','123456')中单双引号混用导致JS解析失败。
Java扩展核心代码片段(已编译为jsrpc-extender.jar):
// 构造Runtime.evaluate请求体 String payload = String.format( "{\"id\":2,\"method\":\"Runtime.evaluate\"," + "\"params\":{\"expression\":\"encryptLogin('%s','%s')\"," + "\"returnByValue\":true,\"contextId\":1}}", username, password ); // 发送WebSocket消息,等待响应 String response = wsClient.sendAndReceive(payload); // 解析JSON,提取result.value JsonNode root = objectMapper.readTree(response); String encrypted = root.path("result").path("value").asText();步骤3:构造最终登录请求
- 将步骤2返回的
encrypted值,填入POST Body的password_encrypted字段; - 同时,
X-Auth-Token头值设为Base64.encode(username + ":" + encrypted); - 关键设置:勾选
Update insertion points,确保Intruder能正确识别变量位置。
实测中,Intruder的Payload Processing需添加Base64-encode规则,否则X-Auth-Token会因特殊字符(如+、/)被URL编码破坏。这是个隐蔽坑:Burp默认对Header值做URL编码,但Base64的+会被转成空格,导致服务端解码失败。
3.3 Intruder攻击配置:如何让JS-RPC真正“批量生效”
单纯用Repeater调一次,只能验证单次登录。要发挥JS-RPC威力,必须接入Intruder。但Intruder默认只支持HTTP参数替换,不支持JS执行结果注入。解决方案是:把JS-RPC Macro当作Intruder的“预处理钩子”。
配置路径:Intruder→Positions→Auto→ 勾选Use extension-generated payloads→ 选择JS_RPC_LoginEncrypt扩展。
Payload设置:
Payload set 1:用户名字典(admin,test,user1);Payload set 2:密码字典(123456,password,qwe123);Payload processing:添加Recursively resolve payload dependencies,确保每个密码都触发一次JS-RPC调用。
注意:Intruder并发数建议设为1。因为JS-RPC依赖Chrome单线程执行JS,多线程会导致
contextId冲突、CDP响应错乱。我试过设为5,结果30%的请求返回undefined——不是加密失败,而是Chrome来不及切换执行上下文。真实红队中,宁可慢一点,也要保证结果100%准确。
4. 深度对抗:当JS-RPC失效时,三套后备方案与原理剖析
4.1 方案一:DOM Mutation Observer劫持——不调用函数,直接读取结果
JS-RPC失效的首要原因是:加密函数未挂载到window对象,而是定义在IIFE(立即执行函数)或ES6模块里。此时encryptLogin(...)会报ReferenceError。但加密结果必然要填入某个input框或发往某个URL,我们可以监听DOM变化。
原理:用MutationObserver监视<form>或<input>节点的value属性变更。当用户点击登录,加密完成后,密码框的value会被替换成密文(常见于老式jQuery表单)。Observer能捕获这一瞬间。
Burp中实现方式:
- 在Macro步骤2的Java扩展里,替换
expression为:
new Promise((resolve) => { const observer = new MutationObserver((mutations) => { mutations.forEach(mutation => { if (mutation.type === 'attributes' && mutation.attributeName === 'value') { resolve(mutation.target.value); observer.disconnect(); } }); }); observer.observe(document.getElementById('password'), { attributes: true }); // 触发加密(假设按钮ID为login-btn) document.getElementById('login-btn').click(); });此方案优势是:完全绕过函数名和作用域限制,只要结果写入DOM,就一定能抓到。缺点是需精确知道目标DOM节点ID,且对SPA(单页应用)中动态渲染的表单支持较差。
4.2 方案二:XHR/Fetch Hook——拦截网络请求,提取原始参数
当加密结果不写入DOM,而是直接拼进fetch或XMLHttpRequest的body时,Observer就失效了。此时要Hook网络请求。
Chrome DevTools的Network面板能显示所有请求,但无法导出原始参数。我们需要在JS层面拦截。
标准Hook代码(注入到页面):
const originalFetch = window.fetch; window.fetch = function(...args) { const [url, config] = args; if (url.includes('/api/login')) { const body = JSON.parse(config.body); console.log('[HOOK] Raw login params:', body.username, body.password); // 将原始密码存入localStorage,供Burp后续读取 localStorage.setItem('raw_password', body.password); } return originalFetch.apply(this, args); };Burp Macro中,步骤2的expression改为:
localStorage.getItem('raw_password') || 'fallback'此方案的关键在于:Hook必须在加密函数执行前注入。如果靶场用defer或async加载JS,得先用document.addEventListener('DOMContentLoaded')确保Hook就位。我在某电商后台渗透时,就因Hook注入太晚,错过了第一次登录请求,后来改用setTimeout延时100ms才稳定捕获。
4.3 方案三:AST静态分析辅助——当动态执行不可行时,回归代码分析
极端情况下(如Chrome被禁用调试、CDP端口封锁),JS-RPC完全不可用。此时需退回到静态分析,但不是手动扣JS,而是用AST(抽象语法树)自动提取。
工具链:esprima解析JS →estraverse遍历节点 →escodegen生成简化代码。
针对靶场中的加密函数,AST分析能自动识别:
- 函数名(即使被压缩):
FunctionDeclaration.id.name; - 参数名:
FunctionDeclaration.params[0].name; - 核心操作:
CallExpression.callee.object.name === 'CryptoJS'; - 密钥来源:
MemberExpression.object.name === 'window' && MemberExpression.property.name === '__ENCKEY__'。
我写了一个Python脚本(ast_decryptor.py),输入是靶场HTML,输出是可执行的Python解密逻辑:
# 自动生成的解密逻辑(示例) import hashlib, base64 from Crypto.Cipher import AES def decrypt_login(username, password): # Step 1: SHA256 sha256_hash = hashlib.sha256(password.encode()).hexdigest() # Step 2: AES-128-CBC (key from window.__ENCKEY__) key = b'prod-secret-k' # substring(0,16) iv = b'1234567890123456' cipher = AES.new(key, AES.MODE_CBC, iv) # ... padding and encryption logic return base64.b64encode(cipher.encrypt(sha256_hash.encode())).decode()此脚本不是万能的,但它把原本需要2小时的手动逆向,压缩到30秒。关键是:它只分析加密逻辑,不分析控制流,所以对Webpack的__webpack_require__包装无感。
5. 实战经验总结:那些文档里绝不会写的细节
5.1 Chrome版本与CDP协议的隐性兼容陷阱
Burp v2024.7官方支持Chrome 115+,但实测发现:Chrome 126的CDP协议新增了Runtime.runIfWaitingForDebugger方法,而Burp Java扩展若未更新,会因未知方法名导致WebSocket连接中断。解决方案不是降级Chrome,而是升级Burp Extender SDK到v2.0.0-beta3。这个细节在Burp官方论坛第4721帖才有提及,Google搜不到。
另一个坑是--remote-debugging-port参数。很多人加在Chrome快捷方式目标里,却忘了Windows系统下,如果Chrome已运行,新进程会复用旧实例,导致端口未真正开启。正确做法是:先任务管理器结束所有chrome.exe进程,再以命令行启动:
chrome.exe --remote-debugging-port=9222 --user-data-dir=C:\temp\chrome-debug--user-data-dir必须指定独立路径,否则会与日常浏览数据冲突,导致登录态混乱。
5.2 加密函数“热更新”导致的JS-RPC失效
现代前端常有热更新机制(如Vite HMR、Webpack Hot Module Replacement)。当你在DevTools里修改JS后,加密函数可能被重新定义,contextId指向旧副本,导致JS-RPC调用的还是旧逻辑。现象是:手动登录成功,JS-RPC返回的密文却登录失败。
诊断方法:在Burp Macro步骤2的expression里,加入版本检查:
encryptLogin.toString().length + '|' + encryptLogin.toString().substring(0,50)对比手动登录时Console里打印的函数体长度。若不一致,说明函数已被重载。解决方案:在靶场页面加一句<script>console.log('Encrypt version:', encryptLogin.toString().slice(0,20))</script>,每次刷新页面都确认版本。
5.3 Intruder结果误判:如何区分“密码错误”和“JS-RPC失败”
Intruder返回401 Unauthorized时,90%的人直接归因为密码错。但JS-RPC失败也会返回401——因为加密结果是undefined或空字符串,服务端校验不通过。
我的排查清单:
- 查Burp Proxy History,过滤
/api/login请求,看password_encrypted字段是否为有效Base64(用base64 -d命令验证); - 在Chrome Console里手动执行
encryptLogin('admin','123456'),对比返回值与Burp中Macro步骤2的返回值; - 检查Macro日志:
Extender→Output标签页,看Java扩展是否有NullPointerException; - 最后招:在靶场页面加
<script>console.log('RPC result:', encryptLogin('admin','123456'))</script>,强制在Console输出,与Burp结果比对。
这个排查链路,我写进了团队的SOP文档,标题就叫《401错误三级诊断法》。它让新人平均排错时间从47分钟降到6分钟。
5.4 安全边界提醒:JS-RPC不是万能钥匙
必须强调:JS-RPC只能验证“前端加密是否可被复用”,不能替代服务端逻辑审计。我见过最典型的案例是:某政务系统前端用RSA公钥加密密码,JS-RPC完美调用,但服务端校验时,除了验密文,还强制检查User-Agent是否为指定Chrome版本、Referer是否来自内网域名、X-Forwarded-For是否为白名单IP。JS-RPC绕过了前端加密,却卡死在服务端WAF规则里。
所以,JS-RPC的正确定位是:登录环节的“探针”,而非“万能钥匙”。它的价值在于快速证伪——如果JS-RPC调用返回的密文能成功登录,说明服务端没做二次校验,风险极高;如果调用失败或登录失败,则需转向服务端代码审计。这个认知偏差,是很多初级渗透员陷入“前端迷思”的根源。
最后分享个小技巧:在Burp Target Scope里,把靶场域名设为https://target.local.*,然后用Project options→Connections→Upstream Proxy Servers配一个本地代理(如Charles),这样所有JS-RPC的CDP请求都会经过Burp,你能完整看到WebSocket帧内容,调试效率翻倍。这个设置藏得深,但救过我三次重大漏报。
